From 84d05a3f597c7bcbab1136867aa73635f873981d Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:42:53 +0800 Subject: [PATCH 01/94] Align the 26-key letter hints with the reference skin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corner marks were an older scheme of our own (a=~, s=!, d=@ …). They now follow the reference product's map (ficon_text in input_kbd_pinyin26.xml and input_kbd_english26.xml): the same key carries the full-width mark in Chinese and the ASCII mark in English, the top row keeps 1–0, and a, s, z, c, m are the same in both languages. A long press now opens only the letter in both cases, upper first, as the reference does (long_press_bubble_text); the mark stays on the swipe-up gesture (fusiyin_keycode). The comma/period key also commits the mark above it on a swipe up. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 + ...aceCursorAndLetterHintsInstrumentedTest.kt | 41 ++++++++++++----- .../openime/keyboard/LetterHintPolicy.kt | 46 ++++++++++--------- .../keyboard/Pinyin26KeyboardRenderer.kt | 12 ++--- .../slacker/openime/LetterHintPolicyTest.kt | 42 ++++++++--------- docs/DECISIONS.md | 4 ++ 6 files changed, 87 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 640a2e6e..b824d4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 26 键字母键角标改按参考产品逐键对齐:英文 a=- s=/ d=: f=; g=( h=) j=~ k=' l=" z=@ x=_ c=# v=& b=? n=! m=…,中文为同键位的全角形(d=: f=; g=( h=) j=~ k=“ l=” x=. v=、 b=? n=! m=…,a、s、z、c、m 两语相同);上滑仍是输入角标,长按改为只弹字母大小写两项(大写在前),与参考一致;逗号键新增上滑输入句号。 + ## [0.0.10-beta.1] - 2026-10-14 第十个测试版:对齐参考产品(横屏留白、单手模式、键盘调节),新增双拼、五笔、手写与自研拼音引擎,语音模型可自行导入,并新增不含语音模型的精简安装包。从 0.0.9-beta.1 覆盖安装即可,用户数据保留。 diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index 17961f56..de877f33 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -304,14 +304,16 @@ class SpaceCursorAndLetterHintsInstrumentedTest { check(harness.awaitMain { true }) } + // The reference product's mark map (LetterHintPolicy). private val chineseHints = mapOf( 'q' to "1", 'w' to "2", 'e' to "3", 'r' to "4", 't' to "5", 'y' to "6", 'u' to "7", 'i' to "8", - 'o' to "9", 'p' to "0", 'a' to "~", 's' to "!", 'd' to "@", 'f' to "#", 'g' to "¥", 'h' to "%", - 'j' to "&", 'k' to "*", 'l' to "?", 'z' to "(", 'x' to ")", 'c' to "-", 'v' to "_", - 'b' to ":", 'n' to ";", 'm' to "/", + 'o' to "9", 'p' to "0", 'a' to "-", 's' to "/", 'd' to ":", 'f' to ";", 'g' to "(", 'h' to ")", + 'j' to "~", 'k' to "“", 'l' to "”", 'z' to "@", 'x' to ".", 'c' to "#", 'v' to "、", + 'b' to "?", 'n' to "!", 'm' to "…", ) private val englishHints = chineseHints + mapOf( - 's' to "!", 'g' to "$", 'l' to "?", 'z' to "(", 'x' to ")", 'b' to ":", 'n' to ";", + 'd' to ":", 'f' to ";", 'g' to "(", 'h' to ")", 'j' to "~", 'k' to "'", 'l' to "\"", + 'x' to "_", 'v' to "&", 'b' to "?", 'n' to "!", ) @Test @@ -361,15 +363,17 @@ class SpaceCursorAndLetterHintsInstrumentedTest { } @Test - fun longPressOffersTheHintInAPopup() = withKeyboard { harness, recorder, keyboard -> + fun longPressOffersTheLetterInBothCases() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } - listOf('w' to "2", 'd' to "@", 'k' to "*", 'v' to "_").forEach { (letter, hint) -> + listOf('w' to "W", 'd' to "D", 'k' to "K", 'v' to "V").forEach { (letter, upper) -> val key = settled(harness, keyboard, "key:$letter") + // The popup lists both cases, upper first; a tap on a cell types it. assertTrue(harness.awaitMain { key.performLongClick() }) - // The popup lists the other case, then the hint; a tap on its cell types it. - assertTrue("popup cell $hint", harness.awaitMain { chooseFromPopup(keyboard, hint) }) + assertTrue("popup lower cell $letter", harness.awaitMain { chooseFromPopup(keyboard, letter.toString()) }) + assertTrue(harness.awaitMain { key.performLongClick() }) + assertTrue("popup upper cell $upper", harness.awaitMain { chooseFromPopup(keyboard, upper) }) } - assertEquals(listOf("2", "@", "*", "_"), recorder.characters.toList()) + assertEquals(listOf("w", "W", "d", "D", "k", "K", "v", "V"), recorder.characters.toList()) } /** Taps the long-press popup cell showing [text]; false when no such cell is on screen. */ @@ -390,16 +394,29 @@ class SpaceCursorAndLetterHintsInstrumentedTest { } @Test - fun theSwipeSwitchTurnsOffSwipeButNotTheHints() = withKeyboard { harness, recorder, keyboard -> + fun swipeUpOnTheCommaKeyTypesThePeriod() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + swipeUp(harness, settled(harness, keyboard, "key-punctuation")) + assertEquals(listOf("。"), recorder.characters.toList()) + + recorder.characters.clear() + harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } + swipeUp(harness, settled(harness, keyboard, "key-punctuation")) + assertEquals(listOf("."), recorder.characters.toList()) + } + + @Test + fun theSwipeSwitchTurnsOffSwipeButKeepsTheLongPress() = withKeyboard { harness, recorder, keyboard -> ImeSettingsRepository.saveSwipeUpDigits(context, false) harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } val q = settled(harness, keyboard, "key:q") swipeUp(harness, q) assertTrue("swipe is off: ${recorder.characters}", recorder.characters.isEmpty()) harness.awaitMain { assertTrue("1" in texts(q)); true } + // The long press offers the two cases; the digit is only on the swipe. assertTrue(harness.awaitMain { q.performLongClick() }) - assertTrue("popup cell 1", harness.awaitMain { chooseFromPopup(keyboard, "1") }) - assertEquals("long press still works", listOf("1"), recorder.characters.toList()) + assertTrue("popup cell q", harness.awaitMain { chooseFromPopup(keyboard, "q") }) + assertEquals("long press still works", listOf("q"), recorder.characters.toList()) } @Test 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 4066d195..0493cac2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt @@ -1,13 +1,15 @@ package llc.slacker.openime.keyboard /** - * The digit or symbol printed in the top-right corner of each 26-key letter key - * (the scheme Sogou, iFlytek and WeChat Keyboard use): the top row carries - * 1–0, the other rows carry punctuation. Swipe up on the key, or long press it, - * commits the hint instead of the letter. + * The digit or symbol printed in the top-right corner of each 26-key letter key, + * on the reference product's map: the top row carries 1–0, every other key + * carries one mark. Chinese mode prints the full-width mark a Chinese IME types, + * English mode the ASCII one; the two layouts differ only where a full-width + * mark exists. * - * Chinese mode commits the full-width marks a Chinese IME types; English mode - * the ASCII ones. The two layouts differ only where a full-width mark exists. + * Swipe up on the key commits the hint. A long press opens the letter in both + * cases, upper first, as the reference keyboard does — it does not offer the + * hint. */ internal object LetterHintPolicy { private val DIGITS = mapOf( @@ -15,16 +17,21 @@ internal object LetterHintPolicy { 'y' to "6", 'u' to "7", 'i' to "8", 'o' to "9", 'p' to "0", ) - private val ENGLISH_SYMBOLS = mapOf( - 'a' to "~", 's' to "!", 'd' to "@", 'f' to "#", 'g' to "$", - 'h' to "%", 'j' to "&", 'k' to "*", 'l' to "?", - 'z' to "(", 'x' to ")", 'c' to "-", 'v' to "_", - 'b' to ":", 'n' to ";", 'm' to "/", + /** Marks that are the same in both languages: ASCII, or no full-width form. */ + private val SHARED_SYMBOLS = mapOf( + 'a' to "-", 's' to "/", 'z' to "@", 'c' to "#", 'm' to "…", ) - private val CHINESE_SYMBOLS = ENGLISH_SYMBOLS + mapOf( - 's' to "!", 'g' to "¥", 'l' to "?", - 'z' to "(", 'x' to ")", 'b' to ":", 'n' to ";", + private val ENGLISH_SYMBOLS = SHARED_SYMBOLS + mapOf( + 'd' to ":", 'f' to ";", 'g' to "(", 'h' to ")", + 'j' to "~", 'k' to "'", 'l' to "\"", + 'x' to "_", 'v' to "&", 'b' to "?", 'n' to "!", + ) + + private val CHINESE_SYMBOLS = SHARED_SYMBOLS + mapOf( + 'd' to ":", 'f' to ";", 'g' to "(", 'h' to ")", + 'j' to "~", 'k' to "“", 'l' to "”", + 'x' to ".", 'v' to "、", 'b' to "?", 'n' to "!", ) /** The hint of [letter] (lower case), or null for none. */ @@ -32,12 +39,9 @@ internal object LetterHintPolicy { 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. + * What a long press on a letter key offers, in popup order: the letter in + * both cases, upper first. */ - 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) - } + fun caseChoices(letter: Char): List = + listOf(letter.uppercaseChar().toString(), letter.lowercaseChar().toString()) } 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 10ee2f69..fee3022d 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -156,9 +156,11 @@ internal class Pinyin26KeyboardRenderer( onCommitCharacter(spec.comma) }.apply { tag = "key-punctuation" - contentDescription = "${spec.comma},长按输入${spec.period}" + contentDescription = "${spec.comma},上滑或长按输入${spec.period}" setSecondaryVisible(true) setSecondaryAlpha(1f) + // Up swipes the period, as the reference keyboard does. + onSwipeUp = { onCommitCharacter(spec.period) } setOnLongClickListener { onCommitCharacter(spec.period) true @@ -216,12 +218,10 @@ internal class Pinyin26KeyboardRenderer( 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. + // Long press opens the letter in both cases: slide to one and lift to + // type it, as the reference keyboard does. setOnLongClickListener { - val upperShown = currentMainText.firstOrNull()?.isUpperCase() == true - val choices = LetterHintPolicy.longPressChoices(character, english, upperShown, hintsEnabled()) - onShowChoicePopup(this, choices) + onShowChoicePopup(this, LetterHintPolicy.caseChoices(character)) true } } diff --git a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt index bff9484b..8c24da1f 100644 --- a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt @@ -34,31 +34,31 @@ class LetterHintPolicyTest { } } + /** The reference product's English marks, key by key. */ @Test - fun englishUsesAsciiMarks() { - assertEquals("!", LetterHintPolicy.hint('s', english = true)) - assertEquals("$", LetterHintPolicy.hint('g', english = true)) - assertEquals("?", LetterHintPolicy.hint('l', english = true)) - assertEquals("(", LetterHintPolicy.hint('z', english = true)) - assertEquals(")", LetterHintPolicy.hint('x', english = true)) - assertEquals(":", LetterHintPolicy.hint('b', english = true)) - assertEquals(";", LetterHintPolicy.hint('n', english = true)) + fun englishFollowsTheReferenceMarks() { + assertEquals( + listOf("-", "/", ":", ";", "(", ")", "~", "'", "\"") + + listOf("@", "_", "#", "&", "?", "!", "…"), + "asdfghjkl".map { LetterHintPolicy.hint(it, english = true) } + + "zxcvbnm".map { LetterHintPolicy.hint(it, english = true) }, + ) } + /** The reference product's Chinese marks: the full-width form of the same row. */ @Test - fun chineseUsesFullWidthMarksWhereOneExists() { - assertEquals("!", LetterHintPolicy.hint('s', english = false)) - assertEquals("¥", LetterHintPolicy.hint('g', english = false)) - assertEquals("?", LetterHintPolicy.hint('l', english = false)) - assertEquals("(", LetterHintPolicy.hint('z', english = false)) - assertEquals(")", LetterHintPolicy.hint('x', english = false)) - assertEquals(":", LetterHintPolicy.hint('b', english = false)) - assertEquals(";", LetterHintPolicy.hint('n', english = false)) + fun chineseFollowsTheReferenceMarks() { + assertEquals( + listOf("-", "/", ":", ";", "(", ")", "~", "“", "”") + + listOf("@", ".", "#", "、", "?", "!", "…"), + "asdfghjkl".map { LetterHintPolicy.hint(it, english = false) } + + "zxcvbnm".map { LetterHintPolicy.hint(it, english = false) }, + ) } @Test fun marksWithoutAFullWidthFormAreTheSameInBothLanguages() { - "adfhjkcvm".forEach { + "aszcm".forEach { assertEquals("$it", LetterHintPolicy.hint(it, true), LetterHintPolicy.hint(it, false)) } } @@ -70,9 +70,9 @@ class LetterHintPolicyTest { } @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)) + fun longPressOffersTheLetterInBothCasesUpperFirst() { + assertEquals(listOf("A", "a"), LetterHintPolicy.caseChoices('a')) + assertEquals(listOf("Q", "q"), LetterHintPolicy.caseChoices('q')) + assertEquals(listOf("S", "s"), LetterHintPolicy.caseChoices('s')) } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 473c945e..9c86ad2c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -138,3 +138,7 @@ Add new entries at the end. Two committed Rime schemas (`double_pinyin_flypy`, `double_pinyin`) share the luna_pinyin dictionary; the keyboard is the plain 26-key surface and a settings choice picks the scheme. The chord tables come from rime-double-pinyin (GPL-3.0, credited in the schema header). The reference product ships the same two named schemes (`shuangpin_xiaohe` / `shuangpin_ziranma`, `oime_engine::ShuangPinScheme`); its mapping is compiled into native code without an extractable table, and 小鹤/自然码 chords are standardized layouts, so the standard tables are the same mapping. *Reason:* keyboard-type parity with the reference product without Lua or external dependencies. + +- **Letter hints follow the reference skin key by key.** + The corner marks on the 26-key letters are the reference product's own map (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml`, `input_kbd_english26.xml`, attribute `ficon_text`): the same key carries the full-width mark in Chinese and the ASCII mark in English, and the top row carries 1–0. A long press opens only the letter in both cases, upper first (`long_press_bubble_text`); the mark belongs to the swipe-up gesture (`fusiyin_keycode`), and the comma/period key also commits the mark above it (。 / .) on an up swipe. + *Reason:* the previous marks (a=~, s=!, d=@ …) were an older scheme of our own; these values are read from the shipped skin instead of guessed. The reference lists the lower case first on P and L only; we keep a single rule (upper first) on every key, and the digit row is unchanged. From 108be29b93d4c3f89cb30fffa2c628b53d85eae0 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:16:34 +0800 Subject: [PATCH 02/94] Align key weights, the nine-key column and candidate spacing with the reference skin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 26-key draws A and L half a letter wider (skin weight 15 against 10) and the last row's 分词 / 退格 with the same ratio, instead of nine equal letters. - The nine-key and stroke side column is 删除 / 重输 / 发送 with send twice as tall; the separate zero key goes away and the space bar carries the corner 0 (an up swipe types it, behind the 上滑数字 setting). - Candidate items pad 8dp per side, so more candidates fit on the strip. Co-Authored-By: Claude Code --- CHANGELOG.md | 3 ++ .../KeyboardBottomRowInstrumentedTest.kt | 38 ++++++++++++++++ ...aceCursorAndLetterHintsInstrumentedTest.kt | 18 ++++++++ .../keyboard/CandidateBarController.kt | 4 +- .../openime/keyboard/ImeKeyboardView.kt | 24 +++++++--- .../keyboard/Pinyin26KeyboardRenderer.kt | 14 ++++-- .../keyboard/Pinyin9KeyboardRenderer.kt | 18 +++----- .../keyboard/SpaceVoiceGestureController.kt | 44 ++++++++++++++++++- .../openime/keyboard/SpaceVoiceKeyFactory.kt | 15 +++++-- .../keyboard/StrokeKeyboardRenderer.kt | 13 +++--- docs/DECISIONS.md | 4 ++ 11 files changed, 164 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b824d4dd..9f3c1fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 26 键的 A、L 两个边键各加宽半个字母(与参考皮肤 15:10 的键宽一致),末行「分词」「退格」按同一比例加宽(原来是 1.25 倍)。 +- 九键与笔画键盘右侧一列改为「删除、重输、发送」三键,发送键占两格高;原先单独的「0」键取消,改印在空格键右上角,上滑空格输入 0(26 键空格不带角标;跟随「上滑输入数字」开关)。 +- 候选栏的候选间距收紧(每侧内边距 12dp → 8dp),同屏能多显示几个候选。 - 26 键字母键角标改按参考产品逐键对齐:英文 a=- s=/ d=: f=; g=( h=) j=~ k=' l=" z=@ x=_ c=# v=& b=? n=! m=…,中文为同键位的全角形(d=: f=; g=( h=) j=~ k=“ l=” x=. v=、 b=? n=! m=…,a、s、z、c、m 两语相同);上滑仍是输入角标,长按改为只弹字母大小写两项(大写在前),与参考一致;逗号键新增上滑输入句号。 ## [0.0.10-beta.1] - 2026-10-14 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt index 0f1fd8ed..ab355eec 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt @@ -8,6 +8,8 @@ import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.voice.VoiceModelLifecycleState import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -79,6 +81,42 @@ class KeyboardBottomRowInstrumentedTest { } } + /** The reference draws this column as delete, retype and a send key twice as tall. */ + @Test + fun theNineKeySideColumnIsDeleteRetypeAndATallSend() = withKeyboard { harness, _, keyboard -> + listOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE).forEach { mode -> + harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); keyboard.setMode(mode); true } + settled(harness, keyboard, "key-space") + val ok = harness.awaitMain { + assertNull("$mode has no separate zero key", keyboard.findViewWithTag("key-9:0")) + val retype = keyboard.findViewWithTag("key-retype") + val enter = keyboard.findViewWithTag("key-enter") + val backspace = keyboard.findViewWithTag("key-backspace") + assertNotNull("$mode retype", retype) + assertNotNull("$mode send", enter) + assertNotNull("$mode delete", backspace) + assertEquals("$mode delete matches retype", retype.height.toFloat(), backspace.height.toFloat(), 2f) + assertEquals("$mode send is twice as tall", (retype.height * 2).toFloat(), enter.height.toFloat(), 2f) + true + } + check(ok) + } + } + + /** The reference widens A and L by half a letter (weight 15 against 10). */ + @Test + fun theEdgeLetterKeysAreHalfALetterWider() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + settled(harness, keyboard, "key:q") + harness.awaitMain { + fun width(tag: String) = keyboard.findViewWithTag(tag).width + assertEquals("A is half a letter wider", width("key:s") * 1.5f, width("key:a").toFloat(), 2f) + assertEquals("L is half a letter wider", width("key:s") * 1.5f, width("key:l").toFloat(), 2f) + assertEquals("the middle letters stay equal", width("key:s"), width("key:d")) + true + } + } + @Test fun commaKeyTapsCommaAndLongPressesPeriodInBothLanguages() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index de877f33..bf982b43 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -393,6 +393,24 @@ class SpaceCursorAndLetterHintsInstrumentedTest { assertTrue("a tap types the letter, not the hint: ${recorder.characters}", "1" !in recorder.characters) } + /** The reference prints 0 on the nine-key and stroke space bar and types it on an up swipe. */ + @Test + fun swipeUpOnTheNineKeySpaceTypesZero() = withKeyboard { harness, recorder, keyboard -> + listOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE).forEach { mode -> + harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); keyboard.setMode(mode); true } + recorder.characters.clear() + recorder.spaces = 0 + val space = settled(harness, keyboard, "key-space") + harness.awaitMain { assertTrue("$mode space carries the 0 mark", "0" in texts(space)); true } + swipeUp(harness, space) + assertEquals("$mode up swipe on the space bar types 0", listOf("0"), recorder.characters.toList()) + recorder.characters.clear() + tap(harness, space) + assertEquals("$mode the tap still types one space", 1, recorder.spaces) + assertEquals("$mode a tap must not type the mark", emptyList(), recorder.characters.toList()) + } + } + @Test fun swipeUpOnTheCommaKeyTypesThePeriod() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } 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 037c7065..db45dbca 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -340,7 +340,9 @@ internal class CandidateBarController( textSize = ImeTypographyTokens.CANDIDATE_SP maxLines = 1 includeFontPadding = false - setPadding(toPx(12), 0, toPx(12), 0) + // The reference item pads 2.7% of the strip per side and + // adds 4 units between items; 8dp keeps that rhythm. + setPadding(toPx(8), 0, toPx(8), 0) isClickable = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, 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 9f523d45..5373af69 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -158,6 +158,9 @@ open class ImeKeyboardView( /** How often a deferred row rebuild re-checks whether the press ended. */ private const val ROW_REBUILD_POLL_MS = 40L + /** Surfaces whose space bar carries the reference keyboard's 0 mark. */ + private val SPACE_HINT_MODES = setOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE) + /** Slider drags preview the strength at most this often. */ private const val STRENGTH_PREVIEW_INTERVAL_MS = 80L @@ -210,6 +213,12 @@ open class ImeKeyboardView( }, onCursorStep = ::moveCursorFromSpace, onCursorModeChanged = ::lockBottomRowForCursorDrag, + // The reference keyboard prints 0 on the nine-key and stroke space bar + // and types it on an up swipe; the 26-key space bar has no corner mark. + hintSwipeEnabled = { + mode in SPACE_HINT_MODES && ImeSettingsRepository.loadSwipeUpDigits(context) + }, + onHintSwipe = { commitKeyboardCharacter("0") }, ) /** @@ -242,11 +251,12 @@ open class ImeKeyboardView( private val spaceVoiceKeyFactory: SpaceVoiceKeyFactory by lazy { SpaceVoiceKeyFactory( gestureController = spaceVoiceGestureController, - createBaseKey = { label, onTap -> + createBaseKey = { label, secondary, onTap -> key( text = label, func = true, - secondary = null, + secondary = secondary, + allowSecondary = true, mainTextSizeOverride = ImeTypographyTokens.BODY_SP, iconRes = R.drawable.ic_mic, onTap = { @@ -522,7 +532,7 @@ open class ImeKeyboardView( }, createBackspaceKey = ::backspaceKey, createSpaceVoiceKey = { label, onTap -> - spaceVoiceKey(label, white = true, onTap = onTap) + spaceVoiceKey(label, white = true, onTap = onTap, hint = "0") }, createSymbolRail = { requireNineKeySymbolRailController().buildRail() }, markSideKey = { key -> key.setTag(MARK_SIDE_KEY, true) }, @@ -564,7 +574,7 @@ open class ImeKeyboardView( }, createBackspaceKey = ::backspaceKey, createSpaceVoiceKey = { label, onTap -> - spaceVoiceKey(label, white = true, onTap = onTap) + spaceVoiceKey(label, white = true, onTap = onTap, hint = "0") }, // The nine-key rail: stroke glyphs are not digits, so it stays on symbols. createSymbolRail = { requireNineKeySymbolRailController().buildRail() }, @@ -2385,11 +2395,13 @@ open class ImeKeyboardView( label: String = "空格", white: Boolean = false, onTap: () -> Unit, + hint: String? = null, ): ImeKeyView = spaceVoiceKeyFactory.build( label = label, white = white, onTap = onTap, + hint = hint, ) /** Starts recording after the combined space key crosses the long-press threshold. */ @@ -3340,12 +3352,14 @@ open class ImeKeyboardView( weight: Float = 1f, mainTextSizeOverride: Float? = null, iconRes: Int = 0, + /** The space bar is a function key with the reference keyboard's 0 mark. */ + allowSecondary: Boolean = false, onTap: () -> Unit, ): ImeKeyView { return ImeKeyView( context, text = text, - secondary = if (func) null else secondary, + secondary = if (func && !allowSecondary) null else secondary, iconRes = iconRes, mainTextSize = mainTextSizeOverride ?: if (func) { ImeTypographyTokens.BODY_SP 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 fee3022d..3ae65198 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -61,19 +61,22 @@ internal class Pinyin26KeyboardRenderer( if (rowIndex == 2) { row.addView( shiftKey(shiftState), - flexKeyParams(1.25f), + flexKeyParams(EDGE_KEY_WEIGHT), ) } - rowText.forEach { character -> + rowText.forEachIndexed { index, character -> + // The reference widens A and L by half a letter (weight 15 + // against 10) and the function keys of the last row likewise. + val wide = rowIndex == 1 && (index == 0 || index == rowText.lastIndex) row.addView( letterKey(character, english, shiftState), - flexKeyParams(), + flexKeyParams(if (wide) EDGE_KEY_WEIGHT else 1f), ) } if (rowIndex == 2) { - row.addView(createBackspaceKey(), flexKeyParams(1.25f)) + row.addView(createBackspaceKey(), flexKeyParams(EDGE_KEY_WEIGHT)) } keyboardBody.addView(row, rowParams()) } @@ -248,6 +251,9 @@ internal class Pinyin26KeyboardRenderer( private companion object { const val SPLIT_GAP = 3.5f + + /** The reference skin's weight 15 against a letter's 10. */ + const val EDGE_KEY_WEIGHT = 1.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 1fdddd42..6f87e8e5 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -108,29 +108,23 @@ internal class Pinyin9KeyboardRenderer( orientation = LinearLayout.VERTICAL tag = "pinyin9-actions" } - // Keep the zero key separate from retype and enter, as on the phone keypad. - side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams(nineBodyHeightDp() / 4)) + // Delete, retype and send, with send twice as tall, as the reference + // keyboard draws this column; the 0 sits on the space bar's corner. + val quarter = nineBodyHeightDp() / 4 + side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams(quarter)) side.addView( createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetranslate).apply { tag = "key-retype" markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 4), - ) - side.addView( - createKey("0", true, null, ImeTypographyTokens.KEY_LETTER_SP) { onCommitCharacter("0") }.apply { - tag = "key-9:0" - contentDescription = "0" - markSideKey(this) - }, - sideKeyParams(nineBodyHeightDp() / 4), + sideKeyParams(quarter), ) side.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 4), + sideKeyParams(quarter * 2), ) if (splitLayout()) { // Keep all Chinese keys in their familiar three-by-three order. diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt index dc7686f4..ff9511d2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceGestureController.kt @@ -12,7 +12,9 @@ import kotlin.math.abs * A short press remains space. A deliberate horizontal drag enters cursor * movement before the long-press timeout. Holding still past Android's * configured long-press timeout arms voice; sliding upward then toggles the - * voice cancel preview. + * voice cancel preview. Upward movement before the timeout swipes the key's + * corner mark (the reference keyboard prints 0 on the nine-key and stroke + * space bar) on the surfaces that carry one. */ internal class SpaceVoiceGestureController( private val toPx: (Int) -> Int, @@ -25,6 +27,9 @@ internal class SpaceVoiceGestureController( private val onCursorStep: (Int) -> Unit, /** Called with the space key when a cursor drag starts and when it ends. */ private val onCursorModeChanged: (anchor: View, active: Boolean) -> Unit = { _, _ -> }, + /** True on a surface whose space bar carries a corner mark. */ + private val hintSwipeEnabled: () -> Boolean = { false }, + private val onHintSwipe: () -> Unit = {}, ) { private val handler = Handler(Looper.getMainLooper()) private val longPressTimeoutMs = ViewConfiguration.getLongPressTimeout().toLong() @@ -41,6 +46,10 @@ internal class SpaceVoiceGestureController( var cursorMode: Boolean = false private set + /** True when an upward swipe owns this gesture (it types the corner mark). */ + var hintMode: Boolean = false + private set + private var downX = 0f private var downY = 0f private var lastX = 0f @@ -66,6 +75,7 @@ internal class SpaceVoiceGestureController( trackingTouch = true active = false cursorMode = false + hintMode = false this.anchor = anchor this.pointerId = pointerId downX = rawX @@ -85,6 +95,21 @@ internal class SpaceVoiceGestureController( if (!active) { val horizontal = rawX - downX val vertical = rawY - downY + // Upward movement belongs to the corner mark on the surfaces that + // have one: it must win before the cursor drag is considered. + if ( + !cursorMode && + !hintMode && + hintSwipeEnabled() && + -vertical >= toPx(HINT_SWIPE_START_DP) && + -vertical > abs(horizontal) * 1.25f + ) { + hintMode = true + handler.removeCallbacks(armVoice) + anchor?.isPressed = false + return true + } + if ( !cursorMode && abs(horizontal) >= toPx(CURSOR_MODE_START_DP) && @@ -135,6 +160,21 @@ internal class SpaceVoiceGestureController( if (!trackingTouch) return false handler.removeCallbacks(armVoice) + if (hintMode) { + hintMode = false + trackingTouch = false + active = false + pointerId = -1 + downX = 0f + downY = 0f + lastX = 0f + cursorTravel = 0f + cancelPreview = false + anchor = null + if (!cancelled) onHintSwipe() + return true + } + if (cursorMode) { endCursorMode() trackingTouch = false @@ -181,6 +221,7 @@ internal class SpaceVoiceGestureController( trackingTouch = false active = false cursorMode = false + hintMode = false pointerId = -1 downX = 0f downY = 0f @@ -206,6 +247,7 @@ internal class SpaceVoiceGestureController( const val CANCEL_ENTER_DISTANCE_DP = 72 const val CANCEL_EXIT_DISTANCE_DP = 48 const val CURSOR_MODE_START_DP = 18 + const val HINT_SWIPE_START_DP = 18 const val CURSOR_STEP_DP = 12 } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt index a1efaa05..c684700a 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SpaceVoiceKeyFactory.kt @@ -9,7 +9,7 @@ import llc.slacker.openime.widget.ImeKeyView */ internal class SpaceVoiceKeyFactory( private val gestureController: SpaceVoiceGestureController, - private val createBaseKey: (String, () -> Unit) -> ImeKeyView, + private val createBaseKey: (label: String, secondary: String?, onTap: () -> Unit) -> ImeKeyView, private val canStartVoice: () -> Boolean, private val markWhiteKey: (ImeKeyView) -> Unit, private val onFeedback: () -> Unit, @@ -19,10 +19,11 @@ internal class SpaceVoiceKeyFactory( label: String, white: Boolean, onTap: () -> Unit, + hint: String? = null, ): ImeKeyView { var suppressNextTap = false var physicalTouchSequenceActive = false - return createBaseKey(label) { + return createBaseKey(label, hint) { if (suppressNextTap) { suppressNextTap = false } else { @@ -30,7 +31,15 @@ internal class SpaceVoiceKeyFactory( } }.apply { tag = "key-space" - contentDescription = "$label,点击空格,左右滑动移动光标,长按语音输入" + contentDescription = if (hint == null) { + "$label,点击空格,左右滑动移动光标,长按语音输入" + } else { + "$label,点击空格,上滑输入$hint,左右滑动移动光标,长按语音输入" + } + if (hint != null) { + setSecondaryVisible(true) + setSecondaryAlpha(1f) + } setOnLongClickListener { if (!canStartVoice()) return@setOnLongClickListener true diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt index dcfb92e0..8646779e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -97,20 +97,23 @@ internal class StrokeKeyboardRenderer( orientation = LinearLayout.VERTICAL tag = "stroke-actions" } - side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams()) + // Delete, retype and send, with send twice as tall, as the reference + // keyboard draws this column (the nine-key column has the same shape). + val sideQuarterDp = bodyHeightDp() / 4 + side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams(sideQuarterDp)) side.addView( createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetype).apply { tag = "key-retype" markSideKey(this) }, - sideKeyParams(), + sideKeyParams(sideQuarterDp), ) side.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(), + sideKeyParams(sideQuarterDp * 2), ) container.addView(side, columnParams(1f)) @@ -155,8 +158,8 @@ internal class StrokeKeyboardRenderer( private fun keyParams(heightDp: Int, weight: Float = 1f) = LinearLayout.LayoutParams(0, toPx(heightDp), weight) - private fun sideKeyParams() = - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(bodyHeightDp() / 3)) + private fun sideKeyParams(heightDp: Int) = + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(heightDp)) private fun columnParams(weight: Float) = LinearLayout.LayoutParams(0, toPx(bodyHeightDp()), weight) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9c86ad2c..18ce1d3e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -142,3 +142,7 @@ Add new entries at the end. - **Letter hints follow the reference skin key by key.** The corner marks on the 26-key letters are the reference product's own map (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml`, `input_kbd_english26.xml`, attribute `ficon_text`): the same key carries the full-width mark in Chinese and the ASCII mark in English, and the top row carries 1–0. A long press opens only the letter in both cases, upper first (`long_press_bubble_text`); the mark belongs to the swipe-up gesture (`fusiyin_keycode`), and the comma/period key also commits the mark above it (。 / .) on an up swipe. *Reason:* the previous marks (a=~, s=!, d=@ …) were an older scheme of our own; these values are read from the shipped skin instead of guessed. The reference lists the lower case first on P and L only; we keep a single rule (upper first) on every key, and the digit row is unchanged. + +- **Key weights and the nine-key column follow the reference skin.** + The 26-key letters A and L carry weight 15 against a letter's 10 (`input_kbd_pinyin26.xml:48,85` against `style.xml:300`), and the same ratio covers 分词 and 退格 in the last row (`cls_button_left_big` / `cls_button_right_big`, `style.xml:907-912`); openIME drew nine equal letters and 1.25 for the function keys. The nine-key and stroke side column is 删除 / 重输 / 发送 with send twice as tall (`input_kbd_pinyin9.xml:104-111`, `height_weight="2"`), and the space bar carries the corner 0 (`ficon_text="0"`, `fusiyin_keycode="0x3030"`) instead of a separate zero key: an up swipe on the space bar types it, behind the same 上滑数字 setting as the letter marks. Candidate items pad 8dp per side (`cls_option_canditem` `textpadding="0.027"` of the 308-unit strip plus `childpadding="4"`). + *Reason:* these are the reference product's shipped values, read from its skin files; the previous numbers were our own approximations. From 559c71134aa4c38ef2102f8e42f0c456b6ccf6d8 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:32 +0800 Subject: [PATCH 03/94] Align the nine-key reading rail with the reference filter list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail during composition is now the same flat list the symbols use: one reading per row at one text size, a hairline between rows, and no state drawn on any item — the pre-edit says which reading is current. The list keeps the user's scroll position until the open digits change, instead of re-centring on every keystroke. The reference ships one FilterListLayout for both jobs and gives the reading the symbol item's own style (cls_pinyin9_filter_button inherits cls_vertical_symbol_list_button, style.xml:1023-1032), with syllables one weight heavier at the same size (style.xml:112,121-122). The native-side evidence is recorded in docs/DECISIONS.md. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/NineKeyChineseInstrumentedTest.kt | 40 ++++++++++++ .../openime/keyboard/ImeThemeApplier.kt | 18 ++---- .../keyboard/NineKeySymbolRailController.kt | 61 +++++++++---------- .../openime/keyboard/SymbolRailRenderer.kt | 41 +++++++------ docs/DECISIONS.md | 5 ++ 6 files changed, 106 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3c1fde..a0a37f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 九键与笔画键盘右侧一列改为「删除、重输、发送」三键,发送键占两格高;原先单独的「0」键取消,改印在空格键右上角,上滑空格输入 0(26 键空格不带角标;跟随「上滑输入数字」开关)。 - 候选栏的候选间距收紧(每侧内边距 12dp → 8dp),同屏能多显示几个候选。 - 26 键字母键角标改按参考产品逐键对齐:英文 a=- s=/ d=: f=; g=( h=) j=~ k=' l=" z=@ x=_ c=# v=& b=? n=! m=…,中文为同键位的全角形(d=: f=; g=( h=) j=~ k=“ l=” x=. v=、 b=? n=! m=…,a、s、z、c、m 两语相同);上滑仍是输入角标,长按改为只弹字母大小写两项(大写在前),与参考一致;逗号键新增上滑输入句号。 +- 九键左侧栏的拼音索引对齐参考产品:与常用符号共用同一个列表,每个读法一行、同一字号(不再逐条缩放),条目间是细线,当前读法不再高亮填色(读数由拼音串本身显示);只有换了数字才回到列表顶部,其余刷新保持滚动位置。 ## [0.0.10-beta.1] - 2026-10-14 diff --git a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt index 528652d3..3cf76b71 100644 --- a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -125,6 +126,45 @@ class NineKeyChineseInstrumentedTest { } } + /** + * The reading list is the symbol rail's list with other items in it: one + * line per reading at one size (no per-item shrinking), a hairline between + * items, and no state drawn on any item — which reading is current shows in + * the pre-edit. The reference skin gives the reading item the symbol item's + * own style (`cls_pinyin9_filter_button` inherits + * `cls_vertical_symbol_list_button`) and draws the marks and the syllables + * at one size (`fi_vertical_symbol_list_text`, style.xml:112 and 1023-1032). + */ + @Test + fun theReadingRailIsAFlatListOfOneLineReadings() { + tapDigits("6464") + + val cells = harness.awaitMain { activity -> + val root = activity.findViewById(android.R.id.content) ?: return@awaitMain emptyList() + val content = findView(root) { it.tag == "nine-pinyin-panel" } as? ViewGroup + ?: return@awaitMain emptyList() + (0 until content.childCount).mapNotNull { content.getChildAt(it) as? TextView } + } + assertTrue("the rail must list readings: ${cells.map { it.text }}", cells.size >= 4) + assertTrue( + "every reading is one line: ${cells.map { it.maxLines }}", + cells.all { it.maxLines == 1 }, + ) + assertEquals( + "readings share one text size, so the list never reflows", + cells.map { it.textSize }.distinct().single(), + cells.first().textSize, + 0.01f, + ) + + val dividers = harness.awaitMain { activity -> + val root = activity.findViewById(android.R.id.content) ?: return@awaitMain -1 + val content = findView(root) { it.tag == "nine-pinyin-panel" } as? ViewGroup ?: return@awaitMain -1 + (0 until content.childCount).count { content.getChildAt(it).tag == "rail-divider" } + } + assertEquals("one hairline between neighbouring readings", cells.size - 1, dividers) + } + private fun assertComposition(expected: String) { val status = harness.awaitMain { activity -> currentStatus(activity).takeIf { it.startsWith("composition=$expected ") } 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 3eee034d..79224db6 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -525,18 +525,12 @@ internal class ImeThemeApplier( ) } - tag == "nine-pinyin-path-selected" -> { - val selected = ImeDrawableFactory.blend(t.primary, t.sideKeyBackground, 0.22f) - view.setTextColor(ImeSurfacePolicy.accentTextOn(t.primary, selected)) - view.background = statefulRounded( - selected, - ImeSurfacePolicy.pressedSurface(selected, t), - toPx(ImeGeometryTokens.KEY_RADIUS_DP), - ) - } - - tag == "nine-pinyin-path-filter" -> { - view.setTextColor(t.sideKeyText) + tag == "nine-pinyin-path-selected" || tag == "nine-pinyin-path-filter" -> { + // A reading is a list item, not a key: it carries no state of + // its own, exactly like the symbol cells beside it (the + // reference's cls_pinyin9_filter_button is the symbol item's + // style). Which reading is current is shown in the pre-edit. + view.setTextColor(t.keyText) view.background = statefulRounded( Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index e52c54a5..d4cadbca 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -43,6 +43,8 @@ internal class NineKeySymbolRailController( private var railMode = RailMode.SYMBOLS private var renderedChoices: List = emptyList() private var renderedSelected: NineKeyReading? = null + /** The open digits the list was built for: a new tail is a new list, so it starts at the top. */ + private var renderedDigits: String? = null init { composition.addTextChangedListener(object : TextWatcher { @@ -128,13 +130,14 @@ internal class NineKeySymbolRailController( return } - // Highlight the reading the visible pinyin shows, so the rail and the - // pre-edit text can never disagree. + // The reading the pre-edit is showing: the list leads with it and a tap + // on it cannot change anything. The pre-edit is where the reference + // shows which reading is current, so nothing is highlighted here. val shown = tail.filter { it in 'a'..'z' } val selected = choices.firstOrNull { it.coversAll && it.syllables.joinToString("") == shown } ?: choices.firstOrNull { shown.startsWith(it.syllables.joinToString("")) } ?: choices.first() - renderPinyinChoices(choices = choices, selected = selected) { chosen -> + renderPinyinChoices(digits = digits, choices = choices, selected = selected) { chosen -> onChooseReading(chosen) } } @@ -171,6 +174,8 @@ internal class NineKeySymbolRailController( railMode = RailMode.SYMBOLS renderedChoices = emptyList() renderedSelected = null + // The next composition builds a new list; it starts at the top. + renderedDigits = null (scroll.getChildAt(0) as? LinearLayout)?.tag = NINE_CONTENT_TAG if (scroll.scrollY != 0) scroll.post { scroll.scrollTo(0, 0) } scroll.contentDescription = "九键常用符号,上下滑动查看更多" @@ -178,6 +183,7 @@ internal class NineKeySymbolRailController( } private fun renderPinyinChoices( + digits: String, choices: List, selected: NineKeyReading, onSelect: (NineKeyReading) -> Unit, @@ -198,21 +204,21 @@ internal class NineKeySymbolRailController( ?.currentTextColor content.removeAllViews() - // One continuous panel with flat items (the theme paints it), not a - // stack of separate keys: it reads as a list to pick from. + // The same flat list the symbols use: one line per reading, a hairline + // between them, nothing highlighted. More readings fit by scrolling + // instead of by shrinking the text, and the item under the finger keeps + // its place when the list is rebuilt. content.tag = PINYIN_PANEL_TAG choices.forEachIndexed { index, choice -> + if (index > 0) SymbolRailRenderer.addDivider(content, toPx) val active = choice == selected content.addView( TextView(content.context).apply { - // A break after each apostrophe lets a long reading wrap into - // two lines instead of shrinking to nothing. - text = choice.display.replace("'", "'\u200B") - // Whole readings (ni'hao) can run long: let them wrap to two - // lines and shrink within a readable range. - setAutoSizeTextTypeUniformWithConfiguration( - 12, 18, 1, android.util.TypedValue.COMPLEX_UNIT_SP, - ) + text = choice.display + textSize = RAIL_ITEM_TEXT_SP + // The reference draws reading items a step heavier than the + // marks (weight 600 against 500) at the same size. + typeface = Typeface.create(RAIL_ITEM_TYPEFACE, Typeface.NORMAL) gravity = Gravity.CENTER tag = if (active) SELECTED_FILTER_TAG else FILTER_TAG contentDescription = @@ -225,10 +231,9 @@ internal class NineKeySymbolRailController( stateDescription = if (active) "已选择" else "未选择" } isSelected = active - typeface = if (active) Typeface.DEFAULT_BOLD else Typeface.DEFAULT isClickable = true isFocusable = true - maxLines = 2 + maxLines = 1 includeFontPadding = false setPadding(toPx(4), 0, toPx(4), 0) inheritedTextColor?.let(::setTextColor) @@ -237,12 +242,7 @@ internal class NineKeySymbolRailController( onSelect(choice) } }, - SymbolRailRenderer.cellParams( - content.context, - withGap = index < choices.lastIndex, - heightDp = cellHeightDp(), - toPx = toPx, - ), + SymbolRailRenderer.flatCellParams(heightDp = cellHeightDp(), toPx = toPx), ) } @@ -251,18 +251,13 @@ internal class NineKeySymbolRailController( renderedSelected = selected scroll.contentDescription = "九键拼音读法,上下滑动查看更多" onRailChanged(scroll) - val selectedIndex = choices.indexOf(selected).coerceAtLeast(0) - scroll.post { - val target = content.getChildAt(selectedIndex) ?: return@post - val viewport = scroll.height.coerceAtLeast(SymbolRailRenderer.cellHeightPx(content.context)) - val targetY = (target.top - (viewport - target.height) / 2).coerceAtLeast(0) - scroll.scrollTo(0, targetY) - } + // New digits are a new list: show it from its start. A refresh for the + // same digits leaves the scroll position where the user put it. + val tailChanged = renderedDigits != digits + renderedDigits = digits + if (tailChanged && scroll.scrollY != 0) scroll.post { scroll.scrollTo(0, 0) } } - private fun displayPath(path: String): String = - path.replace(" ", "'").replace("|", "'") - private fun speakablePath(path: String): String = path.replace(" ", "、").replace("|", "、").replace("'", "、") @@ -279,5 +274,9 @@ internal class NineKeySymbolRailController( const val SELECTED_FILTER_TAG = "nine-pinyin-path-selected" const val PINYIN_PANEL_TAG = "nine-pinyin-panel" const val MAX_VISIBLE_PATHS = 16 + + /** The symbol cells' own size: one list, one text size for both kinds of item. */ + const val RAIL_ITEM_TEXT_SP = 20f + const val RAIL_ITEM_TYPEFACE = "sans-serif-medium" } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt index abf4bacd..1b624ac1 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -93,24 +93,13 @@ internal object SymbolRailRenderer { inheritedTextColor?.let(preservedHeader::setTextColor) content.addView( preservedHeader, - cellParams(content.context, withGap = true, heightDp = cellHeightDp, toPx = toPx), + cellParams(content.context, heightDp = cellHeightDp, toPx = toPx), ) } // One flat list on one panel (the theme draws the panel), hairlines // between the items and faded edges: it reads as something to scroll, // not a column of separate keys. Three items show, level with the keys. - fun divider() { - content.addView( - View(content.context).apply { - tag = DIVIDER_TAG - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { - marginStart = toPx(DIVIDER_INSET_DP) - marginEnd = toPx(DIVIDER_INSET_DP) - }, - ) - } + fun divider() = addDivider(content, toPx) symbols.forEachIndexed { index, symbol -> if (index > 0) divider() content.addView( @@ -148,10 +137,30 @@ internal object SymbolRailRenderer { const val DIVIDER_TAG = "rail-divider" private const val DIVIDER_INSET_DP = 10 - private fun flatCellParams(heightDp: Int, toPx: (Int) -> Int) = + /** The hairline between two list items on the rail's panel. */ + fun addDivider(content: LinearLayout, toPx: (Int) -> Int) { + content.addView( + View(content.context).apply { + tag = DIVIDER_TAG + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { + marginStart = toPx(DIVIDER_INSET_DP) + marginEnd = toPx(DIVIDER_INSET_DP) + }, + ) + } + + + /** + * One rail list item: full width, the given height, no gap of its own. The + * symbol list and the nine-key reading list are the same list, so they use + * the same cell. + */ + fun flatCellParams(heightDp: Int, toPx: (Int) -> Int) = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(heightDp)) - fun cellParams(context: Context, withGap: Boolean, heightDp: Int = CELL_HEIGHT_DP, toPx: (Int) -> Int = { dp(context, it) }) = LinearLayout.LayoutParams( + fun cellParams(context: Context, heightDp: Int = CELL_HEIGHT_DP, toPx: (Int) -> Int = { dp(context, it) }) = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(heightDp), ).apply { @@ -160,8 +169,6 @@ internal object SymbolRailRenderer { height -= gap * 2 } - fun cellHeightPx(context: Context): Int = dp(context, CELL_HEIGHT_DP) - private fun symbolCell( context: Context, symbol: String, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 18ce1d3e..66145c03 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -146,3 +146,8 @@ Add new entries at the end. - **Key weights and the nine-key column follow the reference skin.** The 26-key letters A and L carry weight 15 against a letter's 10 (`input_kbd_pinyin26.xml:48,85` against `style.xml:300`), and the same ratio covers 分词 and 退格 in the last row (`cls_button_left_big` / `cls_button_right_big`, `style.xml:907-912`); openIME drew nine equal letters and 1.25 for the function keys. The nine-key and stroke side column is 删除 / 重输 / 发送 with send twice as tall (`input_kbd_pinyin9.xml:104-111`, `height_weight="2"`), and the space bar carries the corner 0 (`ficon_text="0"`, `fusiyin_keycode="0x3030"`) instead of a separate zero key: an up swipe on the space bar types it, behind the same 上滑数字 setting as the letter marks. Candidate items pad 8dp per side (`cls_option_canditem` `textpadding="0.027"` of the 308-unit strip plus `childpadding="4"`). *Reason:* these are the reference product's shipped values, read from its skin files; the previous numbers were our own approximations. + +- **The nine-key reading rail is the symbol rail's list (the reference filter list).** + During composition the nine-key side rail lists the leading syllables (pre-syllables) of the open digits: one reading per item, one line, one text size, a hairline between items, no state drawn on any item — the pre-edit shows which reading is current — and the list keeps the user's scroll position unless the open digits changed. Tapping a reading fixes that syllable; 退格 undoes it. + The reference ships one list for both jobs: the left column of `input_kbd_pinyin9.xml` is a single `FilterListLayout` whose item style is the symbol item's own (`cls_pinyin9_filter_button` inherits `cls_vertical_symbol_list_button`, `style.xml:1023-1032`), drawn at the symbol list's size (`fi_vertical_symbol_list_text` 47, `style.xml:112`) with readings one step heavier than marks (`pinyin9_filter_syllable` 600 against `pinyin9_filter_punc` 500, both 46, `style.xml:121-122`). The engine fills it with pre-syllables (`ConvertUtils::GetShowSyllableFilterList`, libshell.so 0x30a308, written by the flow behind `SyllableLattice::NineKeyPreSyllableFilter`) and a tap calls `ui::ButtonSyllableFilter::OnButtonUp` (libkeyboard.so 0x2cd944) → `BoardController::SelectUserSyllableFilter`; the chosen syllables stack up in `ImeData::InsertUserSyllableFilterSeq` and `PopUserSyllableFilterSeq` takes them back. + *Reason:* our rail drew the readings as a stack of separate keys, filled the current one with the accent colour and scrolled it to the middle on every keystroke. The list moved under the finger, and the highlighted item was the one a tap could not change. From 5cc04401b78cdea99aedf2b29c28a1b44e4cfd17 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:37 +0800 Subject: [PATCH 04/94] Type the zero through the space bar in the rail stress case The separate zero key went away with the reference-aligned side column, so the rail case swipes up on the space bar, the way the key works. Co-Authored-By: Claude Code --- scripts/typing_stress_e2e.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py index 27c5faee..f5247134 100644 --- a/scripts/typing_stress_e2e.py +++ b/scripts/typing_stress_e2e.py @@ -409,7 +409,12 @@ def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: dev.shell("input", "tap", str(int(x)), str(int(y))) time.sleep(0.25) expected += tag[len("punct:"):] - dev.tap_tag("key-9:0") + # The nine-key pad has no 0 key: the zero is the space bar's up swipe, + # as on the reference keyboard. + _, _, space_x, space_y = next(b for b in dev.bounds if b[0] == "key-space") + dev.shell("input", "swipe", str(int(space_x)), str(int(space_y)), + str(int(space_x)), str(int(space_y - 420)), "300") + time.sleep(0.5) expected += "0" minimum = min(minimum, len(expected)) elif name == "expand": From 1ccb9ef8579e9f554e9d2fa5c67a7c88fc994f5b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:38 +0800 Subject: [PATCH 05/94] Skip the stale-capture clipboard tests while this app is the active IME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When this app is the user's input method its service is bound in the test process and its clipboard listener captures every copy — including the fixture these two tests copy — by design, so the queued panel load they check is indistinguishable from the listener. They now hold the precondition they need instead of failing; a system IME active (CI) runs them as before. Co-Authored-By: Claude Code --- .../ClipboardRetentionInstrumentedTest.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt index fb5c75cf..40a8308f 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt @@ -2,12 +2,15 @@ package llc.slacker.openime import android.content.ClipData import android.content.ClipboardManager +import android.content.ComponentName import android.content.Context import android.os.PersistableBundle +import android.provider.Settings import android.view.View import android.view.ViewGroup import android.view.inputmethod.BaseInputConnection import android.widget.TextView +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -24,6 +27,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assume import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -72,8 +76,29 @@ class ClipboardRetentionInstrumentedTest { } } + /** + * The two "stale queued capture" tests below assert that nothing was + * written to the history. When this app is the user's active input method, + * its own service is bound in this process and its clipboard listener + * captures every copy — including the fixture these tests copy — by design + * (that is the history feature). The queued panel load is then + * indistinguishable from the listener, so those two tests need an + * emulator whose active IME is not this app; the harness runs them on CI, + * where the system IME is active. + */ + private fun assumeNoClipboardListenerOfThisApp() { + val context = ApplicationProvider.getApplicationContext() + val stored = Settings.Secure.getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) + val active = stored?.let(ComponentName::unflattenFromString) + Assume.assumeFalse( + "this app is the active IME (${active?.flattenToShortString()}), so its own clipboard listener captures the fixture", + active == ComponentName(context, LocalVoiceImeService::class.java), + ) + } + @Test fun leavingClipboardBeforePostedLoadDoesNotCaptureStaleClip() { + assumeNoClipboardListenerOfThisApp() lateinit var keyboard: ImeKeyboardView harness.awaitMain { activity -> ClipboardHistoryRepository.clearAll(activity) @@ -118,6 +143,7 @@ class ClipboardRetentionInstrumentedTest { @Test fun closingClipboardBeforePostedLoadDoesNotCaptureStaleClip() { + assumeNoClipboardListenerOfThisApp() lateinit var keyboard: ImeKeyboardView harness.awaitMain { activity -> ClipboardHistoryRepository.clearAll(activity) From ffe8db93103357964fb8972285d607a1ffe19046 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:38 +0800 Subject: [PATCH 06/94] Allow a side-by-side development build through openimeAppIdSuffix -PopenimeAppIdSuffix=.dev installs beside the release build (the owner's phone runs the .dev variant); release builds never pass it. Co-Authored-By: Claude Code --- app/build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f2257fd..12ded606 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,9 @@ android { ndkVersion = "27.0.12077973" defaultConfig { - applicationId = "llc.slacker.openime" + // A side-by-side development build (the owner's phone runs the .dev + // variant) passes -PopenimeAppIdSuffix=.dev; release builds never do. + applicationId = "llc.slacker.openime" + ((findProperty("openimeAppIdSuffix") as String?) ?: "") minSdk = 26 targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 5ed5710fdb00b797d2efe0409359bed8339d1fae Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:20 +0800 Subject: [PATCH 07/94] Rank candidates that spell out every typed letter above fragment matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing "dang" also produced the split da + "ng", and no syllable starts with "ng", so that path can never complete a reading of the input; the words for the settled "da" prefix (打, score 7798) nonetheless entered the candidate set and outranked the complete reading 当 (6899), so the space bar committed 打 and 当地 fell to rank 6 of "dangdi". Candidates now carry a coverage rank: a word whose reading covers the whole input sorts above one that only matches a prefix of it, and frequency decides inside each rank. "dang" -> 当 党 档, "dangdi" -> 当地 荡涤. Co-Authored-By: Claude Code --- .../slacker/openime/engine/PinyinEngine.kt | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt index 99305320..8e1992b1 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt @@ -36,7 +36,7 @@ class PinyinEngine internal constructor( val letters = normalize(input) if (letters.isEmpty() || letters.length > MAX_INPUT) return emptyList() val segmenter = segmenterFor(fuzzyRules) - val found = HashMap() + val found = HashMap() val budget = Budget() for (split in 0..letters.length) { @@ -52,20 +52,20 @@ class PinyinEngine internal constructor( for (path in headPaths) { if (budget.exhausted()) break if (tail.isEmpty()) { - collectExact(found, path.ids, path.fuzzy, budget) - collectPrefix(found, path.ids, path.fuzzy, budget, COMPLETION_WORDS) + collectExact(found, path.ids, path.fuzzy, budget, COMPLETE) + collectPrefix(found, path.ids, path.fuzzy, budget, COMPLETION_WORDS, COMPLETE) } else { // Words that already match the settled prefix stay candidates // while the next syllable is still being typed. An empty // prefix would match from the top of the table: skip it. if (path.ids.isNotEmpty()) { - collectPrefix(found, path.ids, path.fuzzy, budget, FRAGMENT_WORDS) + collectPrefix(found, path.ids, path.fuzzy, budget, FRAGMENT_WORDS, FRAGMENT) } for (edge in segmenter.syllablesStartingWith(tail, TAIL_SYLLABLES)) { if (budget.exhausted()) break val ids = path.ids.copyOf(path.ids.size + 1) ids[ids.size - 1] = edge.id - collectPrefix(found, ids, path.fuzzy || edge.fuzzy, budget, FRAGMENT_WORDS) + collectPrefix(found, ids, path.fuzzy || edge.fuzzy, budget, FRAGMENT_WORDS, FRAGMENT) budget.lookups++ } } @@ -73,7 +73,11 @@ class PinyinEngine internal constructor( } if (found.isEmpty()) return emptyList() return found.entries - .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .sortedWith( + compareBy> { it.value.coverage } + .thenByDescending { it.value.score } + .thenBy { it.key }, + ) .take(limit) .map { it.key } } @@ -148,23 +152,30 @@ class PinyinEngine internal constructor( } } - private fun collectExact(found: HashMap, ids: IntArray, fuzzy: Boolean, budget: Budget) { + private fun collectExact( + found: HashMap, + ids: IntArray, + fuzzy: Boolean, + budget: Budget, + coverage: Int, + ) { val start = lexicon.lowerBound(ids, ids.size) val end = lexicon.exactEnd(ids, ids.size) var taken = 0 for (word in start until end) { if (taken >= EXACT_WORDS) break taken++ - add(found, word, fuzzy, budget) + add(found, word, fuzzy, budget, coverage) } } private fun collectPrefix( - found: HashMap, + found: HashMap, ids: IntArray, fuzzy: Boolean, budget: Budget, cap: Int, + coverage: Int, ) { val start = lexicon.lowerBound(ids, ids.size) val end = lexicon.prefixEnd(ids, ids.size) @@ -172,11 +183,17 @@ class PinyinEngine internal constructor( for (word in start until end) { if (taken >= cap || budget.exhausted()) break taken++ - add(found, word, fuzzy, budget) + add(found, word, fuzzy, budget, coverage) } } - private fun add(found: HashMap, word: Int, fuzzy: Boolean, budget: Budget) { + private fun add( + found: HashMap, + word: Int, + fuzzy: Boolean, + budget: Budget, + coverage: Int, + ) { val text = lexicon.textOf(word) if (text.isEmpty()) return // A fuzzy reading carries a moderate penalty: a frequent fuzzy word can @@ -184,7 +201,12 @@ class PinyinEngine internal constructor( // solid literal match of similar frequency. val adjusted = lexicon.scoreOf(word) - if (fuzzy) FUZZY_SCORE_PENALTY else 0 val current = found[text] - if (current == null || adjusted > current) found[text] = adjusted + if (current == null) { + found[text] = Candidate(coverage, adjusted) + } else if (coverage < current.coverage || (coverage == current.coverage && adjusted > current.score)) { + current.coverage = coverage + current.score = adjusted + } budget.words++ } @@ -228,6 +250,12 @@ class PinyinEngine internal constructor( private class Edge(val to: Int, val word: Int, val fuzzy: Boolean) + /** + * One candidate: how much of the typed input the reading explains + * ([coverage], COMPLETE beats FRAGMENT) and the word's frequency score. + */ + private class Candidate(var coverage: Int, var score: Int) + private class State( val score: Double, val word: Int, @@ -245,6 +273,12 @@ class PinyinEngine internal constructor( private const val PATH_LIMIT = 32 private const val MAX_WORD_SYLLABLES = 6 private const val EXACT_WORDS = 48 + /** Coverage ranks first: a word whose reading spells out every typed + * letter (dang -> 当) always beats a more frequent word that only + * matches a prefix of it (dang -> 打, from da + a tail that never + * becomes a syllable). */ + private const val COMPLETE = 0 + private const val FRAGMENT = 1 private const val COMPLETION_WORDS = 64 private const val FRAGMENT_WORDS = 64 private const val WORDS_PER_SPAN = 24 From f921b1e44d25e94f4659eaedcf14b84c4f623dee Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:31:38 +0800 Subject: [PATCH 08/94] Offer the reference skin's URL marks in the nine-key rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An address or e-mail field now gets `. / www. .com .cn http wap. ftp:` on the nine-key rail instead of the punctuation marks, and no + cell, because that list belongs to the field. The values are the reference nine-key layout's own `uri_filter_list` attribute; only that layout declares one. Co-Authored-By: Claude Code --- .../openime/data/RailSymbolRepository.kt | 9 ++++++ .../openime/editor/EditorInfoAdapter.kt | 7 ++++ .../openime/keyboard/ImeKeyboardView.kt | 6 ++++ .../keyboard/NineKeySymbolRailController.kt | 32 +++++++++++++++---- .../slacker/openime/EditorInfoAdapterTest.kt | 31 ++++++++++++++++++ docs/DECISIONS.md | 5 +++ 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt b/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt index 7c1a0c64..8a54b211 100644 --- a/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/RailSymbolRepository.kt @@ -14,6 +14,15 @@ object RailSymbolRepository { val DEFAULT: List = listOf(",", "。", "?", "!", "、", ":", ";", "“", "”", "……") + /** + * What the rail offers in an address or e-mail field. The reference skin + * ships the same eight marks in the nine-key layout's `uri_filter_list` + * (skin/default/layout/input_kbd_pinyin9.xml); a URL is typed from these + * far more often than from punctuation, and the list is not editable + * because it belongs to the field, not to the user's rail. + */ + val URI: List = listOf(".", "/", "www.", ".com", ".cn", "http", "wap.", "ftp:") + /** A rail holds at most this many; each is a short symbol or text. */ const val MAX_SYMBOLS = 60 const val MAX_SYMBOL_LENGTH = 8 diff --git a/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt b/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt index f0e9db27..712d3fae 100644 --- a/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt @@ -118,4 +118,11 @@ object EditorInfoAdapter { fun allowCandidates(kind: EditorKind): Boolean = kind != EditorKind.PASSWORD fun isPassword(kind: EditorKind): Boolean = kind == EditorKind.PASSWORD + + /** + * Address and e-mail fields get the skin's URL rail instead of the + * punctuation rail. The reference nine-key layout carries the same list in + * its `uri_filter_list` attribute (skin/default/layout/input_kbd_pinyin9.xml). + */ + fun usesUriRail(kind: EditorKind): Boolean = kind == EditorKind.URL || kind == EditorKind.EMAIL } 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 5373af69..efccb0e1 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1685,6 +1685,10 @@ open class ImeKeyboardView( private fun currentImeOptions(): Int? = (context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions + /** The editor the keys are being drawn for; null outside a real input session. */ + private fun currentEditorInfo(): android.view.inputmethod.EditorInfo? = + (context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo + private fun syncEnterKeyPresentation(imeOptions: Int?) { val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return @@ -2263,6 +2267,8 @@ open class ImeKeyboardView( onChooseReading = ::chooseNineKeyReading, fixedPrefix = ::nineKeyFixedPrefix, onEditSymbols = ::openRailSymbolEditor, + // An address or e-mail field gets the skin's URL rail. + fieldKind = { EditorInfoAdapter.kind(currentEditorInfo()) }, ).also { nineKeySymbolRailController = it } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index d4cadbca..ec42a98f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -15,12 +15,14 @@ import llc.slacker.openime.candidate.CandidatePipeline import llc.slacker.openime.candidate.CandidateResolver import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.data.RailSymbolRepository +import llc.slacker.openime.editor.EditorInfoAdapter /** * Owns the Chinese 9-key side rail. * * With no composition it behaves as the normal vertically scrollable symbol - * rail. During composition, the same rail + * rail, or as the URL rail in an address or e-mail field (the reference skin's + * `uri_filter_list`). During composition, the same rail * becomes a directly selectable Pinyin list. This mirrors mature 9-key IMEs: * the user sees all useful spellings at once instead of cycling one hidden * option through a single button. @@ -36,6 +38,7 @@ internal class NineKeySymbolRailController( private val onChooseReading: (NineKeyReading) -> Unit, private val fixedPrefix: () -> String, private val onEditSymbols: () -> Unit, + private val fieldKind: () -> EditorInfoAdapter.EditorKind = { EditorInfoAdapter.EditorKind.TEXT }, ) { private enum class RailMode { SYMBOLS, PINYIN } @@ -73,14 +76,19 @@ internal class NineKeySymbolRailController( context = context, railTag = NINE_RAIL_TAG, contentTag = NINE_CONTENT_TAG, - contentDescription = "九键拼音筛选或常用符号,上下滑动查看更多", - symbols = commonSymbols(), + contentDescription = if (EditorInfoAdapter.usesUriRail(fieldKind())) { + "九键网址符号,上下滑动查看更多" + } else { + "九键拼音筛选或常用符号,上下滑动查看更多" + }, + symbols = railSymbols(), cellHeightDp = symbolCellHeightDp(), toPx = toPx, tagPrefix = "punct:", onCommit = onCommit, onFeedback = onFeedback, - onAdd = onEditSymbols, + // The URL list belongs to the field, so it cannot be edited. + onAdd = if (EditorInfoAdapter.usesUriRail(fieldKind())) null else onEditSymbols, ).also { rail = it railMode = RailMode.SYMBOLS @@ -161,15 +169,17 @@ internal class NineKeySymbolRailController( val scroll = rail ?: return if (railMode == RailMode.SYMBOLS && !force) return + val uri = EditorInfoAdapter.usesUriRail(fieldKind()) SymbolRailRenderer.populate( scroll = scroll, - symbols = commonSymbols(), + symbols = railSymbols(), cellHeightDp = symbolCellHeightDp(), toPx = toPx, tagPrefix = "punct:", onCommit = onCommit, onFeedback = onFeedback, - onAdd = onEditSymbols, + // The URL list belongs to the field, so it cannot be edited. + onAdd = if (uri) null else onEditSymbols, ) railMode = RailMode.SYMBOLS renderedChoices = emptyList() @@ -178,7 +188,8 @@ internal class NineKeySymbolRailController( renderedDigits = null (scroll.getChildAt(0) as? LinearLayout)?.tag = NINE_CONTENT_TAG if (scroll.scrollY != 0) scroll.post { scroll.scrollTo(0, 0) } - scroll.contentDescription = "九键常用符号,上下滑动查看更多" + scroll.contentDescription = + if (uri) "九键网址符号,上下滑动查看更多" else "九键常用符号,上下滑动查看更多" onRailChanged(scroll) } @@ -267,6 +278,13 @@ internal class NineKeySymbolRailController( /** The user's rail (ten common marks until edited), then + to edit it. */ private fun commonSymbols(): List = RailSymbolRepository.load(context) + /** + * The rail's symbols for this field: the fixed URL list in an address or + * e-mail field, the user's own marks everywhere else. + */ + private fun railSymbols(): List = + if (EditorInfoAdapter.usesUriRail(fieldKind())) RailSymbolRepository.URI else commonSymbols() + private companion object { const val NINE_RAIL_TAG = "nine-punct-stack" const val NINE_CONTENT_TAG = "nine-symbol-scroll-content" diff --git a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt index a85440d5..566c9f25 100644 --- a/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt +++ b/app/src/test/java/llc/slacker/openime/EditorInfoAdapterTest.kt @@ -64,6 +64,37 @@ class EditorInfoAdapterTest { assertEquals(KeyboardMode.ENGLISH_26, EditorInfoAdapter.defaultKeyboardMode(url)) } + @Test + fun onlyAddressAndEmailFieldsGetTheUriRail() { + listOf( + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_URI, + ).forEach { variation -> + assertTrue( + "variation $variation must take the URL rail", + EditorInfoAdapter.usesUriRail( + EditorInfoAdapter.kind(info(InputType.TYPE_CLASS_TEXT or variation)), + ), + ) + } + listOf( + InputType.TYPE_TEXT_VARIATION_NORMAL, + InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE, + InputType.TYPE_TEXT_VARIATION_PERSON_NAME, + InputType.TYPE_TEXT_VARIATION_PASSWORD, + ).forEach { variation -> + assertFalse( + "variation $variation keeps the punctuation rail", + EditorInfoAdapter.usesUriRail( + EditorInfoAdapter.kind(info(InputType.TYPE_CLASS_TEXT or variation)), + ), + ) + } + assertFalse(EditorInfoAdapter.usesUriRail(EditorInfoAdapter.kind(info(InputType.TYPE_CLASS_PHONE)))) + assertFalse(EditorInfoAdapter.usesUriRail(EditorInfoAdapter.kind(null))) + } + @Test fun ordinaryTextVariationsAreNotPasswords() { listOf( diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 66145c03..95bfd535 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -151,3 +151,8 @@ Add new entries at the end. During composition the nine-key side rail lists the leading syllables (pre-syllables) of the open digits: one reading per item, one line, one text size, a hairline between items, no state drawn on any item — the pre-edit shows which reading is current — and the list keeps the user's scroll position unless the open digits changed. Tapping a reading fixes that syllable; 退格 undoes it. The reference ships one list for both jobs: the left column of `input_kbd_pinyin9.xml` is a single `FilterListLayout` whose item style is the symbol item's own (`cls_pinyin9_filter_button` inherits `cls_vertical_symbol_list_button`, `style.xml:1023-1032`), drawn at the symbol list's size (`fi_vertical_symbol_list_text` 47, `style.xml:112`) with readings one step heavier than marks (`pinyin9_filter_syllable` 600 against `pinyin9_filter_punc` 500, both 46, `style.xml:121-122`). The engine fills it with pre-syllables (`ConvertUtils::GetShowSyllableFilterList`, libshell.so 0x30a308, written by the flow behind `SyllableLattice::NineKeyPreSyllableFilter`) and a tap calls `ui::ButtonSyllableFilter::OnButtonUp` (libkeyboard.so 0x2cd944) → `BoardController::SelectUserSyllableFilter`; the chosen syllables stack up in `ImeData::InsertUserSyllableFilterSeq` and `PopUserSyllableFilterSeq` takes them back. *Reason:* our rail drew the readings as a stack of separate keys, filled the current one with the accent colour and scrolled it to the middle on every keystroke. The list moved under the finger, and the highlighted item was the one a tap could not change. + +- **The nine-key rail turns into the URL rail in address and e-mail fields.** + Outside composition, the rail lists `.` `/` `www.` `.com` `.cn` `http` `wap.` `ftp:` when the editor is an address or e-mail field, and the user's own punctuation marks everywhere else; the + editor cell is absent in a URL field, because that list belongs to the field. + The reference nine-key layout declares the same eight marks on the same rail (`skin-full/assets/skin/default/layout/input_kbd_pinyin9.xml:11`, `uri_filter_list=". / www. .com .cn http wap. ftp:"`), and only the nine-key layout does: the stroke and number layouts carry the rail without it. + *Reason:* a URL is typed from those fragments far more often than from punctuation, and the reference product puts them exactly where our rail already is. From 3781cfb6ff5d5282386804705017b55f45b7964e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:34:48 +0800 Subject: [PATCH 09/94] Draw the first candidate in the accent, not on a filled tile The reference's first-item class takes the candidate font one weight heavier and the accent as its text colour, with no background (cls_option_first_canditem, style.xml:551-554; first_cand_font weight 500 against 400; first_cand_text_color rgba(79,132,255,1)). openIME drew it bold on a key-coloured tile. Co-Authored-By: Claude Code --- .../keyboard/CandidateBarController.kt | 29 +++++++++++++------ docs/DECISIONS.md | 5 ++++ 2 files changed, 25 insertions(+), 9 deletions(-) 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 db45dbca..25d818aa 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -363,21 +363,30 @@ internal class CandidateBarController( val word = item.getChildAt(0) as TextView if (word.text.toString() != candidate) word.text = candidate word.tag = if (index == 0) "candidate-first" else "candidate-word" - word.typeface = if (index == 0) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + // The first candidate is the same size in the accent colour one weight + // heavier, with no tile behind it: the reference's own first-item class + // (skin-full/assets/skin/default/style.xml:551-554) names + // first_cand_font (weight 500 against 400, style.xml:83,98) and + // first_cand_text_color (rgba(79,132,255,1), values/colors.xml:4). + word.typeface = if (index == 0) MEDIUM_WEIGHT else Typeface.DEFAULT val palette = tokens() - val normalBackground = - if (index == 0) palette.keyBackground else Color.TRANSPARENT + val pressedSurface = + if (index == 0) { + ImeSurfacePolicy.selectedSurface(palette) + } else { + palette.candidateBackground + } word.setTextColor( - palette.candidateText, - ) - item.background = statefulBackground( - normalBackground, if (index == 0) { - ImeSurfacePolicy.pressedSurface(normalBackground, palette) + ImeSurfacePolicy.selectedText(palette) } else { - ImeSurfacePolicy.pressedSurface(palette.candidateBackground, palette) + palette.candidateText }, + ) + item.background = statefulBackground( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(pressedSurface, palette), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) item.setOnTouchListener { _, event -> @@ -414,6 +423,8 @@ internal class CandidateBarController( } private companion object { + /** The reference's 500 weight: one step above the regular candidate. */ + val MEDIUM_WEIGHT: Typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) const val STRIP_LIMIT = 24 const val CANDIDATE_EXPAND_SWIPE_DP = 36 const val SCROLL_SETTLE_WINDOW_MS = 96L diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 95bfd535..993e3552 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -156,3 +156,8 @@ Add new entries at the end. Outside composition, the rail lists `.` `/` `www.` `.com` `.cn` `http` `wap.` `ftp:` when the editor is an address or e-mail field, and the user's own punctuation marks everywhere else; the + editor cell is absent in a URL field, because that list belongs to the field. The reference nine-key layout declares the same eight marks on the same rail (`skin-full/assets/skin/default/layout/input_kbd_pinyin9.xml:11`, `uri_filter_list=". / www. .com .cn http wap. ftp:"`), and only the nine-key layout does: the stroke and number layouts carry the rail without it. *Reason:* a URL is typed from those fragments far more often than from punctuation, and the reference product puts them exactly where our rail already is. + +- **The first candidate is the accent colour, one weight heavier, with no tile.** + The first item in the candidate strip is drawn in the theme's accent as text (contrast-corrected by `ImeSurfacePolicy.selectedText`) at the medium weight, and it has no background of its own; tapping it shows the accent-tinted pressed surface like any other item. + The reference style names the same treatment for its first item: `cls_option_first_canditem` (`skin-full/assets/skin/default/style.xml:551-554`) takes `first_cand_font`, which is the candidate font one weight step up (weight 500 against 400, `style.xml:83,98`), and `first_cand_text_color`, which is the accent `rgba(79,132,255,1)` in the light skin and `rgba(255,255,255,0.95)` in the dark one (`values/colors.xml:4`, `values/dark_colors.xml:4`). No first-item class carries a background. + *Reason:* openIME drew the first candidate as bold black text on a filled key-coloured tile, which is heavier than the reference and competes with the keys below. The colour follows the user's accent instead of the fixed blue in the skin, and passes the 4.5:1 text contrast rule. From 174aac8096085bcfe55e3088c0b25cf86829b4e0 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:47:40 +0800 Subject: [PATCH 10/94] Draw the expanded page the way the reference ships it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded candidates used to be the key grid itself: one bordered tile per candidate with the first one filled in the accent. The reference's 更多候选 page is a different surface, and its skin file says how it is built. The page now opens with 全部 / 单字 as one option group over the list (input_more_cand.xml:9-13), the list sits in one rounded card (radius 11, 2 edge, cls_morecand_bk), and the backspace floats in that card's bottom-right corner at 25.2% by 20.2% of the list, rounding only that corner with the card's own radius (bkround="0,0,11,0"). Every cell is the same flat label with a hairline edge (cls_morecands_button): the skin gives the expanded list no first-item class, so no cell carries the accent tile. 单字 keeps single characters and 全部 is the page's default, as the options declare. The corner key deletes one letter of the open composition, so the page stays open on the shorter list. The reference's left syllable list (FilterListLayout, 音节过滤) is not in this change: the 26-key path has no syllable-fixing state yet. Co-Authored-By: Claude Code --- .../CandidatePresentationInstrumentedTest.kt | 32 ++- .../keyboard/CandidateBarController.kt | 218 +++++++++++++++--- .../openime/keyboard/CandidateLayoutPolicy.kt | 17 ++ .../openime/keyboard/ImeKeyboardView.kt | 4 + .../openime/keyboard/ImeThemeApplier.kt | 63 ++++- .../openime/theme/ImeDrawableFactory.kt | 43 ++++ .../openime/CandidateLayoutPolicyTest.kt | 12 + docs/DECISIONS.md | 4 + 8 files changed, 356 insertions(+), 37 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt index 349ab0b2..2195c0ff 100644 --- a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt @@ -3,11 +3,13 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup import android.widget.EditText -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.keyboard.ImeKeyboardView import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test @@ -50,15 +52,37 @@ class CandidatePresentationInstrumentedTest { keyboard.findViewWithTag("candidate-expand").performClick() val overlay = keyboard.findViewWithTag("candidate-overlay") assertEquals(View.GONE, keyboard.findViewWithTag("keyboard-body").visibility) - // The expanded overlay is just the scrolling grid (no header row). - val scroll = overlay.getChildAt(0) as ScrollView + // The expanded overlay is the filter row over the list card. + val card = overlay.findViewWithTag("candidate-expanded-card") + assertNotNull(card) keyboard.renderState(state) assertSame(first, keyboard.findViewWithTag("candidate-first-row")) - assertSame(scroll, overlay.getChildAt(0)) + assertSame(card, overlay.findViewWithTag("candidate-expanded-card")) + + // 单字 keeps single characters only; this list has none, so the + // grid empties while the page stays open. + filterChip(overlay, "单字").performClick() + assertEquals(View.VISIBLE, overlay.visibility) + assertNull(keyboard.findViewWithTag("candidate-grid-first")) + filterChip(overlay, "全部").performClick() + assertNotNull(keyboard.findViewWithTag("candidate-grid-first")) + keyboard.renderState(ImeState()) assertEquals(View.GONE, overlay.visibility) true } } } + + private fun filterChip(root: ViewGroup, label: String): View { + val found = mutableListOf() + fun walk(view: View) { + if (view.tag == "candidate-filter-chip" && (view as? TextView)?.text?.toString() == label) { + found.add(view) + } + if (view is ViewGroup) for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + walk(root) + return found.single() + } } 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 25d818aa..0a4ba671 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -12,11 +12,13 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout import android.widget.HorizontalScrollView import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import llc.slacker.openime.R import llc.slacker.openime.setup.SetupUi import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeMotionTokens @@ -51,11 +53,14 @@ internal class CandidateBarController( private val onFeedback: () -> Unit, private val onCandidateSelected: (String) -> Unit, private val onCandidateLongPressed: (String) -> Unit, + private val onExpandedBackspace: () -> Unit, ) { private var renderedCandidates: List? = null private var renderedComposition: String? = null private var renderedExpandedCandidates: List? = null private var renderedExpandedComposition: String? = null + private var renderedExpandedFilter: ExpandedCandidateFilter? = null + private var expandedFilter = ExpandedCandidateFilter.ALL private var candidateTouchDownX = 0f private var candidateTouchDownY = 0f private var candidateTouchActive = false @@ -212,6 +217,8 @@ internal class CandidateBarController( expandedOpen = false renderedExpandedCandidates = null renderedExpandedComposition = null + renderedExpandedFilter = null + expandedFilter = ExpandedCandidateFilter.ALL syncExpandControl(candidates.isNotEmpty()) return } @@ -220,19 +227,21 @@ internal class CandidateBarController( expandedOpen && overlay.visibility == View.VISIBLE && renderedExpandedCandidates == candidates && - renderedExpandedComposition == compositionPreview + renderedExpandedComposition == compositionPreview && + renderedExpandedFilter == expandedFilter ) { return } val previousScroll = if (renderedExpandedComposition == compositionPreview) { - (overlay.getChildAt(0) as? ScrollView)?.scrollY ?: 0 + overlay.findViewWithTag(EXPANDED_SCROLL_TAG)?.scrollY ?: 0 } else { 0 } renderedExpandedCandidates = candidates.toList() renderedExpandedComposition = compositionPreview + renderedExpandedFilter = expandedFilter expandedOpen = true expandButton.rotation = 180f expandButton.contentDescription = "收起候选" @@ -246,11 +255,121 @@ internal class CandidateBarController( overlay.alpha = 0f overlay.translationY = toPx(8).toFloat() overlay.removeAllViews() - val scroll = ScrollView(context) + val scroll = expandedCandidateList(expandedFilter.apply(candidates)) + val card = expandedCard(scroll) + val page = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView( + createFilterRow(), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(FILTER_ROW_HEIGHT_UNITS), + ), + ) + addView( + card, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f, + ), + ) + } + overlay.addView( + page, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + + applyTheme() + if (previousScroll > 0) { + scroll.post { scroll.scrollTo(0, previousScroll) } + } + overlay.animate() + .alpha(1f) + .translationY(0f) + .setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS) + .setInterpolator(DecelerateInterpolator(1.5f)) + .start() + } + + fun resetExpandedState() { + expandedOpen = false + renderedExpandedCandidates = null + renderedExpandedComposition = null + renderedExpandedFilter = null + expandedFilter = ExpandedCandidateFilter.ALL + expandButton.rotation = 0f + expandButton.contentDescription = "展开更多候选" + } + + /** + * 全部 / 单字, right-aligned above the list (`input_more_cand.xml:7-14`). + * The chips repaint through their tags, so only their selected state is + * set here. + */ + private fun createFilterRow(): LinearLayout = + LinearLayout(context).apply { + tag = EXPANDED_FILTER_ROW_TAG + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL or Gravity.END + val container = LinearLayout(context).apply { + tag = EXPANDED_FILTER_CONTAINER_TAG + orientation = LinearLayout.HORIZONTAL + val inset = toPx(FILTER_CONTAINER_INSET_UNITS) + setPadding(inset, inset, inset, inset) + } + ExpandedCandidateFilter.entries.forEach { filter -> + container.addView( + createFilterChip(filter), + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f), + ) + } + addView( + container, + LinearLayout.LayoutParams( + toPx(FILTER_CONTAINER_WIDTH_UNITS), + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + } + + private fun createFilterChip(filter: ExpandedCandidateFilter): TextView = + TextView(context).apply { + tag = EXPANDED_FILTER_CHIP_TAG + text = filter.label + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER + includeFontPadding = false + isClickable = true + isFocusable = true + isSelected = filter == expandedFilter + contentDescription = "筛选${filter.label}" + if (Build.VERSION.SDK_INT >= 30) { + stateDescription = if (isSelected) "已选择" else "未选择" + } + setOnClickListener { + if (expandedFilter == filter) return@setOnClickListener + onFeedback() + expandedFilter = filter + // The whole page is rebuilt, so the chips are handed their new + // selected state with it; the cache key includes the filter. + renderExpanded( + open = true, + candidates = renderedExpandedCandidates.orEmpty(), + compositionPreview = renderedExpandedComposition.orEmpty(), + ) + } + } + + private fun expandedCandidateList(shown: List): ScrollView { + val scroll = ScrollView(context).apply { tag = EXPANDED_SCROLL_TAG } val column = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - if (candidates.isEmpty()) { + if (shown.isEmpty()) { column.addView( createEmptyLabel(), LinearLayout.LayoutParams( @@ -259,14 +378,14 @@ internal class CandidateBarController( ), ) } else { - expandedCandidateRows(candidates).forEach { chunk -> + expandedCandidateRows(shown).forEach { chunk -> val rowView = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { candidate -> rowView.addView( createExpandedCandidate(candidate).apply { - tag = if (candidate == candidates.first()) "candidate-grid-first" else "candidate-grid" + tag = if (candidate == shown.first()) "candidate-grid-first" else "candidate-grid" }, LinearLayout.LayoutParams( 0, @@ -298,33 +417,59 @@ internal class CandidateBarController( ViewGroup.LayoutParams.WRAP_CONTENT, ), ) - overlay.addView( - scroll, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 0, - 1f, - ), - ) + return scroll + } - applyTheme() - if (previousScroll > 0) { - scroll.post { scroll.scrollTo(0, previousScroll) } + /** + * The rounded list the candidates sit in, with the reference's own + * backspace key floating in the bottom-right corner of that list + * (`input_more_cand.xml:18-26`). + */ + private fun expandedCard(list: ScrollView): FrameLayout { + val card = FrameLayout(context).apply { + tag = EXPANDED_CARD_TAG + clipToOutline = true + addView( + list, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + addView( + ImageView(context).apply { + tag = EXPANDED_BACKSPACE_TAG + setImageResource(R.drawable.ic_backspace) + scaleType = ImageView.ScaleType.CENTER_INSIDE + contentDescription = "退格" + isClickable = true + isFocusable = true + setPadding(toPx(6), toPx(6), toPx(6), toPx(6)) + setOnClickListener { + onFeedback() + onExpandedBackspace() + } + }, + FrameLayout.LayoutParams(0, 0, Gravity.BOTTOM or Gravity.END), + ) } - overlay.animate() - .alpha(1f) - .translationY(0f) - .setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS) - .setInterpolator(DecelerateInterpolator(1.5f)) - .start() + // Fractions of the list, not fixed dp: the key keeps its share of the + // panel in both orientations and at every keyboard height. + card.addOnLayoutChangeListener { view, left, top, right, bottom, _, _, _, _ -> + sizeCornerBackspace(view, right - left, bottom - top) + } + return card } - fun resetExpandedState() { - expandedOpen = false - renderedExpandedCandidates = null - renderedExpandedComposition = null - expandButton.rotation = 0f - expandButton.contentDescription = "展开更多候选" + private fun sizeCornerBackspace(card: View, width: Int, height: Int) { + val key = card.findViewWithTag(EXPANDED_BACKSPACE_TAG) ?: return + val keyParams = key.layoutParams as? FrameLayout.LayoutParams ?: return + val targetWidth = (width * BACKSPACE_WIDTH_FRACTION).toInt() + val targetHeight = (height * BACKSPACE_HEIGHT_FRACTION).toInt() + if (keyParams.width == targetWidth && keyParams.height == targetHeight) return + keyParams.width = targetWidth + keyParams.height = targetHeight + key.layoutParams = keyParams } private fun createItem(index: Int, candidate: String): LinearLayout = @@ -428,5 +573,20 @@ internal class CandidateBarController( const val STRIP_LIMIT = 24 const val CANDIDATE_EXPAND_SWIPE_DP = 36 const val SCROLL_SETTLE_WINDOW_MS = 96L + + // The expanded page of the reference skin (`input_more_cand.xml`), in + // its own 390-unit canvas: the filter group is 150 wide with a 3 inset, + // and the corner backspace takes 25.2% by 20.2% of the list. + const val EXPANDED_SCROLL_TAG = "candidate-expanded-scroll" + const val EXPANDED_CARD_TAG = "candidate-expanded-card" + const val EXPANDED_FILTER_ROW_TAG = "candidate-filter-row" + const val EXPANDED_FILTER_CONTAINER_TAG = "candidate-filter-container" + const val EXPANDED_FILTER_CHIP_TAG = "candidate-filter-chip" + const val EXPANDED_BACKSPACE_TAG = "candidate-expanded-backspace" + const val FILTER_ROW_HEIGHT_UNITS = 40 + const val FILTER_CONTAINER_WIDTH_UNITS = 150 + const val FILTER_CONTAINER_INSET_UNITS = 3 + const val BACKSPACE_WIDTH_FRACTION = 0.252f + const val BACKSPACE_HEIGHT_FRACTION = 0.202f } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt index ae21983a..d4ed1906 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateLayoutPolicy.kt @@ -7,6 +7,23 @@ internal fun candidateColumnSpan(text: String): Int = when { else -> 1 } +/** + * The expanded page's 全部 / 单字 chips. + * + * The reference ships the two as one option group with 全部 selected + * (`input_more_cand.xml:9-13`), so the page opens unfiltered. + */ +internal enum class ExpandedCandidateFilter(val label: String) { + ALL("全部"), + SINGLE_CHAR("单字"), + ; + + fun apply(candidates: List): List = when (this) { + ALL -> candidates + SINGLE_CHAR -> candidates.filter { it.codePointCount(0, it.length) == 1 } + } +} + internal fun expandedCandidateRows(candidates: List): List> { val rows = mutableListOf>() var row = mutableListOf() 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 efccb0e1..b5b7d3a6 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1392,6 +1392,10 @@ open class ImeKeyboardView( onFeedback = ::feedback, onCandidateSelected = listener::onCandidateSelected, onCandidateLongPressed = listener::onCandidateLongPressed, + // The expanded page carries its own backspace: it deletes one letter + // of the open composition and the page stays open on the shorter + // candidate list. + onExpandedBackspace = ::performBackspaceOnce, ) mainDock.addView( topZone, 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 79224db6..463d20e3 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -69,10 +69,20 @@ internal class ImeThemeApplier( private fun applyKey(view: ImeKeyView, t: ImeTheme.Tokens) { if (view.tag == "candidate-grid" || view.tag == "candidate-grid-first") { - val selected = view.tag == "candidate-grid-first" - val fill = if (selected) ImeSurfacePolicy.selectedSurface(t) else t.expandedBackground - view.background = ImeDrawableFactory.rounded(fill, 0, ImeSurfacePolicy.divider(t), toPx(1).coerceAtLeast(1)) - view.setColors(if (selected) t.primary else t.keyText) + // One item class in the reference (`cls_morecands_button`, + // style.xml:1141-1149): every cell is a flat label with a hairline + // edge on the list's own background, so no cell carries the accent + // tile here. + val edge = ImeDrawableFactory.rounded( + Color.TRANSPARENT, + 0, + ImeSurfacePolicy.divider(t), + toPx(1).coerceAtLeast(1), + ) + view.background = LayerDrawable( + arrayOf(edge, statefulRounded(Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), 0)), + ) + view.setColors(t.keyText) return } val side = isSideKey(view) @@ -129,6 +139,11 @@ internal class ImeThemeApplier( private fun applyLinearLayout(view: LinearLayout, t: ImeTheme.Tokens) { when (view.tag) { + // The option group 全部 / 单字 sits on (`cls_morecand_filter_containder`, + // style.xml:1157-1159: a 20-unit pill in the darker neutral). + "candidate-filter-container" -> + view.background = ImeDrawableFactory.rounded(t.candidateBackground, toPx(20)) + "app-setting-key" -> { val radius = toPx(ImeGeometryTokens.KEY_RADIUS_DP) view.background = ImeDrawableFactory.keyCap( @@ -245,6 +260,7 @@ internal class ImeThemeApplier( private fun applyImageView(view: ImageView, t: ImeTheme.Tokens) { val iconSize = when { + view.tag == "candidate-expanded-backspace" -> 24 view.tag == "candidate-emoji" || view.tag == "candidate-expand" -> 20 view.tag == "key-panel-back" -> 24 view.tag?.toString()?.startsWith("clip-pin:") == true || view.tag?.toString()?.startsWith("phrase-") == true -> 18 @@ -295,6 +311,17 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } + view.tag == "candidate-expanded-backspace" -> { + // The reference's corner key (`cls_button_backspace` as the + // expanded page uses it, input_more_cand.xml:22-26): a function + // key that rounds only the list's bottom-right corner. + view.imageTintList = ColorStateList.valueOf(t.functionKeyText) + view.background = ImeDrawableFactory.statefulCorners( + normal = t.functionKeyBackground, + pressed = ImeSurfacePolicy.pressedSurface(t.functionKeyBackground, t), + bottomRightPx = toPx(11).toFloat(), + ) + } // Row icons are decoration, so they stay neutral; the accent is // reserved for controls and their on/current state. view.tag == "setting-icon" -> { @@ -383,6 +410,24 @@ internal class ImeThemeApplier( tag == "candidate-word" -> view.setTextColor(t.candidateText) + tag == "candidate-filter-chip" -> { + // 全部 / 单字: the chosen one is the raised surface with plain + // text, the other a dimmed label on the group's own background + // (`cls_filter_button`, style.xml:773-780). + val selected = view.isSelected + view.setTextColor(if (selected) t.keyText else t.textSecondary) + view.typeface = if (selected) { + android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } else { + android.graphics.Typeface.DEFAULT + } + view.background = statefulRounded( + if (selected) t.keyBackground else Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + toPx(20), + ) + } + tag == "panel-note" || tag == "setting-sub" || tag == "setting-value" || @@ -607,6 +652,16 @@ internal class ImeThemeApplier( private fun applyFrameLayout(view: FrameLayout, t: ImeTheme.Tokens) { when (view.tag) { + // The expanded page's list card (`cls_morecand_bk`, style.xml:1162-1166): + // one rounded surface for the whole grid, an 11 radius and a 2 edge. + "candidate-expanded-card" -> + view.background = ImeDrawableFactory.rounded( + t.keyBackground, + toPx(11), + ImeSurfacePolicy.divider(t), + toPx(2).coerceAtLeast(1), + ) + "emoji-cell" -> { view.background = statefulRounded( Color.TRANSPARENT, diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt index 0f12f067..f32cdc55 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt @@ -42,6 +42,49 @@ internal object ImeDrawableFactory { strokeColor?.let { setStroke(strokeWidthPx, it) } } + /** + * A rectangle whose corners carry different radii: the reference rounds + * only the corner a key sits in, so the expanded page's backspace carries + * the list's own 11 at its bottom-right corner (`input_more_cand.xml:22-26`). + */ + fun roundedCorners( + color: Int, + topLeftPx: Float = 0f, + topRightPx: Float = 0f, + bottomRightPx: Float = 0f, + bottomLeftPx: Float = 0f, + strokeColor: Int? = null, + strokeWidthPx: Int = 1, + ): GradientDrawable = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setColor(color) + cornerRadii = floatArrayOf( + topLeftPx, topLeftPx, + topRightPx, topRightPx, + bottomRightPx, bottomRightPx, + bottomLeftPx, bottomLeftPx, + ) + strokeColor?.let { setStroke(strokeWidthPx, it) } + } + + fun statefulCorners( + normal: Int, + pressed: Int, + topLeftPx: Float = 0f, + topRightPx: Float = 0f, + bottomRightPx: Float = 0f, + bottomLeftPx: Float = 0f, + ): StateListDrawable = StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + roundedCorners(pressed, topLeftPx, topRightPx, bottomRightPx, bottomLeftPx), + ) + addState( + intArrayOf(), + roundedCorners(normal, topLeftPx, topRightPx, bottomRightPx, bottomLeftPx), + ) + } + fun statefulRounded( normal: Int, pressed: Int, diff --git a/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt b/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt index 29a0560a..2437bac0 100644 --- a/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidateLayoutPolicyTest.kt @@ -1,5 +1,6 @@ package llc.slacker.openime +import llc.slacker.openime.keyboard.ExpandedCandidateFilter import llc.slacker.openime.keyboard.candidateColumnSpan import llc.slacker.openime.keyboard.expandedCandidateRows import org.junit.Assert.assertEquals @@ -22,4 +23,15 @@ class CandidateLayoutPolicyTest { assertEquals(1, candidateColumnSpan("😀😀😀😀")) assertTrue(expandedCandidateRows(emptyList()).isEmpty()) } + + @Test + fun theExpandedFiltersKeepEverythingOrOnlySingleCharacters() { + val candidates = listOf("你", "你好", "这是一句用于验证布局的长候选词", "😀") + assertEquals(candidates, ExpandedCandidateFilter.ALL.apply(candidates)) + assertEquals( + listOf("你", "😀"), + ExpandedCandidateFilter.SINGLE_CHAR.apply(candidates), + ) + assertEquals(listOf("全部", "单字"), ExpandedCandidateFilter.entries.map { it.label }) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 993e3552..fcf3c0ab 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -161,3 +161,7 @@ Add new entries at the end. The first item in the candidate strip is drawn in the theme's accent as text (contrast-corrected by `ImeSurfacePolicy.selectedText`) at the medium weight, and it has no background of its own; tapping it shows the accent-tinted pressed surface like any other item. The reference style names the same treatment for its first item: `cls_option_first_canditem` (`skin-full/assets/skin/default/style.xml:551-554`) takes `first_cand_font`, which is the candidate font one weight step up (weight 500 against 400, `style.xml:83,98`), and `first_cand_text_color`, which is the accent `rgba(79,132,255,1)` in the light skin and `rgba(255,255,255,0.95)` in the dark one (`values/colors.xml:4`, `values/dark_colors.xml:4`). No first-item class carries a background. *Reason:* openIME drew the first candidate as bold black text on a filled key-coloured tile, which is heavier than the reference and competes with the keys below. The colour follows the user's accent instead of the fixed blue in the skin, and passes the 4.5:1 text contrast rule. + +- **The expanded candidate page is the reference's 更多候选 page.** + The page opens with 全部 / 单字 as one option group over the list (`input_more_cand.xml:9-13`; `cls_filter_button`, `style.xml:773-780`), the list sits in one rounded card (radius 11, 2 edge, `cls_morecand_bk`, `style.xml:1162-1166`), and the backspace floats in that card's bottom-right corner at 25.2% by 20.2% of the list, rounding only that corner with the card's own radius (`bkround="0,0,11,0"`, `input_more_cand.xml:18-26`, key face `cls_button_backspace` over `cls_bk_gray_circle`, `style.xml:828-835`). Every cell is the same flat label with a hairline edge (`cls_morecands_button`, `style.xml:1141-1149`), and no cell carries the accent tile, because the skin gives the expanded list no first-item class. The corner key deletes one letter of the open composition and the page stays open on the shorter list. + *Reason:* the page used to be the key grid itself — one bordered tile per candidate, the first one filled in the accent — which is not the surface the reference ships. 单字 keeps single characters (`is_single="true"` on that option), and 全部 is the page's own default (`selected="true"`), so the two chips do what their labels say. The page's left syllable list (`FilterListLayout name="more_cand_filter_list"`, `input_more_cand.xml:17-18`, commented 音节过滤) is not in this change: the 26-key path has no syllable-fixing state yet, and the list would have nothing to filter by. From 6bdaae68619ea87133db7ca9e23ed0b455fc7ef4 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:48:34 +0800 Subject: [PATCH 11/94] Record the alignment work in the change log The URL rail, the first-candidate accent, the candidate ranking fix and the expanded page were user-visible changes without an entry. Co-Authored-By: Claude Code --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a37f12..14f53eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 候选栏的候选间距收紧(每侧内边距 12dp → 8dp),同屏能多显示几个候选。 - 26 键字母键角标改按参考产品逐键对齐:英文 a=- s=/ d=: f=; g=( h=) j=~ k=' l=" z=@ x=_ c=# v=& b=? n=! m=…,中文为同键位的全角形(d=: f=; g=( h=) j=~ k=“ l=” x=. v=、 b=? n=! m=…,a、s、z、c、m 两语相同);上滑仍是输入角标,长按改为只弹字母大小写两项(大写在前),与参考一致;逗号键新增上滑输入句号。 - 九键左侧栏的拼音索引对齐参考产品:与常用符号共用同一个列表,每个读法一行、同一字号(不再逐条缩放),条目间是细线,当前读法不再高亮填色(读数由拼音串本身显示);只有换了数字才回到列表顶部,其余刷新保持滚动位置。 +- 26 键拼音候选排序:读音正好覆盖已输入全部字母的词,现在排在只匹配了前半段、尾部还差几个字母的候选前面(输入 dang 先出「当、党、档」,输入 dangdi 先出「当地」),不再被「切了一半」的高频词挤下去。 +- 九键左侧栏在网址、邮箱输入框里改为参考皮肤的网址栏(`.` `/` `www.` `.com` `.cn` `http` `wap.` `ftp:`),普通输入框仍是用自定义的标点。 +- 候选栏第一个候选改为主题色文字(中等字重、无底色),点按仍是主题色按压效果,不再是一个带底色的方块。 +- 展开候选页对齐参考皮肤:顶部一排「全部 / 单字」筛选(「单字」只留单个汉字,默认「全部」);候选列表收进一张圆角卡片(圆角 11、描边 2);退格键浮在卡片右下角(占列表宽高各 25.2% / 20.2%,只圆右下角),点它删掉一个拼音字母、页面留在更短的候选列表上;网格里每个候选统一为平铺样式,不再把第一个填成强调色块。 ## [0.0.10-beta.1] - 2026-10-14 From 94616ecd6b045ec9aacbcd1df2c14a5ede8dea49 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:03:58 +0800 Subject: [PATCH 12/94] Ship the reference's seven-key bottom row and its @# nine-key cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 26-key bottom row gains the reference's two symbol keys beside the space bar, in Chinese and English, with the address-field marks in a URL or e-mail field, and each key takes the reference's own weight. The English merged key now leads with the period and carries the comma in its corner, as the skin draws it, instead of the other way round. The nine-key 1 cell becomes the reference's @# key: the label names the symbols it offers, a tap and an up swipe still type the digit, and a long press opens the reference's own seven-symbol bubble. The board has no 分词 key because the reference's has none, and the 26-key 分词 key drops the invented corner mark and popup its class never declares. Co-Authored-By: Claude Code --- CHANGELOG.md | 3 ++ .../AuditInteractionInstrumentedTest.kt | 3 +- .../openime/editor/EditorInfoAdapter.kt | 12 +++-- .../openime/keyboard/ImeKeyboardView.kt | 2 +- .../keyboard/NineKeyLongPressPolicy.kt | 8 +++ .../keyboard/Pinyin26KeyboardRenderer.kt | 50 ++++++++++++------- .../keyboard/Pinyin9KeyboardRenderer.kt | 19 ++++--- .../openime/keyboard/ProductionKeyPolicy.kt | 15 +++--- .../openime/keyboard/PunctuationKeyPolicy.kt | 31 +++++++++--- .../openime/NineKeyLongPressPolicyTest.kt | 9 ++++ .../openime/ProductionKeyPolicyTest.kt | 47 +++++++++-------- .../openime/PunctuationKeyPolicyTest.kt | 24 ++++++++- docs/DECISIONS.md | 8 +++ 13 files changed, 165 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f53eed..ec5969ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 九键左侧栏在网址、邮箱输入框里改为参考皮肤的网址栏(`.` `/` `www.` `.com` `.cn` `http` `wap.` `ftp:`),普通输入框仍是用自定义的标点。 - 候选栏第一个候选改为主题色文字(中等字重、无底色),点按仍是主题色按压效果,不再是一个带底色的方块。 - 展开候选页对齐参考皮肤:顶部一排「全部 / 单字」筛选(「单字」只留单个汉字,默认「全部」);候选列表收进一张圆角卡片(圆角 11、描边 2);退格键浮在卡片右下角(占列表宽高各 25.2% / 20.2%,只圆右下角),点它删掉一个拼音字母、页面留在更短的候选列表上;网格里每个候选统一为平铺样式,不再把第一个填成强调色块。 +- 26 键底行补齐参考产品的两个符号键:空格键左右各加一键(中文是「,」和「。」,英文是「,」和「.」,网址与邮箱输入框里换成「/」和「.」),底行各键宽度改按参考皮肤的实际权重;英文下「句号逗号」合并键改为以「.」为主标签、角标「,」(原来主副颠倒),上滑或长按输入角标。 +- 九键左上角一格改为参考产品的「@#」键:主标签「@#」、角标仍是 1,点按与上滑都输入 1,长按弹出参考皮肤自带的符号列表「/ - _ 1 @ # .」;九键不再有「分词」键(参考产品只在 26 键那一行有分词键)。 +- 26 键「分词」键去掉自造的「@#/」角标与长按弹窗:参考皮肤的这个键只有一个文字标签。 ## [0.0.10-beta.1] - 2026-10-14 diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index 433937b5..b67c3844 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -919,7 +919,8 @@ class AuditInteractionInstrumentedTest { @Test fun modeSwitchDismissesLongPressChoicePopup() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - // 1 is the nine-key segmentation key; its long press offers @ # /. + // The nine-key 1 cell is the @# key: its long press offers the + // reference's own symbol list. keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) val segment = keyboard.findViewWithTag("key-9:1") val baseline = keyboard.childCount diff --git a/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt b/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt index 712d3fae..c3cda5a6 100644 --- a/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EditorInfoAdapter.kt @@ -120,9 +120,13 @@ object EditorInfoAdapter { fun isPassword(kind: EditorKind): Boolean = kind == EditorKind.PASSWORD /** - * Address and e-mail fields get the skin's URL rail instead of the - * punctuation rail. The reference nine-key layout carries the same list in - * its `uri_filter_list` attribute (skin/default/layout/input_kbd_pinyin9.xml). + * Address and e-mail fields are the fields the reference adapts to: its + * nine-key rail switches to a fixed URL list (`uri_filter_list`, + * skin/default/layout/input_kbd_pinyin9.xml) and the 26-key comma and period + * keys take their `uri_adapter_symbol` marks (skin/default/layout/input_kbd_pinyin26.xml). */ - fun usesUriRail(kind: EditorKind): Boolean = kind == EditorKind.URL || kind == EditorKind.EMAIL + fun isUriField(kind: EditorKind): Boolean = kind == EditorKind.URL || kind == EditorKind.EMAIL + + /** Address and e-mail fields get the skin's URL rail instead of the punctuation rail. */ + fun usesUriRail(kind: EditorKind): Boolean = isUriField(kind) } 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 b5b7d3a6..8615775b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -539,7 +539,6 @@ open class ImeKeyboardView( markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) }, onDigitKeyCreated = nineKeySegmentRepairController::bindDigitKey, onNineKey = ::onNineKey, - onPinyinSegment = ::onPinyinSegment, onShowChoicePopup = ::showChoicePopup, onShowChoiceRows = ::showChoiceRows, swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, @@ -891,6 +890,7 @@ open class ImeKeyboardView( onEnter = listener::onEnter, onSymbols = { showPanel(Panel.SYMBOLS) }, splitLayout = ::splitKeyboard, + uriField = { EditorInfoAdapter.isUriField(EditorInfoAdapter.kind(currentEditorInfo())) }, ) } 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 fd7fb7ef..dba767fd 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt @@ -12,6 +12,14 @@ import llc.slacker.openime.core.ImeData * their own behavior and return no rows. */ internal object NineKeyLongPressPolicy { + /** + * The nine-key `@#` key's symbols, as the reference lists them in that key's + * own up-swipe bubble (`input_kbd_pinyin9.xml:26-27`). The corner already + * costs one of them — the digit 1, which a plain up swipe types — so the list + * stays reachable here. + */ + val AT_SYMBOL_CHOICES = listOf("/", "-", "_", "1", "@", "#", ".") + 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() 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 3ae65198..eb3ee396 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -43,6 +43,7 @@ internal class Pinyin26KeyboardRenderer( private val onEnter: () -> Unit, private val onSymbols: () -> Unit = {}, private val splitLayout: () -> Boolean = { false }, + private val uriField: () -> Boolean = { false }, ) { fun render( english: Boolean, @@ -82,18 +83,23 @@ internal class Pinyin26KeyboardRenderer( } val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights(landscape) + val punctuation = PunctuationKeyPolicy.spec(english, uriField()) val bottom = rowHost() bottom.addView( createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), flexKeyParams(weights.leftOuter), ) - // ,。 sits left of space and 中/英 right of it, next to the enter key. - bottom.addView(punctuationKey(english), flexKeyParams(weights.leftInner)) + // The merged ,。 key sits left of the space bar, 中/英 right of it. + bottom.addView(punctuationKey(punctuation), flexKeyParams(weights.merged)) + // The reference's two symbol keys bookend the space bar: ,and 。in + // Chinese, , and . in English, the address marks in a URL field. + bottom.addView(symbolKey("key-symbol-comma", punctuation.comma), flexKeyParams(weights.symbol)) bottom.addView( createSpaceVoiceKey("空格", onSpace), flexKeyParams(weights.space), ) + bottom.addView(symbolKey("key-symbol-period", punctuation.period), flexKeyParams(weights.symbol)) bottom.addView( createKey( "中/英", @@ -139,7 +145,7 @@ internal class Pinyin26KeyboardRenderer( row { addView(fn("符") { onSymbols() }.apply { tag = "key-symbols" }, flexKeyParams(1f)) addView(fn("123", onDigits), flexKeyParams(1f)) - addView(punctuationKey(english), flexKeyParams(1f)) + addView(punctuationKey(PunctuationKeyPolicy.spec(english, uriField())), flexKeyParams(1f)) addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(2f)) gap() addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(2f)) @@ -152,33 +158,43 @@ internal class Pinyin26KeyboardRenderer( } } - private fun punctuationKey(english: Boolean): ImeKeyView { - val spec = PunctuationKeyPolicy.spec(english) + private fun punctuationKey(spec: PunctuationKeyPolicy.Spec): ImeKeyView { // A character key, not a function key: only character keys carry corner hints. - return createKey(spec.comma, false, spec.period, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { - onCommitCharacter(spec.comma) + return createKey(spec.merged, false, spec.mergedCorner, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { + onCommitCharacter(spec.merged) }.apply { tag = "key-punctuation" - contentDescription = "${spec.comma},上滑或长按输入${spec.period}" + contentDescription = "${spec.merged},上滑或长按输入${spec.mergedCorner}" setSecondaryVisible(true) setSecondaryAlpha(1f) - // Up swipes the period, as the reference keyboard does. - onSwipeUp = { onCommitCharacter(spec.period) } + // Up swipes the corner mark, as the reference keyboard does. + onSwipeUp = { onCommitCharacter(spec.mergedCorner) } setOnLongClickListener { - onCommitCharacter(spec.period) + onCommitCharacter(spec.mergedCorner) true } } } + /** + * One of the reference's two symbol keys beside the space bar + * (`input_kbd_pinyin26.xml:153,156`). It carries no corner mark and no + * bubble: the key is its own character. + */ + private fun symbolKey(tag: String, text: String): ImeKeyView = + createKey(text, false, null, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { + onCommitCharacter(text) + }.apply { + this.tag = tag + contentDescription = text + } + private fun segmentKey(): ImeKeyView = - createKey("分词", true, "@#/", ImeTypographyTokens.BODY_SP, 0, onPinyinSegment).apply { + createKey("分词", true, null, ImeTypographyTokens.BODY_SP, 0, onPinyinSegment).apply { + // `cls_button_shift` draws the label alone: the reference's 分词 key + // carries no corner mark and no bubble (`style.xml:915-920`). tag = "key-segment" - contentDescription = "分词,长按输入@井号或斜杠" - setOnLongClickListener { - onShowChoicePopup(this, listOf("@", "#", "/")) - true - } + contentDescription = "分词" } private fun shiftKey(shiftState: ShiftState): ImeKeyView { 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 6f87e8e5..d807843a 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -35,7 +35,6 @@ internal class Pinyin9KeyboardRenderer( private val markWhiteKey: (ImeKeyView) -> Unit, private val onDigitKeyCreated: (String, ImeKeyView) -> Unit, private val onNineKey: (String) -> Unit, - private val onPinyinSegment: () -> Unit, private val onShowChoicePopup: (View, List) -> Unit, private val onShowChoiceRows: (View, List>) -> Unit, private val swipeUpEnabled: () -> Boolean, @@ -216,22 +215,26 @@ internal class Pinyin9KeyboardRenderer( } private fun createNineDigitKey(digit: String): ImeKeyView { - val segmentation = digit == "1" + // The 1 cell is the reference's `@#` key: the label names the symbols it + // offers, and the corner shows the digit a tap and an up swipe type + // (`input_kbd_pinyin9.xml:23-28`). The board has no 分词 key: that key + // exists on the 26-key row alone. + val atSymbol = digit == "1" val labels = NINE_KEYS.flatten().first { it.first == digit }.second val key = createKey( - if (segmentation) "分词" else labels, + labels, false, digit, - if (segmentation) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.CANDIDATE_SP, + ImeTypographyTokens.CANDIDATE_SP, ) { - if (segmentation) onPinyinSegment() else onNineKey(digit) + onNineKey(digit) }.apply { tag = "key-9:$digit" - contentDescription = if (segmentation) "1,分词" else digit + contentDescription = if (atSymbol) "@#,角标 1" else digit markWhiteKey(this) - if (segmentation) { + if (atSymbol) { setOnLongClickListener { - onShowChoicePopup(this, listOf("@", "#", "/")) + onShowChoicePopup(this, NineKeyLongPressPolicy.AT_SYMBOL_CHOICES) true } } else { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt index b93eb2e8..767ced7b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt @@ -9,7 +9,8 @@ internal object ProductionKeyPolicy { data class BottomRowWeights( val leftOuter: Float, - val leftInner: Float, + val merged: Float, + val symbol: Float, val space: Float, val rightInner: Float, val rightOuter: Float, @@ -22,14 +23,16 @@ internal object ProductionKeyPolicy { /** * Visual reference geometry for the shared Chinese/English 26-key bottom row: - * 123 | ,。 | 空格 | 中/英 | 确定. The row mirrors around the space key so the - * space bar sits on the keyboard's centre line. + * 123 | ,。 | , | 空格 | 。 | 中/英 | 确定, in the reference's own weights + * (`style.xml:957-978`: 123 70.6, the merged key 37.6, both symbol keys 35.5, + * space 133.6, 中/英 38.6, 确定 74.6). The row mirrors around the space key so + * the space bar sits on the keyboard's centre line. */ fun twentySixKeyBottomRowWeights(landscape: Boolean = false): BottomRowWeights { // 横屏按参考键盘的横屏皮肤放大空格键:其空格权重 133.6 -> 182.2(×1.3638), - // 123 / 逗句号 / 中英 / 确定四个键均不变。 - val space = if (landscape) 210f else 154f - return BottomRowWeights(70f, 48f, space, 48f, 70f) + // 其余各键均不变(skin-reference/land/style.xml:115)。 + val space = if (landscape) 182.2f else 133.6f + return BottomRowWeights(70.6f, 37.6f, 35.5f, space, 38.6f, 74.6f) } /** diff --git a/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt index 48ba1db0..bd1c92e3 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/PunctuationKeyPolicy.kt @@ -1,19 +1,38 @@ package llc.slacker.openime.keyboard /** - * Content of the comma/period key beside the 26-key space bar. + * Content of the three punctuation keys on the 26-key bottom row. * - * Tap commits [comma]; long press commits [period], which is also the corner - * hint. Chinese mode uses the full-width marks. + * [Spec.merged] is the wide key left of the space bar, the reference's + * `ButtonPeriodComma`: Chinese draws `text=","` with the always-visible corner + * `ficon_text="。"` (`input_kbd_pinyin26.xml:145-152`), English draws `text="."` + * with the corner `","` (`input_kbd_english26.xml:154-161`). An up swipe and a + * long press commit that corner mark in both modes. + * + * [Spec.comma] and [Spec.period] are the two symbol keys the reference ships + * visible either side of the space bar (`input_kbd_pinyin26.xml:153,156`; + * `input_kbd_english26.xml:162-164,174-175`). Each shows its class's own text: + * full-width `,` and `。` in Chinese (`style.xml:335-356`), `,` and `.` in + * English. In an address or e-mail field the class's `uri_adapter_symbol` + * replaces the pair with `"/"` and `"."`. */ internal object PunctuationKeyPolicy { data class Spec( + val merged: String, + val mergedCorner: String, val comma: String, val period: String, ) - private val CHINESE = Spec(comma = ",", period = "。") - private val ENGLISH = Spec(comma = ",", period = ".") + private val CHINESE = Spec(merged = ",", mergedCorner = "。", comma = ",", period = "。") + private val ENGLISH = Spec(merged = ".", mergedCorner = ",", comma = ",", period = ".") + + /** The address-field marks, from `uri_adapter_symbol` on both symbol keys. */ + private const val URI_COMMA = "/" - fun spec(english: Boolean): Spec = if (english) ENGLISH else CHINESE + fun spec(english: Boolean, uriField: Boolean = false): Spec { + val base = if (english) ENGLISH else CHINESE + // The merged key carries no uri_adapter_symbol, so it keeps its marks. + return if (uriField) base.copy(comma = URI_COMMA, period = ".") else base + } } diff --git a/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt index 1e072a82..bfcbfbef 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt @@ -33,4 +33,13 @@ class NineKeyLongPressPolicyTest { assertTrue(NineKeyLongPressPolicy.choiceRows(it).isEmpty()) } } + + @Test + fun theAtSymbolKeyOffersTheReferenceBubble() { + // input_kbd_pinyin9.xml:26-27: the @# key's seven symbols, in order. + assertEquals( + listOf("/", "-", "_", "1", "@", "#", "."), + NineKeyLongPressPolicy.AT_SYMBOL_CHOICES, + ) + } } diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt index 2793cf84..4b69b759 100644 --- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt @@ -7,19 +7,28 @@ import org.junit.Test /** Regression coverage for current-main production key geometry. */ class ProductionKeyPolicyTest { @Test - fun twentySixKeyBottomRowFollowsTheReferenceCanvas() { + fun twentySixKeyBottomRowCarriesTheReferenceWeights() { val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() - // 123 | ,。 | 空格 | 中/英 | 确定, in the design's 390 units. - assertEquals(70f, weights.leftOuter, 0.0001f) - assertEquals(48f, weights.leftInner, 0.0001f) - assertEquals(154f, weights.space, 0.0001f) - assertEquals(48f, weights.rightInner, 0.0001f) - assertEquals(70f, weights.rightOuter, 0.0001f) - assertEquals( - 390f, - weights.leftOuter + weights.leftInner + weights.space + weights.rightInner + weights.rightOuter, - 0.0001f, - ) + // 123 | ,。 | , | 空格 | 。 | 中/英 | 确定, in the reference's own units + // (style.xml:957-978). + assertEquals(70.6f, weights.leftOuter, 0.0001f) + assertEquals(37.6f, weights.merged, 0.0001f) + assertEquals(35.5f, weights.symbol, 0.0001f) + assertEquals(133.6f, weights.space, 0.0001f) + assertEquals(38.6f, weights.rightInner, 0.0001f) + assertEquals(74.6f, weights.rightOuter, 0.0001f) + } + + @Test + fun twentySixKeyBottomRowKeepsTheSpaceBarNearTheCentreLine() { + val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() + // The reference's own row is a touch heavier on the right: 确定 is wider + // than 123, and the two symbol keys bookend the space bar. + val left = weights.leftOuter + weights.merged + weights.symbol + val right = weights.symbol + weights.rightInner + weights.rightOuter + assertEquals(143.7f, left, 0.0001f) + assertEquals(148.7f, right, 0.0001f) + assertEquals(5f, right - left, 1f) } @Test @@ -28,18 +37,12 @@ class ProductionKeyPolicyTest { val landscape = ProductionKeyPolicy.twentySixKeyBottomRowWeights(landscape = true) // 参考键盘横屏皮肤只放大空格键(133.6 -> 182.2,×1.3638),其余键不动。 assertEquals(portrait.leftOuter, landscape.leftOuter, 0.0001f) - assertEquals(portrait.leftInner, landscape.leftInner, 0.0001f) + assertEquals(portrait.merged, landscape.merged, 0.0001f) + assertEquals(portrait.symbol, landscape.symbol, 0.0001f) assertEquals(portrait.rightInner, landscape.rightInner, 0.0001f) assertEquals(portrait.rightOuter, landscape.rightOuter, 0.0001f) - assertEquals(210f, landscape.space, 0.2f) - assertEquals(182.2f / 133.6f, landscape.space / portrait.space, 0.01f) - } - - @Test - fun twentySixKeyBottomRowIsSymmetricAroundTheSpaceKey() { - val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() - assertEquals(weights.leftOuter, weights.rightOuter, 0.0001f) - assertEquals(weights.leftInner, weights.rightInner, 0.0001f) + assertEquals(182.2f, landscape.space, 0.0001f) + assertEquals(182.2f / 133.6f, landscape.space / portrait.space, 0.0001f) } @Test diff --git a/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt index 5a9c0876..d45e8f37 100644 --- a/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/PunctuationKeyPolicyTest.kt @@ -8,14 +8,36 @@ class PunctuationKeyPolicyTest { @Test fun chineseModeUsesFullWidthCommaAndPeriod() { val spec = PunctuationKeyPolicy.spec(english = false) + assertEquals(",", spec.merged) + assertEquals("。", spec.mergedCorner) assertEquals(",", spec.comma) assertEquals("。", spec.period) } @Test - fun englishModeUsesAsciiCommaAndPeriod() { + fun englishMergedKeyLeadsWithThePeriod() { + // The reference draws 句号逗号 with the period as the main label and the + // comma as the corner mark (input_kbd_english26.xml:154-161). val spec = PunctuationKeyPolicy.spec(english = true) + assertEquals(".", spec.merged) + assertEquals(",", spec.mergedCorner) assertEquals(",", spec.comma) assertEquals(".", spec.period) } + + @Test + fun anAddressFieldTakesTheUriAdapterMarks() { + val chinese = PunctuationKeyPolicy.spec(english = false, uriField = true) + assertEquals("/", chinese.comma) + assertEquals(".", chinese.period) + // The merged key carries no uri_adapter_symbol, so it keeps its marks. + assertEquals(",", chinese.merged) + assertEquals("。", chinese.mergedCorner) + + val english = PunctuationKeyPolicy.spec(english = true, uriField = true) + assertEquals("/", english.comma) + assertEquals(".", english.period) + assertEquals(".", english.merged) + assertEquals(",", english.mergedCorner) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fcf3c0ab..90c2e635 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -165,3 +165,11 @@ Add new entries at the end. - **The expanded candidate page is the reference's 更多候选 page.** The page opens with 全部 / 单字 as one option group over the list (`input_more_cand.xml:9-13`; `cls_filter_button`, `style.xml:773-780`), the list sits in one rounded card (radius 11, 2 edge, `cls_morecand_bk`, `style.xml:1162-1166`), and the backspace floats in that card's bottom-right corner at 25.2% by 20.2% of the list, rounding only that corner with the card's own radius (`bkround="0,0,11,0"`, `input_more_cand.xml:18-26`, key face `cls_button_backspace` over `cls_bk_gray_circle`, `style.xml:828-835`). Every cell is the same flat label with a hairline edge (`cls_morecands_button`, `style.xml:1141-1149`), and no cell carries the accent tile, because the skin gives the expanded list no first-item class. The corner key deletes one letter of the open composition and the page stays open on the shorter list. *Reason:* the page used to be the key grid itself — one bordered tile per candidate, the first one filled in the accent — which is not the surface the reference ships. 单字 keeps single characters (`is_single="true"` on that option), and 全部 is the page's own default (`selected="true"`), so the two chips do what their labels say. The page's left syllable list (`FilterListLayout name="more_cand_filter_list"`, `input_more_cand.xml:17-18`, commented 音节过滤) is not in this change: the 26-key path has no syllable-fixing state yet, and the list would have nothing to filter by. + +- **The 26-key bottom row is the reference's seven-key row.** + The row is 123 | ,。 | , | 空格 | 。 | 中/英 | 确定, with the merged key's corner mark and the two symbol keys as the reference ships them. The merged key is `ButtonPeriodComma`: Chinese draws `text=","` with the always-visible corner `ficon_text="。"` (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml:145-152`) and English draws `text="."` with the corner `","` and the bubble `", ."` (`input_kbd_english26.xml:154-161`) — the wide key leads with the period in English, which our version had reversed. The two symbol keys are the `ButtonSymbol` pair the reference marks `visible="true"` either side of the space bar (`input_kbd_pinyin26.xml:153,156`, again in `skin-reference/input_kbd_pinyin26.xml:144,153`; English `input_kbd_english26.xml:162-164,174-175`): they carry the class's own text — full-width `,` and `。` in Chinese (`cls_keyboard_button_comma` / `cls_keyboard_button_period`, `style.xml:335-356`), `,` and `.` in English — and their `uri_adapter_symbol` replaces the pair with `"/"` and `"."` in an address or e-mail field, the same fields whose rail carries the URL list. Weights are the reference's own: 70.6 / 37.6 / 35.5 / 133.6 / 35.5 / 38.6 / 74.6 (`style.xml:957-978`), which in landscape only widens the space bar to 182.2 (`skin-reference/land/style.xml:115`). + *Reason:* the reference's row has seven keys, not five: the earlier row folded the two symbol keys away and gave the space bar the room, so the 123 and 中/英 keys sat wider than the reference draws them and no key offered `/` or a second `,`/`。`. The two keys' label is inferred from the element's absent `text` plus `uri_adapter_symbol` on the class text: under that reading every attribute in the layout has a job. A device capture can overrule it — the skin alone cannot say whether the app hides these two keys at runtime, since the same file ships both the merged key and them. + +- **The nine-key 1 cell is the reference's @# key, and the board has no 分词 key.** + The cell shows `@#` with the digit 1 in the corner: a tap types the digit and an up swipe types it too, exactly as the letter cells type their digits, and a long press opens the reference's own symbol list `/ - _ 1 @ # .` in that order. The reference's cell is `ButtonPinyin9Symbol text="@#"` with `ficon_text="1"`, `fusiyin_keycode="0x3031"` (the digit-1 family) and `fusiyin_bubble_text="/ - _ 1 @ # ."` (`skin-full/assets/skin/default/layout/input_kbd_pinyin9.xml:23-28`), and no keycode attribute: the label names the symbols the key offers rather than what it types. The board has no 分词 key because the reference has none — `ButtonSplit text="分词"` appears once in the whole skin, on the 26-key row (`input_kbd_pinyin26.xml:92`). + *Reason:* our nine-key cell was a 分词 key with an invented `@#/` long-press popup, and the syllable-split command it ran does not exist on the reference's nine-key board (there the user taps a reading in the rail). The reference lists those seven symbols on the key's up-swipe bubble; our up swipe commits a single character, so the same list opens on a long press, which is where our other nine-key keys keep their bubbles. From 0538ce1ab9ea69e35e9462734da5cbd5ec088c63 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:09:31 +0800 Subject: [PATCH 13/94] Split the pre-edit into the reference's two colours The open Pinyin string now paints the syllables the user has fixed in the accent and the unconverted tail in the key text colour, as the reference's cls_comp_half / cls_comp_right pair does, and it takes comp_font's own size. A decoder-inserted divider stays in the plain colour: only a syllable the user chose is fixed. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeKeyboardView.kt | 27 ++++++++++++++++++- .../slacker/openime/keyboard/ImeTopZone.kt | 2 +- .../keyboard/PreeditHighlightPolicy.kt | 18 +++++++++++++ .../slacker/openime/theme/ImeDesignTokens.kt | 3 +++ .../openime/PreeditHighlightPolicyTest.kt | 21 +++++++++++++++ docs/DECISIONS.md | 4 +++ 7 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/PreeditHighlightPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/PreeditHighlightPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5969ea..2aa009c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 26 键底行补齐参考产品的两个符号键:空格键左右各加一键(中文是「,」和「。」,英文是「,」和「.」,网址与邮箱输入框里换成「/」和「.」),底行各键宽度改按参考皮肤的实际权重;英文下「句号逗号」合并键改为以「.」为主标签、角标「,」(原来主副颠倒),上滑或长按输入角标。 - 九键左上角一格改为参考产品的「@#」键:主标签「@#」、角标仍是 1,点按与上滑都输入 1,长按弹出参考皮肤自带的符号列表「/ - _ 1 @ # .」;九键不再有「分词」键(参考产品只在 26 键那一行有分词键)。 - 26 键「分词」键去掉自造的「@#/」角标与长按弹窗:参考皮肤的这个键只有一个文字标签。 +- 拼音预编辑串按参考皮肤分两色:用户已经选定的音节(九键左侧点选的读法)用主题色,剩下未转换的部分用普通键字色;字号同时从 14sp 调到参考的 13sp(comp_font 40)。 ## [0.0.10-beta.1] - 2026-10-14 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 8615775b..df20f806 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -9,7 +9,10 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.os.SystemClock +import android.text.Spannable +import android.text.Spanned import android.text.TextUtils +import android.text.style.ForegroundColorSpan import android.util.Log import android.view.Gravity import android.view.MotionEvent @@ -3026,6 +3029,25 @@ open class ImeKeyboardView( } finally { syncingComposition = false } + applyPreeditHighlight(text) + } + + /** + * Paint the syllables the user has fixed in the accent, as the reference + * draws its two-part composition (`cls_comp_half` over `cls_comp_right`, + * `style.xml:580-587`). The rest of the pre-edit keeps the key text colour. + */ + private fun applyPreeditHighlight(text: String) { + val editable = composition.text as? Spannable ?: return + editable.getSpans(0, editable.length, ForegroundColorSpan::class.java).forEach(editable::removeSpan) + val length = PreeditHighlightPolicy.fixedPrefixLength(text, lastNineSegmentPrefix) + if (length == 0) return + editable.setSpan( + ForegroundColorSpan(ImeSurfacePolicy.selectedText(currentThemeTokens())), + 0, + length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) } /** Recompute candidates after the user edits the visible Pinyin field. */ @@ -3518,7 +3540,10 @@ open class ImeKeyboardView( expandedPanel.setBackgroundColor(t.keyboardBackground) candidateOverlay.setBackgroundColor(t.expandedBackground) themeApplier.apply(this, t) - composition.setTextColor(ImeSurfacePolicy.selectedText(t)) + // comp_right: the unconverted tail keeps the key text colour; the fixed + // syllables carry the accent through the span applyPreeditHighlight sets. + composition.setTextColor(t.keyText) + applyPreeditHighlight(composition.text?.toString().orEmpty()) topZone.candidateExpandButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keyText) topZone.candidateEmojiButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText) floatingKeyboardController.applyTheme(t) 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 3809b4a2..2a1fd669 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -221,7 +221,7 @@ internal class ImeTopZone( composition.apply { tag = "pinyin-composition-editor" contentDescription = "可编辑拼音预编辑" - textSize = ImeTypographyTokens.BODY_SP + textSize = ImeTypographyTokens.COMPOSITION_SP letterSpacing = 0.04f typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) gravity = Gravity.CENTER_VERTICAL or Gravity.START diff --git a/app/src/main/java/llc/slacker/openime/keyboard/PreeditHighlightPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/PreeditHighlightPolicy.kt new file mode 100644 index 00000000..d0359532 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/PreeditHighlightPolicy.kt @@ -0,0 +1,18 @@ +package llc.slacker.openime.keyboard + +/** + * Which part of the pre-edit the accent colour covers. + * + * The reference splits the open Pinyin string into two labels: `cls_comp_half` + * paints the part the user has already fixed — 半选 and the nine-key reading the + * user chose — in the theme colour (`style.xml:580-583`, `@color/theme_color`), + * and `cls_comp_right` paints the still-unconverted tail in the key text colour + * (`style.xml:585-587`, `@color/keybutton_text`). + * + * Only a plain prefix of the shown text counts as fixed: a syllable boundary the + * decoder guessed is not one the user chose, so it stays in the plain colour. + */ +internal object PreeditHighlightPolicy { + fun fixedPrefixLength(text: String, fixed: String): Int = + if (fixed.isNotEmpty() && text.startsWith(fixed)) fixed.length else 0 +} 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 6b46b0ab..9eb41652 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -115,6 +115,9 @@ internal object ImeTypographyTokens { const val CAPTION_SP = 11f const val SMALL_SP = 12f const val BODY_SP = 14f + + /** The open Pinyin string above the candidates (`comp_font` size 40). */ + const val COMPOSITION_SP = 13f const val TITLE_SP = 16f const val CANDIDATE_SP = 18f const val GLYPH_SP = 20f diff --git a/app/src/test/java/llc/slacker/openime/PreeditHighlightPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PreeditHighlightPolicyTest.kt new file mode 100644 index 00000000..5d5f5218 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PreeditHighlightPolicyTest.kt @@ -0,0 +1,21 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.PreeditHighlightPolicy +import org.junit.Assert.assertEquals +import org.junit.Test + +class PreeditHighlightPolicyTest { + @Test + fun theFixedSyllablesOfTheShownTextCarryTheAccent() { + assertEquals(3, PreeditHighlightPolicy.fixedPrefixLength("ni'hao", "ni'")) + assertEquals(3, PreeditHighlightPolicy.fixedPrefixLength("ni hao", "ni ")) + assertEquals(2, PreeditHighlightPolicy.fixedPrefixLength("nihao", "ni")) + } + + @Test + fun aGuessedBoundaryStaysInThePlainColour() { + // A decoder-inserted divider is not a syllable the user chose. + assertEquals(0, PreeditHighlightPolicy.fixedPrefixLength("wo'yi a m", "ni ")) + assertEquals(0, PreeditHighlightPolicy.fixedPrefixLength("hao", "")) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 90c2e635..36c4cda2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -173,3 +173,7 @@ Add new entries at the end. - **The nine-key 1 cell is the reference's @# key, and the board has no 分词 key.** The cell shows `@#` with the digit 1 in the corner: a tap types the digit and an up swipe types it too, exactly as the letter cells type their digits, and a long press opens the reference's own symbol list `/ - _ 1 @ # .` in that order. The reference's cell is `ButtonPinyin9Symbol text="@#"` with `ficon_text="1"`, `fusiyin_keycode="0x3031"` (the digit-1 family) and `fusiyin_bubble_text="/ - _ 1 @ # ."` (`skin-full/assets/skin/default/layout/input_kbd_pinyin9.xml:23-28`), and no keycode attribute: the label names the symbols the key offers rather than what it types. The board has no 分词 key because the reference has none — `ButtonSplit text="分词"` appears once in the whole skin, on the 26-key row (`input_kbd_pinyin26.xml:92`). *Reason:* our nine-key cell was a 分词 key with an invented `@#/` long-press popup, and the syllable-split command it ran does not exist on the reference's nine-key board (there the user taps a reading in the rail). The reference lists those seven symbols on the key's up-swipe bubble; our up swipe commits a single character, so the same list opens on a long press, which is where our other nine-key keys keep their bubbles. + +- **The pre-edit is two colours on one line, as the reference draws it.** + The open Pinyin string above the candidates is drawn at `comp_font` (`style.xml:79`, size 40, one step below the candidate font's 56) with the syllables the user has fixed in the theme colour and the still-unconverted tail in the key text colour. The reference ships exactly that pair: `cls_comp_half` holds 半选 and the nine-key reading the user chose, right-aligned in `@color/theme_color` (`style.xml:580-583`), `cls_comp_right` holds the unconverted string, left-aligned in `@color/keybutton_text` (`style.xml:585-587`), and the two are sibling labels of one `Composition` (`layout/input_toolbar_typing.xml:5-13`). + *Reason:* our pre-edit was one uniform accent-coloured run at 14sp, so it could not show which syllables were settled. Only a plain prefix of the shown string counts as fixed — a divider the decoder inserted is not a choice the user made — so the colour follows the reading the user tapped in the nine-key rail instead of every boundary the engine guesses. The 26-key path has no user-fixed prefix yet, so it stays in one colour until it does. From 98e3806c12a9855aebbecc2f0f5da44f9bae9f9a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:14:55 +0800 Subject: [PATCH 14/94] Lay the candidate strip out as the reference does The strip now insets the candidate list at both ends, draws the reference's hairline split between the list and the expand key, keeps a 3-unit gap before it, and gives the expand key the reference's pill press shape. Its 48dp touch target stays: that is our own rule. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeThemeApplier.kt | 8 ++++- .../slacker/openime/keyboard/ImeTopZone.kt | 33 ++++++++++++++++++- docs/DECISIONS.md | 4 +++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa009c0..5a43aa4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 九键左上角一格改为参考产品的「@#」键:主标签「@#」、角标仍是 1,点按与上滑都输入 1,长按弹出参考皮肤自带的符号列表「/ - _ 1 @ # .」;九键不再有「分词」键(参考产品只在 26 键那一行有分词键)。 - 26 键「分词」键去掉自造的「@#/」角标与长按弹窗:参考皮肤的这个键只有一个文字标签。 - 拼音预编辑串按参考皮肤分两色:用户已经选定的音节(九键左侧点选的读法)用主题色,剩下未转换的部分用普通键字色;字号同时从 14sp 调到参考的 13sp(comp_font 40)。 +- 候选条按参考皮肤排版:候选列表左右各留 4 单位内缩,列表与展开键之间加一条细分隔线(中间再隔 3 单位),展开键的按压底色改成参考的整圆胶囊形。 ## [0.0.10-beta.1] - 2026-10-14 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 463d20e3..f2afb85d 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -304,11 +304,14 @@ internal class ImeThemeApplier( view.imageTintList = ColorStateList.valueOf(if (view.isSelected) t.primary else t.keySecondaryText) } view.tag == "candidate-emoji" || view.tag == "candidate-expand" -> { + // `cls_btn_morecand` is a `cls_bk_toolbar_button`: a transparent + // pill that only shows a press tint (`bkround="111"`, + // `pushedbkcolor="@color/canditem_pushedbk"`, style.xml:231-235, 482-488). view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.background = statefulRounded( Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), - toPx(ImeGeometryTokens.KEY_RADIUS_DP), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } view.tag == "candidate-expanded-backspace" -> { @@ -715,6 +718,9 @@ internal class ImeThemeApplier( "setting-divider" -> view.setBackgroundColor(ImeDrawableFactory.withAlpha(t.border, if (ImeSurfacePolicy.isDark(t)) 150 else 48)) "row-hairline" -> view.setBackgroundColor(ImeSurfacePolicy.hairline(t)) + // `cls_candidate_split` is filled with @color/canditem_split + // (style.xml:504-506), the colour the candidate row's hairlines use. + "candidate-strip-split" -> view.setBackgroundColor(ImeSurfacePolicy.divider(t)) } } 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 2a1fd669..0b6791ef 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -43,6 +43,15 @@ internal enum class ImeTopZoneState { private const val COMPOSITION_LINE_DP = 18 private const val CANDIDATE_ROW_DP = 42 +/** + * The reference's candidate strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` + * (`input_toolbar_typing.xml:16-30`, weights from `style.xml:530-534`, 506 and 468): + * a small inset at each end, a hairline split before the expand key and a 3-unit + * gap ahead of it. + */ +private const val CANDIDATE_EDGE_UNITS = 4 +private const val CANDIDATE_BUTTON_GAP_UNITS = 3 + internal class ImeTopZone( context: Context, private val toPx: (Int) -> Int, @@ -260,8 +269,13 @@ internal class ImeTopZone( orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL tag = "candidate-field" - setPadding(toPx(6), 0, toPx(0), 0) + setPadding(0, 0, 0, 0) } + // The reference's strip opens with a 4-unit inset. + candidateField.addView( + View(context).apply { tag = "candidate-strip-lead" }, + LinearLayout.LayoutParams(toPx(CANDIDATE_EDGE_UNITS), LinearLayout.LayoutParams.MATCH_PARENT), + ) candidateRow.apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL @@ -312,7 +326,24 @@ internal class ImeTopZone( onExpandCandidates() } } + // The hairline split sits between the list and the expand key, and the + // expand key keeps a 3-unit gap from it. + candidateField.addView( + View(context).apply { tag = "candidate-strip-split" }, + LinearLayout.LayoutParams(toPx(1).coerceAtLeast(1), LinearLayout.LayoutParams.MATCH_PARENT), + ) + candidateField.addView( + View(context).apply { tag = "candidate-strip-gap" }, + LinearLayout.LayoutParams( + toPx(CANDIDATE_BUTTON_GAP_UNITS), + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) candidateField.addView(candidateExpandButton, touchTargetParams()) + candidateField.addView( + View(context).apply { tag = "candidate-strip-tail" }, + LinearLayout.LayoutParams(toPx(CANDIDATE_EDGE_UNITS), LinearLayout.LayoutParams.MATCH_PARENT), + ) composeZone.addView( candidateField, LinearLayout.LayoutParams( diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 36c4cda2..c3ff6237 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -177,3 +177,7 @@ Add new entries at the end. - **The pre-edit is two colours on one line, as the reference draws it.** The open Pinyin string above the candidates is drawn at `comp_font` (`style.xml:79`, size 40, one step below the candidate font's 56) with the syllables the user has fixed in the theme colour and the still-unconverted tail in the key text colour. The reference ships exactly that pair: `cls_comp_half` holds 半选 and the nine-key reading the user chose, right-aligned in `@color/theme_color` (`style.xml:580-583`), `cls_comp_right` holds the unconverted string, left-aligned in `@color/keybutton_text` (`style.xml:585-587`), and the two are sibling labels of one `Composition` (`layout/input_toolbar_typing.xml:5-13`). *Reason:* our pre-edit was one uniform accent-coloured run at 14sp, so it could not show which syllables were settled. Only a plain prefix of the shown string counts as fixed — a divider the decoder inserted is not a choice the user made — so the colour follows the reading the user tapped in the nine-key rail instead of every boundary the engine guesses. The 26-key path has no user-fixed prefix yet, so it stays in one colour until it does. + +- **The candidate strip carries the reference's own insets, split and expand key.** + The strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` (`layout/input_toolbar_typing.xml:16-30`): a small inset at each end, a hairline split immediately after the list, a 3-unit gap, then the expand key. The list's own weight is `cls_cand_list_weight_no_speech` 308 (`style.xml:530-534`), the split is filled with `@color/canditem_split` (`cls_candidate_split`, `style.xml:504-506`, the same colour the list's hairlines use), and the expand key is `cls_bk_toolbar_button`: transparent until pressed, pill radius, press tint `canditem_pushedbk` (`style.xml:231-235`, `cls_btn_morecand` `style.xml:482-488`). + *Reason:* openIME had a 6dp pad on one side and nothing on the other, no split, and a square 8dp-radius press tint on the expand key. The key keeps its 48dp touch target — our own accessibility rule, which the reference's 40-unit box does not meet — so only its shape and spacing follow the skin. From 147a2a0424c47ba2e33047691efd65c5a1ca8453 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:18:22 +0800 Subject: [PATCH 15/94] Give the idle toolbar the reference's pill keys Each idle-toolbar key is now the reference's own 40-unit pill with a press tint, instead of a bare icon stretched by weight across the row, and the slack of the strip sits in a spacer ahead of the hide key. The 48dp touch target stays our own rule. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeThemeApplier.kt | 10 +++-- .../slacker/openime/keyboard/ImeTopZone.kt | 40 ++++++++++++++----- docs/DECISIONS.md | 4 ++ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a43aa4f..e67f88b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 26 键「分词」键去掉自造的「@#/」角标与长按弹窗:参考皮肤的这个键只有一个文字标签。 - 拼音预编辑串按参考皮肤分两色:用户已经选定的音节(九键左侧点选的读法)用主题色,剩下未转换的部分用普通键字色;字号同时从 14sp 调到参考的 13sp(comp_font 40)。 - 候选条按参考皮肤排版:候选列表左右各留 4 单位内缩,列表与展开键之间加一条细分隔线(中间再隔 3 单位),展开键的按压底色改成参考的整圆胶囊形。 +- 空闲工具栏按键改为参考皮肤的样子:每个图标 40 单位宽、自带白色胶囊底与按压色(收起键同样),多余宽度留在图标与收起键之间,不再把图标拉伸铺满一行。 ## [0.0.10-beta.1] - 2026-10-14 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 f2afb85d..df4830e9 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -367,10 +367,14 @@ internal class ImeThemeApplier( ) || hasAncestorTag(view, "tools-panel") -> { view.imageTintList = ColorStateList.valueOf(if (hasAncestorTag(view, "tools-panel")) ImeSurfacePolicy.selectedText(t) else t.keyText) if (view.isClickable) { + // Every idle-toolbar key wears its own pill face at rest + // (`cls_toolbar_idle_button`: `bkcolor="@color/toolbar_idle_button_bk"`, + // `bkround="999"`, `pushedbkcolor`, style.xml:237-248), which on the + // toolbar surface is the key cap colour. view.background = statefulRounded( - Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.toolbarBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + t.keyBackground, + ImeSurfacePolicy.pressedSurface(t.keyBackground, t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } } 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 0b6791ef..c3e69759 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -52,6 +52,12 @@ private const val CANDIDATE_ROW_DP = 42 private const val CANDIDATE_EDGE_UNITS = 4 private const val CANDIDATE_BUTTON_GAP_UNITS = 3 +/** + * One idle-toolbar button: `cls_toolbar_idle_button` is 40 units wide and as + * tall as the row, with its own pill face (`style.xml:237-248`). + */ +private const val TOOLBAR_BUTTON_UNITS = 40 + internal class ImeTopZone( context: Context, private val toPx: (Int) -> Int, @@ -90,6 +96,7 @@ internal class ImeTopZone( private lateinit var compactToolbarIcons: List private var compact = false private lateinit var keyboardHide: View + private val toolbarSpacer = View(context) private lateinit var associationScroll: HorizontalScrollView private var toolbarMode = ToolbarMode.NORMAL private var associationsShown = false @@ -141,7 +148,7 @@ internal class ImeTopZone( toolbarIcons = listOf(keyboardIcon, clipboardIcon, emojiIcon, textEditIcon, undoIcon, toolsIcon) compactToolbarIcons = listOf(keyboardIcon, phraseIcon, emojiIcon, toolsIcon) (toolbarIcons + phraseIcon + splitIcon).distinct().forEach { - toolbarRow.addView(it, LinearLayout.LayoutParams(0, toPx(48), 1f)) + toolbarRow.addView(it, LinearLayout.LayoutParams(toPx(TOOLBAR_BUTTON_UNITS), toPx(48))) } // Association state: "‹ words… ∨". The back control and the hide @@ -211,8 +218,20 @@ internal class ImeTopZone( LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), ) + // The reference keeps the icons at a fixed 40 units and leaves the slack + // of the strip empty: `btn_hide` stays its own button on the right + // (`input_toolbar_idle.xml:41-43`, `cls_toolbar_idle_button` width 40, + // `style.xml:237-248`). + toolbarSpacer.apply { + tag = "toolbar-spacer" + visibility = View.VISIBLE + } + toolbarRow.addView(toolbarSpacer, LinearLayout.LayoutParams(0, toPx(48), 1f)) keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard) - toolbarRow.addView(keyboardHide, LinearLayout.LayoutParams(0, toPx(48), 1f)) + toolbarRow.addView( + keyboardHide, + LinearLayout.LayoutParams(toPx(TOOLBAR_BUTTON_UNITS), toPx(48)), + ) addView( toolbarRow, @@ -638,15 +657,14 @@ internal class ImeTopZone( associationScroll.visibility = if (association) View.VISIBLE else View.GONE autofillScroll.visibility = if (autofill) View.VISIBLE else View.GONE keyboardHide.visibility = View.VISIBLE - (keyboardHide.layoutParams as? LinearLayout.LayoutParams)?.let { params -> - if (association || autofill) { - params.width = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - params.weight = 0f - } else { - params.width = 0 - params.weight = 1f - } - keyboardHide.layoutParams = params + // The slack of the strip belongs to the spacer before 收起, so the icons + // and the hide key keep the reference's own width. A strip or the + // autofill chips take the slack instead. + (toolbarSpacer.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + val flexible = normal + params.width = 0 + params.weight = if (flexible) 1f else 0f + toolbarSpacer.layoutParams = params } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c3ff6237..12e4245a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -181,3 +181,7 @@ Add new entries at the end. - **The candidate strip carries the reference's own insets, split and expand key.** The strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` (`layout/input_toolbar_typing.xml:16-30`): a small inset at each end, a hairline split immediately after the list, a 3-unit gap, then the expand key. The list's own weight is `cls_cand_list_weight_no_speech` 308 (`style.xml:530-534`), the split is filled with `@color/canditem_split` (`cls_candidate_split`, `style.xml:504-506`, the same colour the list's hairlines use), and the expand key is `cls_bk_toolbar_button`: transparent until pressed, pill radius, press tint `canditem_pushedbk` (`style.xml:231-235`, `cls_btn_morecand` `style.xml:482-488`). *Reason:* openIME had a 6dp pad on one side and nothing on the other, no split, and a square 8dp-radius press tint on the expand key. The key keeps its 48dp touch target — our own accessibility rule, which the reference's 40-unit box does not meet — so only its shape and spacing follow the skin. + +- **Idle toolbar keys are the reference's pill buttons, and the strip keeps its slack.** + Each idle-toolbar key is 40 units wide with a pill face of its own and a press tint (`cls_toolbar_idle_button`: `width="40"`, `bkcircle="true"`, `bkcolor="@color/toolbar_idle_button_bk"`, `bkround="999"`, `pushedbkcolor`, `style.xml:237-248`; the idle row's keys are `input_toolbar_idle.xml:8-40`, with 收起 last at line 41-43). On our toolbar surface the face is the key cap colour, which is what the reference's own light and dark values do — a white pill at 0.8 alpha on its light toolbar, white at 0.12 in the dark skin. + *Reason:* our keys were bare icons stretched by weight to fill the row, so they had no face at all and 收起 absorbed the leftover width. The keys now keep the reference's width and press shape, and the slack of the strip sits in a spacer before 收起; a strip or the autofill chips take that slack when they are open. Height stays our 48dp touch target rather than the reference's row height, for the same accessibility reason as the expand key. From bc1c80625b9362036301fdaf57cc0272cadfc82c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:27:00 +0800 Subject: [PATCH 16/94] Lay the number page out as the reference's own number layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digits page was a 3x3 grid over a 返回 / 空格 / 0 row with 退格 / . / @ / 发送 down the right. The reference's 26-key 123 key opens input_kbd_number_from26.xml instead: a symbol rail over the return key in the left column, three digit columns closed by 符号 / 0 / . , and a delete / space / send column, at the skin's own weights 66 / 75 / 75 / 75 / 66 and with send twice as tall as the keys above it. The @ key is gone with no number page declaring one; in a phone field the symbol cell carries the dialer's # and the period cell its +, so * / + / # stay reachable. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 + .../openime/keyboard/ImeKeyboardView.kt | 5 +- .../openime/keyboard/NumberPadPolicy.kt | 39 ++++ .../keyboard/NumericKeyboardRenderer.kt | 168 +++++++----------- .../openime/keyboard/PhoneKeypadPolicy.kt | 4 +- .../slacker/openime/NumberPadPolicyTest.kt | 62 +++++++ .../slacker/openime/PhoneKeypadPolicyTest.kt | 9 + docs/DECISIONS.md | 4 + 8 files changed, 183 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/NumberPadPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/NumberPadPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e67f88b2..4739af22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 数字键盘改为参考皮肤自己的数字页:左列是符号栏(占三行键高)加底部「返回」键,中间三列数字(1/4/7、2/5/8、3/6/9),每列最后一格分别是「符号」「0」「.」,右列是「退格」「空格」「发送」三键、发送键占两格高;列宽按参考权重 66 : 75 : 75 : 75 : 66。原来横排的「返回 空格 0」一行和右侧的「@」键取消,空格键与另外两个功能键一样是灰底。电话输入框里「符号」格改为拨号的「#」、「.」格改为「+」,「空格」仍是「*」。 + - 26 键的 A、L 两个边键各加宽半个字母(与参考皮肤 15:10 的键宽一致),末行「分词」「退格」按同一比例加宽(原来是 1.25 倍)。 - 九键与笔画键盘右侧一列改为「删除、重输、发送」三键,发送键占两格高;原先单独的「0」键取消,改印在空格键右上角,上滑空格输入 0(26 键空格不带角标;跟随「上滑输入数字」开关)。 - 候选栏的候选间距收紧(每侧内边距 12dp → 8dp),同屏能多显示几个候选。 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 df20f806..3b2436dc 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -599,7 +599,6 @@ open class ImeKeyboardView( keyboardBody = keyboardBody, toPx = ::dp, keyRowHeightDp = ::keyRowHeightDp, - nineGridHeightDp = ::nineGridHeightDp, nineBodyHeightDp = ::nineBodyHeightDp, createKey = { text, function, textSize, onTap -> key( @@ -611,8 +610,10 @@ open class ImeKeyboardView( ) }, createBackspaceKey = ::backspaceKey, + // The number page's space is a grey function key in the reference + // (`cls_keyboard9_button_func`, input_kbd_number_from26.xml:35). createSpaceVoiceKey = { label, onTap -> - spaceVoiceKey(label, white = true, onTap = onTap) + spaceVoiceKey(label, white = false, onTap = onTap) }, markSideKey = { key -> key.setTag(MARK_SIDE_KEY, true) }, markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) }, diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NumberPadPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/NumberPadPolicy.kt new file mode 100644 index 00000000..be6af915 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumberPadPolicy.kt @@ -0,0 +1,39 @@ +package llc.slacker.openime.keyboard + +/** + * Geometry of the number / decimal / phone page. + * + * Every number is the reference's own, read from its number layout + * (`input_kbd_number_from26.xml:5-42`), the page its 26-key `123` key opens + * (`input_kbd_pinyin26.xml:134`). `width_weight` is normalised per row, so the + * figures below are the ratio the row must keep; the space between cells comes + * from each key's own margins (`cls_keyboard9_button_margin`, + * `style.xml:373-374`: 2.5 sideways, 2.75 up and down). + */ +internal object NumberPadPolicy { + /** The symbol rail column, wider than the digit columns (`input_kbd_number_from26.xml:9`). */ + const val RAIL_COLUMN_WEIGHT = 66f + + /** One digit column (`input_kbd_number_from26.xml:19,25,31`). */ + const val DIGIT_COLUMN_WEIGHT = 75f + + /** The delete / space / send column (`input_kbd_number_from26.xml:36`). */ + const val SEND_COLUMN_WEIGHT = 66f + + /** The rail spans `RAIL_ROWS` key rows and the return key the next; 156 against 52 is 3 against 1. */ + const val RAIL_ROWS = 3 + const val BACK_ROWS = 1 + + /** Send is twice as tall as the keys above it (`height_weight` 2, `input_kbd_number_from26.xml:39`). */ + const val SEND_ROWS = 2 + + /** Each digit column closes with 符号, 0 and `.` (`input_kbd_number_from26.xml:26-27,29,34`). */ + val COLUMN_TAILS = listOf("符号", "0", ".") + + /** The digits above each column's last cell, read down the reference's columns. */ + val DIGIT_COLUMN_TOPS = listOf( + listOf("1", "4", "7"), + listOf("2", "5", "8"), + listOf("3", "6", "9"), + ) +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt index a2e4ddc1..c32a670e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -1,6 +1,7 @@ package llc.slacker.openime.keyboard import android.content.Context +import android.view.Gravity import android.view.View import android.widget.LinearLayout import android.widget.ScrollView @@ -11,7 +12,12 @@ import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView /** - * Concrete first-frame renderer for number/decimal/phone keyboards. + * Concrete first-frame renderer for the number/decimal/phone page. + * + * The page is the reference's own number layout + * (`input_kbd_number_from26.xml:5-42`): a symbol rail over the return key on + * the left, three columns of digits whose last cell is 符号 / 0 / `.`, and a + * delete / space / send column on the right. * * Phone remapping and the symbol rail are resolved before the view is attached, * so no post-build hierarchy scan is needed. @@ -21,7 +27,6 @@ internal class NumericKeyboardRenderer( private val keyboardBody: LinearLayout, private val toPx: (Int) -> Int, private val keyRowHeightDp: () -> Int, - private val nineGridHeightDp: () -> Int, private val nineBodyHeightDp: () -> Int, private val createKey: ( text: String, @@ -54,7 +59,12 @@ internal class NumericKeyboardRenderer( tag = "digits-layout" } - val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + // The rail takes three key rows and the return key the fourth, as the + // reference splits the column (input_kbd_number_from26.xml:10-15). + val left = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.BOTTOM + } val rail = SymbolRailRenderer.build( context = context, railTag = DIGITS_RAIL_TAG, @@ -74,93 +84,45 @@ internal class NumericKeyboardRenderer( rail, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(nineGridHeightDp()), + toPx(keyRowHeightDp() * NumberPadPolicy.RAIL_ROWS), ), ) left.addView( - createKey("符号", true, ImeTypographyTokens.BODY_SP, onShowSymbols).apply { + createKey("返回", true, ImeTypographyTokens.BODY_SP, onReturnToText).apply { + tag = "key:mode" markSideKey(this) }, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(keyRowHeightDp()), - ), + columnCellParams(keyRowHeightDp() * NumberPadPolicy.BACK_ROWS), ) - container.addView(left, adaptiveColumnParams(1f)) + container.addView(left, adaptiveColumnParams(NumberPadPolicy.RAIL_COLUMN_WEIGHT)) - val grid = LinearLayout(context).apply { - tag = "digits-grid" - orientation = LinearLayout.VERTICAL - } - DIGIT_ROWS.forEachIndexed { rowIndex, rowDigits -> - val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - rowDigits.forEach { digit -> - row.addView( - createKey(digit, false, ImeTypographyTokens.SYMBOL_SP) { onCommitCharacter(digit) }.apply { - tag = "key:$digit" - markWhiteKey(this) - }, - flexKeyParams(), - ) + // Three digit columns, each closed by the reference's own fourth cell + // (input_kbd_number_from26.xml:17-34). + val stacks = NumberPadPolicy.DIGIT_COLUMN_TOPS.map { column -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + column.forEach { digit -> addView(digitKey(digit), columnCellParams()) } } - grid.addView( - row, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(keyRowHeightDp()), - ), - ) } - - val center = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - center.addView( - grid, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(nineGridHeightDp()), - ), - ) - val centerBottom = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - } - centerBottom.addView( - createKey("返回", true, ImeTypographyTokens.BODY_SP, onReturnToText).apply { - tag = "key:mode" - markSideKey(this) - }, - flexKeyParams(), - ) - centerBottom.addView( - if (phone) { - val value = literal("key-space", "*") - createKey(value, true, ImeTypographyTokens.BODY_SP) { onCommitCharacter(value) }.apply { - tag = "key-space" - markWhiteKey(this) - } - } else { - createSpaceVoiceKey("空格", onSpace).apply { - markWhiteKey(this) - } - }, - flexKeyParams(), - ) - centerBottom.addView( - createKey(literal("key:0", "0"), false, 24f) { - onCommitCharacter(literal("key:0", "0")) + val tails = NumberPadPolicy.COLUMN_TAILS + stacks[SYMBOL_COLUMN].addView( + createKey(literal(SYMBOL_CELL_TAG, tails[SYMBOL_COLUMN]), true, ImeTypographyTokens.BODY_SP) { + if (phone) onCommitCharacter(literal(SYMBOL_CELL_TAG, tails[SYMBOL_COLUMN])) else onShowSymbols() }.apply { - tag = "key:0" + tag = SYMBOL_CELL_TAG markWhiteKey(this) }, - flexKeyParams(), + columnCellParams(), ) - center.addView( - centerBottom, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(keyRowHeightDp()), - ), + stacks[ZERO_COLUMN].addView( + digitKey(tails[ZERO_COLUMN], value = literal("key:0", tails[ZERO_COLUMN]), tag = "key:0"), + columnCellParams(), + ) + stacks[PERIOD_COLUMN].addView( + digitKey(literal("key:.", tails[PERIOD_COLUMN]), tag = "key:."), + columnCellParams(), ) - container.addView(center, adaptiveColumnParams(25f / 7f)) + stacks.forEach { container.addView(it, adaptiveColumnParams(NumberPadPolicy.DIGIT_COLUMN_WEIGHT)) } val side = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -168,32 +130,22 @@ internal class NumericKeyboardRenderer( } side.addView( createBackspaceKey().apply { markSideKey(this) }, - sideKeyParams(), + columnCellParams(), ) side.addView( - createKey(".", true, ImeTypographyTokens.KEY_LETTER_SP) { onCommitCharacter(".") }.apply { - tag = "key:." - markSideKey(this) - }, - sideKeyParams(), - ) - side.addView( - createKey(literal("key:@", "@"), true, ImeTypographyTokens.BODY_SP) { - onCommitCharacter(literal("key:@", "@")) - }.apply { - tag = "key:@" - markSideKey(this) - }, - sideKeyParams(), + createSpaceVoiceKey("空格", onSpace), + columnCellParams(), ) side.addView( createKey(enterLabel, true, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(), + // The reference draws send twice as tall as the keys above it + // (input_kbd_number_from26.xml:38-40). + columnCellParams(keyRowHeightDp() * NumberPadPolicy.SEND_ROWS), ) - container.addView(side, adaptiveColumnParams(1f)) + container.addView(side, adaptiveColumnParams(NumberPadPolicy.SEND_COLUMN_WEIGHT)) keyboardBody.addView( container, @@ -204,6 +156,13 @@ internal class NumericKeyboardRenderer( ) } + /** A digit cell: the reference draws every digit on a white key (`cls_keyboard_number9_button`). */ + private fun digitKey(digit: String, value: String = digit, tag: String = "key:$digit"): ImeKeyView = + createKey(digit, false, ImeTypographyTokens.SYMBOL_SP) { onCommitCharacter(value) }.apply { + this.tag = tag + markWhiteKey(this) + } + fun refreshSymbols() { val rail = symbolRail ?: return SymbolRailRenderer.populate( @@ -227,17 +186,10 @@ internal class NumericKeyboardRenderer( .filter { it.isNotBlank() } ).distinct() - private fun flexKeyParams(weight: Float = 1f) = - LinearLayout.LayoutParams( - 0, - toPx(keyRowHeightDp()), - weight, - ) - - private fun sideKeyParams() = + private fun columnCellParams(heightDp: Int = keyRowHeightDp()) = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(keyRowHeightDp()), + toPx(heightDp), ) private fun adaptiveColumnParams(weight: Float) = @@ -250,10 +202,12 @@ internal class NumericKeyboardRenderer( private companion object { const val DIGITS_RAIL_TAG = "digits-symbol-scroll" const val DIGITS_CONTENT_TAG = "digits-symbol-scroll-content" - val DIGIT_ROWS = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9"), - ) + + /** The symbol-page cell of the first column; a phone field puts the dialer's `#` here. */ + const val SYMBOL_CELL_TAG = "key:symbol" + + const val SYMBOL_COLUMN = 0 + const val ZERO_COLUMN = 1 + const val PERIOD_COLUMN = 2 } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt index 43728314..9627e9b8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/PhoneKeypadPolicy.kt @@ -5,6 +5,8 @@ internal object PhoneKeypadPolicy { val literalByTag: LinkedHashMap = linkedMapOf( "key-space" to "*", "key:." to "+", - "key:@" to "#", + // The reference's number page has no `#` cell; its symbol-page cell + // carries the dialer's own symbol in a phone field (our own rule). + "key:symbol" to "#", ) } diff --git a/app/src/test/java/llc/slacker/openime/NumberPadPolicyTest.kt b/app/src/test/java/llc/slacker/openime/NumberPadPolicyTest.kt new file mode 100644 index 00000000..d27e70ef --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NumberPadPolicyTest.kt @@ -0,0 +1,62 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.NumberPadPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NumberPadPolicyTest { + /** The reference's row is 66 / 75 / 75 / 75 / 66 (`input_kbd_number_from26.xml:8,14-38`). */ + @Test + fun theRowKeepsTheReferencesColumnWidths() { + assertEquals(66f, NumberPadPolicy.RAIL_COLUMN_WEIGHT, 0f) + assertEquals(75f, NumberPadPolicy.DIGIT_COLUMN_WEIGHT, 0f) + assertEquals(66f, NumberPadPolicy.SEND_COLUMN_WEIGHT, 0f) + } + + /** 156 against 52 is the rail's three rows against the return key's one. */ + @Test + fun theRailIsThreeKeyRowsAndTheReturnKeyTheFourth() { + assertEquals(3, NumberPadPolicy.RAIL_ROWS) + assertEquals(1, NumberPadPolicy.BACK_ROWS) + assertEquals( + "height_weight 156 against 52", + 3 * NumberPadPolicy.BACK_ROWS, + NumberPadPolicy.RAIL_ROWS, + ) + } + + /** Send is twice as tall as the delete and space keys above it. */ + @Test + fun sendIsTwiceAsTallAsTheKeysAboveIt() { + assertEquals(2, NumberPadPolicy.SEND_ROWS) + assertEquals( + "the four rows of the digit columns", + NumberPadPolicy.BACK_ROWS * 4, + NumberPadPolicy.SEND_ROWS + NumberPadPolicy.BACK_ROWS * 2, + ) + } + + /** Every digit column closes with its own fourth cell, read off the reference. */ + @Test + fun eachDigitColumnClosesWithItsOwnCell() { + assertEquals(listOf("符号", "0", "."), NumberPadPolicy.COLUMN_TAILS) + assertEquals( + NumberPadPolicy.COLUMN_TAILS.size, + NumberPadPolicy.DIGIT_COLUMN_TOPS.size, + ) + } + + /** The digits read down the columns, and all nine appear once. */ + @Test + fun theDigitsReadDownTheColumnsAndCoverOneToNine() { + assertEquals( + listOf(listOf("1", "4", "7"), listOf("2", "5", "8"), listOf("3", "6", "9")), + NumberPadPolicy.DIGIT_COLUMN_TOPS, + ) + val digits = NumberPadPolicy.DIGIT_COLUMN_TOPS.flatten() + assertEquals(9, digits.size) + assertEquals(('1'..'9').map(Char::toString).toSet(), digits.toSet()) + assertTrue("0 closes the second column", "0" !in digits) + } +} diff --git a/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt b/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt index e7c11f12..a9c9f1f0 100644 --- a/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/PhoneKeypadPolicyTest.kt @@ -16,4 +16,13 @@ class PhoneKeypadPolicyTest { assertTrue("+" in literals) assertFalse("*" in literals) } + + /** Each dialer symbol sits on a cell of the number page's own row. */ + @Test + fun theDialerSymbolsSitOnTheNumberPagesOwnCells() { + assertEquals( + setOf("key-space", "key:.", "key:symbol"), + PhoneKeypadPolicy.literalByTag.keys, + ) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 12e4245a..da098a8d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -185,3 +185,7 @@ Add new entries at the end. - **Idle toolbar keys are the reference's pill buttons, and the strip keeps its slack.** Each idle-toolbar key is 40 units wide with a pill face of its own and a press tint (`cls_toolbar_idle_button`: `width="40"`, `bkcircle="true"`, `bkcolor="@color/toolbar_idle_button_bk"`, `bkround="999"`, `pushedbkcolor`, `style.xml:237-248`; the idle row's keys are `input_toolbar_idle.xml:8-40`, with 收起 last at line 41-43). On our toolbar surface the face is the key cap colour, which is what the reference's own light and dark values do — a white pill at 0.8 alpha on its light toolbar, white at 0.12 in the dark skin. *Reason:* our keys were bare icons stretched by weight to fill the row, so they had no face at all and 收起 absorbed the leftover width. The keys now keep the reference's width and press shape, and the slack of the strip sits in a spacer before 收起; a strip or the autofill chips take that slack when they are open. Height stays our 48dp touch target rather than the reference's row height, for the same accessibility reason as the expand key. + +- **The number page is the reference's own number layout.** + The page is `input_kbd_number_from26.xml:5-42`, the layout the reference's 26-key `123` key opens (`input_kbd_pinyin26.xml:134`, `cls_button_123` with `target="9"`): a symbol rail over the return key in the left column, three digit columns each closed by a function cell, and a delete / space / send column on the right. The column weights are the reference's own 66 / 75 / 75 / 75 / 66 (`input_kbd_number_from26.xml:8,14-38`); the left column is the rail's 156 units against the return key's 52 (`input_kbd_number_from26.xml:10-15`), three key rows against one; send is `height_weight="2"` (`input_kbd_number_from26.xml:39`), twice the delete and space keys above it; and the digits read down the columns 1/4/7, 2/5/8, 3/6/9 with `符号`, `0` and `.` as the last cell of each (`input_kbd_number_from26.xml:17-34`). Every digit sits on `cls_keyboard_number9_button`, a white key with `key_number9` (`style.xml:394-395`), and the two function cells follow their own class: the return key is grey (`cls_button_back_26key` over `cls_button_back`, `style.xml:857-858,838-842`) and so is send (`cls_button_enter9` over `cls_keyboard9_button_func`, `style.xml:824-826,398-401`), which is why this page's space key is grey where the letter pages draw theirs white. + *Reason:* our page was a 3x3 grid over a `返回 / 空格 / 0` row with `退格 / . / @ / 发送` down the right — an arrangement the reference does not ship, with the zero parked beside the space bar and an `@` key no number page declares. A phone field in the reference dials from a page our build does not draw, so there the symbol cell carries the dialer's `#` and the period cell its `+` (our own rule, unchanged in effect: `*`, `+` and `#` stay reachable); the reference's other number page keeps the same rail and swaps only those two cells, 符号 at the foot of the left column and the return key in the digit grid (`input_kbd_number.xml:19-20,24-25`), so the slot is the page's, not the key's. From 530311fd97e349b493d0216d3fbbfb1b5c1b1f74 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:32:44 +0800 Subject: [PATCH 17/94] Give the stroke board the reference's nine-cell grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input_kbd_stroke.xml draws three columns of three cells: 一 丨 丿 / 丶 折 通配 / @# :;, each with the skin's own corner digit and an up swipe that types it. The last row was missing from our board, so 通配's digit 6 and the two punctuation marks had no key; the @# cell now opens the reference's own / - _ @ # . 7 list on a long press. The bottom rows of the stroke and nine-key boards take the reference's 45.5 / 134 / 45.5, which fills the 225-unit centre column exactly. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeKeyboardView.kt | 1 + .../openime/keyboard/ProductionKeyPolicy.kt | 9 +- .../openime/keyboard/StrokeGridPolicy.kt | 54 ++++++++++++ .../keyboard/StrokeKeyboardRenderer.kt | 76 ++++++++++------- .../openime/ProductionKeyPolicyTest.kt | 13 +-- .../slacker/openime/StrokeGridPolicyTest.kt | 85 +++++++++++++++++++ docs/DECISIONS.md | 4 + 8 files changed, 203 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/StrokeGridPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/StrokeGridPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4739af22..b578fa6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 笔画键盘对齐参考皮肤:九宫格补齐为三行九格(一 丨 丿 / 丶 折 通配 / @# :;),每格右上角印角标 1–9、上滑输入该数字;「通配」角标 6;新增的「:」「;」直接上屏对应标点,「@#」长按弹出参考皮肤自带的「/ - _ @ # . 7」。九键与笔画底行的空格键改按参考权重(45.5 : 134 : 45.5)加宽。 - 数字键盘改为参考皮肤自己的数字页:左列是符号栏(占三行键高)加底部「返回」键,中间三列数字(1/4/7、2/5/8、3/6/9),每列最后一格分别是「符号」「0」「.」,右列是「退格」「空格」「发送」三键、发送键占两格高;列宽按参考权重 66 : 75 : 75 : 75 : 66。原来横排的「返回 空格 0」一行和右侧的「@」键取消,空格键与另外两个功能键一样是灰底。电话输入框里「符号」格改为拨号的「#」、「.」格改为「+」,「空格」仍是「*」。 - 26 键的 A、L 两个边键各加宽半个字母(与参考皮肤 15:10 的键宽一致),末行「分词」「退格」按同一比例加宽(原来是 1.25 倍)。 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 3b2436dc..8d725763 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -580,6 +580,7 @@ open class ImeKeyboardView( }, // The nine-key rail: stroke glyphs are not digits, so it stays on symbols. createSymbolRail = { requireNineKeySymbolRailController().buildRail() }, + onShowChoicePopup = ::showChoicePopup, markSideKey = { key -> key.setTag(MARK_SIDE_KEY, true) }, markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) }, onStroke = ::onStrokeKey, diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt index 767ced7b..01afd116 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt @@ -16,10 +16,15 @@ internal object ProductionKeyPolicy { val rightOuter: Float, ) - /** Nine-key bottom row: 123 | 空格 | 中/英. The space bar is the wide key. */ + /** + * Nine-key bottom row: 123 | 空格 | 中/英, in the reference's own weights. + * Its nine-key board and its stroke board give that row the same three + * figures — 45.5, 134, 45.5 — inside the 225-unit centre column + * (`input_kbd_pinyin9.xml:87-100`, `input_kbd_stroke.xml:69-84`). + */ data class NineKeyBottomRowWeights(val side: Float, val space: Float) - fun nineKeyBottomRowWeights(): NineKeyBottomRowWeights = NineKeyBottomRowWeights(side = 0.7f, space = 1.6f) + fun nineKeyBottomRowWeights(): NineKeyBottomRowWeights = NineKeyBottomRowWeights(side = 45.5f, space = 134f) /** * Visual reference geometry for the shared Chinese/English 26-key bottom row: diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeGridPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeGridPolicy.kt new file mode 100644 index 00000000..4c444893 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeGridPolicy.kt @@ -0,0 +1,54 @@ +package llc.slacker.openime.keyboard + +import llc.slacker.openime.candidate.Stroke + +/** + * The reference's stroke keyboard grid. + * + * Three columns of three cells, read from `input_kbd_stroke.xml:16-66`: the + * five strokes in their usual order (一 丨 丿 丶 折), 通配, and the three cells + * the reference puts beside them. Every cell carries a corner digit, and an up + * swipe types it: the skin gives each one `ficon_text` with a matching + * `fusiyin_keycode` (一 is `ficon_text="1" fusiyin_keycode="0x3031"`, + * `input_kbd_stroke.xml:19-22`, 通配 `ficon_text="6"`, line 57-61). + */ +internal object StrokeGridPolicy { + /** + * One cell. A cell with [stroke] feeds the stroke engine; a cell with + * [commit] types its character at once, as a `commitstr` key does. + */ + data class Cell( + val label: String, + val corner: String, + val tag: String, + val stroke: String? = null, + val commit: String? = null, + ) + + val ROWS: List> = listOf( + listOf( + strokeCell(Stroke.HENG), + strokeCell(Stroke.SHU), + strokeCell(Stroke.PIE), + ), + listOf( + strokeCell(Stroke.DIAN), + strokeCell(Stroke.ZHE), + Cell("通配", "6", "key-stroke:wildcard", stroke = Stroke.WILDCARD_GLYPH), + ), + listOf( + // The reference's three extra cells: @# with the corner 7, then the + // two punctuation marks it commits at once (`commitstr=":"` / + // `";"`, `input_kbd_stroke.xml:46,62`). + Cell("@#", "7", "key-stroke:at", commit = "7"), + Cell(":", "8", "key-stroke:colon", commit = ":"), + Cell(";", "9", "key-stroke:semicolon", commit = ";"), + ), + ) + + /** The @# cell's own list (`long_press_bubble_text`, `input_kbd_stroke.xml:32`). */ + val AT_SYMBOL_CHOICES = listOf("/", "-", "_", "@", "#", ".", "7") + + private fun strokeCell(stroke: Stroke) = + Cell(stroke.glyph, stroke.digit.toString(), "key-stroke:${stroke.code}", stroke = stroke.glyph) +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt index 8646779e..7faf197b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -1,18 +1,18 @@ package llc.slacker.openime.keyboard import android.content.Context +import android.view.View import android.widget.LinearLayout import android.widget.ScrollView -import llc.slacker.openime.candidate.Stroke import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView /** * The 笔画 keyboard. It keeps the nine-key frame (symbol rail over 符号, * 删除/重输/确定 column, 123 · 空格 · 中/英 row) so switching between the two moves nothing - * the user already knows, and puts the five strokes and 通配 in two rows of - * large keys in their usual order: 一 丨 丿 / 丶 乛 通配, numbered 1–5 as in - * other stroke keyboards. + * the user already knows, and lays the strokes out as the reference does: a + * 3x3 grid whose last row carries 通配, @# and the two punctuation cells + * (`input_kbd_stroke.xml:16-66`, see [StrokeGridPolicy]). */ internal class StrokeKeyboardRenderer( private val context: Context, @@ -31,6 +31,7 @@ internal class StrokeKeyboardRenderer( private val createBackspaceKey: () -> ImeKeyView, private val createSpaceVoiceKey: (label: String, onTap: () -> Unit) -> ImeKeyView, private val createSymbolRail: () -> ScrollView, + private val onShowChoicePopup: (View, List) -> Unit, private val markSideKey: (ImeKeyView) -> Unit, private val markWhiteKey: (ImeKeyView) -> Unit, private val onStroke: (String) -> Unit, @@ -124,37 +125,49 @@ internal class StrokeKeyboardRenderer( } private fun buildStrokeGrid(): LinearLayout { - val rowHeightDp = gridHeightDp() / ROWS.size + val rows = StrokeGridPolicy.ROWS + val rowHeightDp = gridHeightDp() / rows.size val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - ROWS.forEach { strokes -> + rows.forEach { cells -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - strokes.forEach { stroke -> - val key = if (stroke == null) { - createKey("通配", false, null, ImeTypographyTokens.BODY_SP) { - onStroke(Stroke.WILDCARD_GLYPH) - }.apply { - tag = "key-stroke:wildcard" - contentDescription = "通配,代替一笔" - } - } else { - createKey(stroke.glyph, false, stroke.digit.toString(), ImeTypographyTokens.SYMBOL_SP) { - onStroke(stroke.glyph) - }.apply { - tag = "key-stroke:${stroke.code}" - contentDescription = "${stroke.digit},${stroke.label}" - // Swipe up types the key's digit, as on the nine-key keyboard. - onSwipeUp = { onCommitCharacter(stroke.digit.toString()) } - swipeUpEnabled = this@StrokeKeyboardRenderer.swipeUpEnabled - } - } - markWhiteKey(key) - row.addView(key, keyParams(rowHeightDp)) - } + cells.forEach { cell -> row.addView(strokeCellKey(cell), keyParams(rowHeightDp)) } grid.addView(row, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(rowHeightDp))) } return grid } + /** One cell of the grid: a stroke, 通配, or a mark the cell commits itself. */ + private fun strokeCellKey(cell: StrokeGridPolicy.Cell): ImeKeyView { + val stroke = cell.stroke + val key = if (stroke != null) { + createKey(cell.label, false, cell.corner, ImeTypographyTokens.SYMBOL_SP) { onStroke(stroke) } + } else { + val commit = cell.commit.orEmpty() + createKey(cell.label, false, cell.corner, ImeTypographyTokens.SYMBOL_SP) { onCommitCharacter(commit) } + } + return key.apply { + tag = cell.tag + contentDescription = if (stroke != null && cell.label == WILDCARD_LABEL) { + "${cell.corner},通配,代替一笔" + } else if (stroke != null) { + "${cell.corner},${cell.label}" + } else { + "${cell.label},角标 ${cell.corner}" + } + // Swipe up types the key's digit, as on the nine-key keyboard. + val digit = cell.corner + onSwipeUp = { onCommitCharacter(digit) } + swipeUpEnabled = this@StrokeKeyboardRenderer.swipeUpEnabled + if (cell.tag == AT_SYMBOL_TAG) { + setOnLongClickListener { + onShowChoicePopup(this, StrokeGridPolicy.AT_SYMBOL_CHOICES) + true + } + } + markWhiteKey(this) + } + } + private fun keyParams(heightDp: Int, weight: Float = 1f) = LinearLayout.LayoutParams(0, toPx(heightDp), weight) @@ -165,10 +178,7 @@ internal class StrokeKeyboardRenderer( LinearLayout.LayoutParams(0, toPx(bodyHeightDp()), weight) private companion object { - /** null is 通配. */ - val ROWS: List> = listOf( - listOf(Stroke.HENG, Stroke.SHU, Stroke.PIE), - listOf(Stroke.DIAN, Stroke.ZHE, null), - ) + const val WILDCARD_LABEL = "通配" + const val AT_SYMBOL_TAG = "key-stroke:at" } } diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt index 4b69b759..b5a2faac 100644 --- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import llc.slacker.openime.keyboard.ProductionKeyPolicy import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test /** Regression coverage for current-main production key geometry. */ @@ -46,12 +47,14 @@ class ProductionKeyPolicyTest { } @Test - fun nineKeyBottomRowGivesTheSpaceBarMoreRoomThanTheSideKeys() { + fun nineKeyBottomRowUsesTheReferencesOwnWeights() { val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() - assertEquals(0.7f, weights.side, 0.0001f) - assertEquals(1.6f, weights.space, 0.0001f) - // Same total as the former 1:1:1 row, so the grid above is unaffected. - assertEquals(3f, weights.side * 2 + weights.space, 0.0001f) + assertEquals(45.5f, weights.side, 0.0001f) + assertEquals(134f, weights.space, 0.0001f) + // The three fill the reference's 225-unit centre column exactly, and + // the space bar keeps better than twice a side key's width. + assertEquals(225f, weights.side * 2 + weights.space, 0.0001f) + assertTrue(weights.space > weights.side * 2) } @Test diff --git a/app/src/test/java/llc/slacker/openime/StrokeGridPolicyTest.kt b/app/src/test/java/llc/slacker/openime/StrokeGridPolicyTest.kt new file mode 100644 index 00000000..20bd2894 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/StrokeGridPolicyTest.kt @@ -0,0 +1,85 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.StrokeGridPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StrokeGridPolicyTest { + /** The reference's grid reads 一 丨 丿 / 丶 折 通配 / @# :; (`input_kbd_stroke.xml:16-66`). */ + @Test + fun theGridCarriesTheReferencesNineCellsInItsOrder() { + assertEquals( + listOf( + listOf("一", "丨", "丿"), + listOf("丶", "乛", "通配"), + listOf("@#", ":", ";"), + ), + StrokeGridPolicy.ROWS.map { row -> row.map { it.label } }, + ) + } + + /** Every cell carries the skin's own corner digit, 1-9 down the grid. */ + @Test + fun everyCellCarriesItsCornerDigit() { + assertEquals( + listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + ), + StrokeGridPolicy.ROWS.map { row -> row.map { it.corner } }, + ) + } + + /** The five strokes and 通配 feed the engine; the other three commit themselves. */ + @Test + fun theCellsSplitBetweenTheEngineAndDirectCommits() { + val cells = StrokeGridPolicy.ROWS.flatten() + val strokes = cells.filter { it.stroke != null } + val commits = cells.filter { it.commit != null } + assertEquals(6, strokes.size) + assertEquals( + listOf("一", "丨", "丿", "丶", "乛", "通配"), + strokes.map { it.label }, + ) + // :and ;type themselves, and @# types the digit its corner names. + assertEquals(listOf("@#", ":", ";"), commits.map { it.label }) + assertEquals(listOf("7", ":", ";"), commits.map { it.commit }) + cells.forEach { + assertTrue( + "${it.label} must not both feed the engine and commit", + (it.stroke == null) != (it.commit == null), + ) + } + assertNull("no cell of the grid is both", cells.first { it.label == "通配" }.commit) + } + + /** The @# cell's bubble is its own, with the 7 the reference puts last. */ + @Test + fun theAtSymbolCellOffersTheReferencesOwnList() { + assertEquals(listOf("/", "-", "_", "@", "#", ".", "7"), StrokeGridPolicy.AT_SYMBOL_CHOICES) + assertEquals( + "key-stroke:at", + StrokeGridPolicy.ROWS.flatten().first { it.label == "@#" }.tag, + ) + } + + /** The tags the volume harness types stay the stroke codes plus the wildcard. */ + @Test + fun theEngineCellsKeepTheStrokeCodeTags() { + val tags = StrokeGridPolicy.ROWS.flatten().filter { it.stroke != null }.map { it.tag } + assertEquals( + listOf( + "key-stroke:h", + "key-stroke:s", + "key-stroke:p", + "key-stroke:n", + "key-stroke:z", + "key-stroke:wildcard", + ), + tags, + ) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index da098a8d..ca459865 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -189,3 +189,7 @@ Add new entries at the end. - **The number page is the reference's own number layout.** The page is `input_kbd_number_from26.xml:5-42`, the layout the reference's 26-key `123` key opens (`input_kbd_pinyin26.xml:134`, `cls_button_123` with `target="9"`): a symbol rail over the return key in the left column, three digit columns each closed by a function cell, and a delete / space / send column on the right. The column weights are the reference's own 66 / 75 / 75 / 75 / 66 (`input_kbd_number_from26.xml:8,14-38`); the left column is the rail's 156 units against the return key's 52 (`input_kbd_number_from26.xml:10-15`), three key rows against one; send is `height_weight="2"` (`input_kbd_number_from26.xml:39`), twice the delete and space keys above it; and the digits read down the columns 1/4/7, 2/5/8, 3/6/9 with `符号`, `0` and `.` as the last cell of each (`input_kbd_number_from26.xml:17-34`). Every digit sits on `cls_keyboard_number9_button`, a white key with `key_number9` (`style.xml:394-395`), and the two function cells follow their own class: the return key is grey (`cls_button_back_26key` over `cls_button_back`, `style.xml:857-858,838-842`) and so is send (`cls_button_enter9` over `cls_keyboard9_button_func`, `style.xml:824-826,398-401`), which is why this page's space key is grey where the letter pages draw theirs white. *Reason:* our page was a 3x3 grid over a `返回 / 空格 / 0` row with `退格 / . / @ / 发送` down the right — an arrangement the reference does not ship, with the zero parked beside the space bar and an `@` key no number page declares. A phone field in the reference dials from a page our build does not draw, so there the symbol cell carries the dialer's `#` and the period cell its `+` (our own rule, unchanged in effect: `*`, `+` and `#` stay reachable); the reference's other number page keeps the same rail and swaps only those two cells, 符号 at the foot of the left column and the return key in the digit grid (`input_kbd_number.xml:19-20,24-25`), so the slot is the page's, not the key's. + +- **The stroke grid is the reference's own nine cells, and the nine-key row its own weights.** + The stroke board's centre is the reference's 3x3 grid (`input_kbd_stroke.xml:16-66`): 一 丨 丿 on top, 丶 折 通配 next, and @# :; last, read down its three columns. Every cell carries the skin's corner digit — `ficon_text` 1 to 9 with the matching `fusiyin_keycode` (`input_kbd_stroke.xml:19-22`, 通配 at 57-61) — the two punctuation cells commit themselves (`commitstr=":"` and `";"`, lines 46 and 62), and the @# cell opens the reference's own list `/ - _ @ # . 7` (`long_press_bubble_text`, line 32). The bottom row of both this board and the nine-key board then takes the reference's own 45.5 / 134 / 45.5, which fills its 225-unit centre column exactly (`input_kbd_stroke.xml:69-84`, `input_kbd_pinyin9.xml:87-100`). + *Reason:* our stroke board drew two rows of six cells and left the reference's last row unwritten, so 通配's digit 6 and the two punctuation marks had no key at all, and the row weights were our own 0.7 against 1.6 — a space bar that took 53% of the row where the reference gives it 60%. The @# cell types its corner digit on a tap, as the nine-key @# cell does, because the skin declares no `keycode` for it; its up swipe types the digit too, like every other cell of the grid. From 852f7925c1e88ff67989044c6bb0fe7d05006b31 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:36:13 +0800 Subject: [PATCH 18/94] Put the idle toolbar in the reference's order and its own scrolling strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's idle row runs clipboard, emoji, board selector, where ours led with the board selector, and its icons sit in a strip that scrolls when they do not fit, with the slack in a spacer at the strip's own end (sliderenable, input_toolbar_idle.xml:14,34). Ours stretched the icons by weight instead, so a narrow screen shrank them, and the spacer sat outside the strip. 收起 stays outside it. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../FloatingGameKeyboardInstrumentedTest.kt | 37 +++++++++- .../slacker/openime/keyboard/ImeTopZone.kt | 68 +++++++++++++------ docs/DECISIONS.md | 4 ++ 4 files changed, 89 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b578fa6c..1144b242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 空闲工具栏的按键顺序改为参考皮肤的顺序(剪贴板、表情、切换键盘…),整条图标栏改为可左右滑动:图标保持固定宽度,放不下时滑动查看,多余宽度留在图标栏内部;「收起」键仍在最右、不随滑动移动。 - 笔画键盘对齐参考皮肤:九宫格补齐为三行九格(一 丨 丿 / 丶 折 通配 / @# :;),每格右上角印角标 1–9、上滑输入该数字;「通配」角标 6;新增的「:」「;」直接上屏对应标点,「@#」长按弹出参考皮肤自带的「/ - _ @ # . 7」。九键与笔画底行的空格键改按参考权重(45.5 : 134 : 45.5)加宽。 - 数字键盘改为参考皮肤自己的数字页:左列是符号栏(占三行键高)加底部「返回」键,中间三列数字(1/4/7、2/5/8、3/6/9),每列最后一格分别是「符号」「0」「.」,右列是「退格」「空格」「发送」三键、发送键占两格高;列宽按参考权重 66 : 75 : 75 : 75 : 66。原来横排的「返回 空格 0」一行和右侧的「@」键取消,空格键与另外两个功能键一样是灰底。电话输入框里「符号」格改为拨号的「#」、「.」格改为「+」,「空格」仍是「*」。 diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt index 5c74f518..18c2212a 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.os.SystemClock import android.util.DisplayMetrics +import android.widget.HorizontalScrollView import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -77,10 +78,42 @@ class FloatingGameKeyboardInstrumentedTest { private fun View.visibleByTag(tag: String): Boolean = findViewWithTag(tag)?.visibility == View.VISIBLE + /** The icons live in the toolbar's own scrolling strip, so search the row's subtree. */ private fun View.toolbarIconByDescription(description: String): View? { val row = findViewWithTag("toolbar-row") ?: return null - return (0 until row.childCount).map(row::getChildAt) - .firstOrNull { it.contentDescription == description } + fun find(view: View): View? { + if (view.contentDescription == description) return view + if (view is ViewGroup) { + for (index in 0 until view.childCount) find(view.getChildAt(index))?.let { return it } + } + return null + } + return find(row) + } + + /** The idle row is the reference's own order, in the reference's own scrolling strip. */ + @Test + fun theIdleIconsFollowTheReferencesOrderInsideAScrollingStrip() = withKeyboard { harness, _, keyboard -> + harness.awaitMain { + val strip = keyboard.findViewWithTag("toolbar-icon-strip") ?: return@awaitMain null + val scroll = keyboard.findViewWithTag("toolbar-icon-scroll") + assertTrue( + "The idle row must scroll like the reference's own strip", + scroll is HorizontalScrollView, + ) + val labels = (0 until strip.childCount).map(strip::getChildAt) + .filter { it.visibility == View.VISIBLE && it.contentDescription != null } + .map { it.contentDescription.toString() } + listOf("剪贴板", "表情", "切换键盘").forEach { + assertTrue("$it must keep its key in the idle row, got $labels", it in labels) + } + assertTrue( + "The reference puts the clipboard first, then emoji, then the board selector: $labels", + labels.indexOf("剪贴板") < labels.indexOf("表情") && + labels.indexOf("表情") < labels.indexOf("切换键盘"), + ) + true + } } @Test 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 c3e69759..7badea8e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -96,7 +96,8 @@ internal class ImeTopZone( private lateinit var compactToolbarIcons: List private var compact = false private lateinit var keyboardHide: View - private val toolbarSpacer = View(context) + private lateinit var toolbarIconScroll: HorizontalScrollView + private val toolbarStripSpacer = View(context) private lateinit var associationScroll: HorizontalScrollView private var toolbarMode = ToolbarMode.NORMAL private var associationsShown = false @@ -135,21 +136,56 @@ internal class ImeTopZone( setPadding(toPx(6), 0, toPx(6), 0) minimumHeight = toPx(ImeGeometryTokens.TOOLBAR_HEIGHT_DP) } - val keyboardIcon = toolbarIcon(R.drawable.ic_keyboard, "切换键盘", "keyboard-selector", onKeyboardSelect) val clipboardIcon = toolbarIcon(R.drawable.ic_clipboard, "剪贴板", "clipboard-toolbar", onClipboard) - val phraseIcon = toolbarIcon(R.drawable.ic_bubble, "常用语", "quick-phrase-toolbar", onQuickPhrases) val emojiIcon = toolbarIcon(R.drawable.ic_emoji, "表情", "toolbar", onEmoji) + val phraseIcon = toolbarIcon(R.drawable.ic_bubble, "常用语", "quick-phrase-toolbar", onQuickPhrases) + val keyboardIcon = toolbarIcon(R.drawable.ic_keyboard, "切换键盘", "keyboard-selector", onKeyboardSelect) 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 + splitIcon).distinct().forEach { - toolbarRow.addView(it, LinearLayout.LayoutParams(toPx(TOOLBAR_BUTTON_UNITS), toPx(48))) + // The reference's own idle order, for the tools we show: clipboard, + // emoji, then the board selector (`input_toolbar_idle.xml:21-25`). Our + // own extras follow them. Floating (game) mode keeps the compact set, + // where quick phrases take the clipboard's place. + toolbarIcons = listOf( + clipboardIcon, + emojiIcon, + keyboardIcon, + textEditIcon, + undoIcon, + toolsIcon, + ) + compactToolbarIcons = listOf(emojiIcon, phraseIcon, keyboardIcon, toolsIcon) + + // The strip scrolls when the tools do not fit, as the reference's own + // toolbar strip does (`sliderenable="true" sliderstopslowdown="true"`, + // `input_toolbar_idle.xml:14`), and its slack sits in a spacer at the end + // of the strip (`toolbar_right_spacer`, line 34). + val strip = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + tag = "toolbar-icon-strip" + } + (toolbarIcons + compactToolbarIcons + splitIcon).distinct().forEach { + strip.addView(it, LinearLayout.LayoutParams(toPx(TOOLBAR_BUTTON_UNITS), toPx(48))) } + toolbarStripSpacer.apply { tag = "toolbar-strip-spacer" } + strip.addView(toolbarStripSpacer, LinearLayout.LayoutParams(0, 1, 1f)) + toolbarIconScroll = HorizontalScrollView(context).apply { + tag = "toolbar-icon-scroll" + isHorizontalScrollBarEnabled = false + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isFillViewport = true + addView( + strip, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + toPx(48), + ), + ) + } + toolbarRow.addView(toolbarIconScroll, LinearLayout.LayoutParams(0, toPx(48), 1f)) // Association state: "‹ words… ∨". The back control and the hide // control are fixed-width; the words take the rest of the row. @@ -222,11 +258,6 @@ internal class ImeTopZone( // of the strip empty: `btn_hide` stays its own button on the right // (`input_toolbar_idle.xml:41-43`, `cls_toolbar_idle_button` width 40, // `style.xml:237-248`). - toolbarSpacer.apply { - tag = "toolbar-spacer" - visibility = View.VISIBLE - } - toolbarRow.addView(toolbarSpacer, LinearLayout.LayoutParams(0, toPx(48), 1f)) keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard) toolbarRow.addView( keyboardHide, @@ -657,14 +688,13 @@ internal class ImeTopZone( associationScroll.visibility = if (association) View.VISIBLE else View.GONE autofillScroll.visibility = if (autofill) View.VISIBLE else View.GONE keyboardHide.visibility = View.VISIBLE - // The slack of the strip belongs to the spacer before 收起, so the icons - // and the hide key keep the reference's own width. A strip or the - // autofill chips take the slack instead. - (toolbarSpacer.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + // The icons keep the reference's own width and their own row takes the + // slack; a suggestion strip or the autofill chips take it instead. + (toolbarIconScroll.layoutParams as? LinearLayout.LayoutParams)?.let { params -> val flexible = normal params.width = 0 params.weight = if (flexible) 1f else 0f - toolbarSpacer.layoutParams = params + toolbarIconScroll.layoutParams = params } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ca459865..50c62223 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -193,3 +193,7 @@ Add new entries at the end. - **The stroke grid is the reference's own nine cells, and the nine-key row its own weights.** The stroke board's centre is the reference's 3x3 grid (`input_kbd_stroke.xml:16-66`): 一 丨 丿 on top, 丶 折 通配 next, and @# :; last, read down its three columns. Every cell carries the skin's corner digit — `ficon_text` 1 to 9 with the matching `fusiyin_keycode` (`input_kbd_stroke.xml:19-22`, 通配 at 57-61) — the two punctuation cells commit themselves (`commitstr=":"` and `";"`, lines 46 and 62), and the @# cell opens the reference's own list `/ - _ @ # . 7` (`long_press_bubble_text`, line 32). The bottom row of both this board and the nine-key board then takes the reference's own 45.5 / 134 / 45.5, which fills its 225-unit centre column exactly (`input_kbd_stroke.xml:69-84`, `input_kbd_pinyin9.xml:87-100`). *Reason:* our stroke board drew two rows of six cells and left the reference's last row unwritten, so 通配's digit 6 and the two punctuation marks had no key at all, and the row weights were our own 0.7 against 1.6 — a space bar that took 53% of the row where the reference gives it 60%. The @# cell types its corner digit on a tap, as the nine-key @# cell does, because the skin declares no `keycode` for it; its up swipe types the digit too, like every other cell of the grid. + +- **The idle toolbar keeps the reference's own order, inside its own scrolling strip.** + The icons follow the reference's row — the clipboard, then emoji, then the board selector (`input_toolbar_idle.xml:21-25`) — and they live in a strip that scrolls when they do not all fit, with the strip's slack in a spacer at its own end (`toolbar_left_container` with `sliderenable="true" sliderstopslowdown="true"`, `input_toolbar_idle.xml:14`, and `toolbar_right_spacer`, line 34). 收起 stays outside the strip, its own button on the right (`btn_hide`, line 40). + *Reason:* our row led with the board selector, which the reference puts after the clipboard and the emoji keys, and our icons were stretched by a row weight: on a narrow screen they shrank instead of scrolling, and the slack spacer sat outside the strip, so it moved 收起's neighbours rather than staying inside the scrolled content. The tools the reference lists that this build does not ship (translate, 繁简, 单手, handwriting lookup) are not in the row; quick phrases keep their own key in the floating toolbar, which is also where the compact set lives. From 1bdf76c201b08114d42102601c34d4e646181251 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:38:45 +0800 Subject: [PATCH 19/94] Move the symbol page's categories into the reference's bottom strip input_kbd_symbol.xml ends the page with a row of the return key and the category container, weighted 85 against 13.1, and scrolling. Ours put the categories down a 74dp column at the left edge, which cost the grid a fifth of the page's width. The strip is the chip row the emoji page and the expanded candidate page already use, so the symbol surfaces agree now. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/panel/ImePanelRenderer.kt | 46 ++++++++++++------- docs/DECISIONS.md | 4 ++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1144b242..e8fa22f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 符号页的分类标签移到页面底部:一条可横向滑动的标签栏(参考皮肤把分类放在符号页最后一行、返回键右侧),符号格重新占满页宽;每行仍是 5 格以保证 48dp 的触控尺寸(参考皮肤每行 10 格)。 - 空闲工具栏的按键顺序改为参考皮肤的顺序(剪贴板、表情、切换键盘…),整条图标栏改为可左右滑动:图标保持固定宽度,放不下时滑动查看,多余宽度留在图标栏内部;「收起」键仍在最右、不随滑动移动。 - 笔画键盘对齐参考皮肤:九宫格补齐为三行九格(一 丨 丿 / 丶 折 通配 / @# :;),每格右上角印角标 1–9、上滑输入该数字;「通配」角标 6;新增的「:」「;」直接上屏对应标点,「@#」长按弹出参考皮肤自带的「/ - _ @ # . 7」。九键与笔画底行的空格键改按参考权重(45.5 : 134 : 45.5)加宽。 - 数字键盘改为参考皮肤自己的数字页:左列是符号栏(占三行键高)加底部「返回」键,中间三列数字(1/4/7、2/5/8、3/6/9),每列最后一格分别是「符号」「0」「.」,右列是「退格」「空格」「发送」三键、发送键占两格高;列宽按参考权重 66 : 75 : 75 : 75 : 66。原来横排的「返回 空格 0」一行和右侧的「@」键取消,空格键与另外两个功能键一样是灰底。电话输入框里「符号」格改为拨号的「#」、「.」格改为「+」,「空格」仍是「*」。 diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 8ff6dcfa..67e7961f 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -477,22 +477,18 @@ internal class ImePanelRenderer( notifyRebuilt: Boolean, ) { body.removeAllViews() - val categories = SymbolCatalog.CATEGORIES - body.orientation = LinearLayout.HORIZONTAL - body.setPadding(toPx(8), toPx(5), toPx(4), 0) - val categoryColumn = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - categories.forEach { category -> - categoryColumn.addView(filterChip(category, category == symbolCategory) { - if (category != symbolCategory) { - symbolCategory = category - renderSymbolContent(body, notifyRebuilt = true) - } - }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(44))) - } - body.addView(panelVerticalScroll(categoryColumn, "symbol-categories"), - LinearLayout.LayoutParams(toPx(74), LinearLayout.LayoutParams.MATCH_PARENT).apply { marginEnd = toPx(5) }) + body.orientation = LinearLayout.VERTICAL + body.setPadding(toPx(8), toPx(5), toPx(4), toPx(4)) + val content = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - body.addView(content, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + body.addView( + content, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f, + ), + ) if (symbolCategory == "自定义") { content.addView( createPanelButton("管理自定义符号", ImeTypographyTokens.BODY_SP, true).apply { @@ -528,7 +524,9 @@ internal class ImePanelRenderer( ) } // Kaomoji and other long items get three wide cells a row and a smaller - // size; five narrow cells cut (づ。◕‿‿◕。)づ down to "(づ。". + // size; five narrow cells cut (づ。◕‿‿◕。)づ down to "(づ。". The reference + // halves its symbol cells to ten a row (`input_kbd_symbol.xml:6-15`), + // which our touch-target floor does not allow. val wide = items.any { it.codePointCount(0, it.length) > WIDE_SYMBOL_CODE_POINTS } val columns = if (wide) 3 else 5 val textSize = if (wide) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.KEY_LETTER_SP @@ -566,6 +564,22 @@ internal class ImePanelRenderer( 1f, ), ) + + // The reference keeps this page's category strip in its bottom row, + // beside the return key (`input_kbd_symbol.xml:44-47`, weight 85 against + // the key's 13.1, `cls_symbol_tab_container`, `style.xml:707-710`). + body.addView( + panelChipScroll(SymbolCatalog.CATEGORIES, symbolCategory, { category -> + if (category != symbolCategory) { + symbolCategory = category + renderSymbolContent(body, notifyRebuilt = true) + } + }, heightDp = 32), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(32), + ).apply { bottomMargin = toPx(6) }, + ) applyTheme() if (notifyRebuilt) onHierarchyRebuilt() } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 50c62223..3dfd697f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -197,3 +197,7 @@ Add new entries at the end. - **The idle toolbar keeps the reference's own order, inside its own scrolling strip.** The icons follow the reference's row — the clipboard, then emoji, then the board selector (`input_toolbar_idle.xml:21-25`) — and they live in a strip that scrolls when they do not all fit, with the strip's slack in a spacer at its own end (`toolbar_left_container` with `sliderenable="true" sliderstopslowdown="true"`, `input_toolbar_idle.xml:14`, and `toolbar_right_spacer`, line 34). 收起 stays outside the strip, its own button on the right (`btn_hide`, line 40). *Reason:* our row led with the board selector, which the reference puts after the clipboard and the emoji keys, and our icons were stretched by a row weight: on a narrow screen they shrank instead of scrolling, and the slack spacer sat outside the strip, so it moved 收起's neighbours rather than staying inside the scrolled content. The tools the reference lists that this build does not ship (translate, 繁简, 单手, handwriting lookup) are not in the row; quick phrases keep their own key in the floating toolbar, which is also where the compact set lives. + +- **The symbol page keeps its categories in a bottom strip.** + The reference's symbol page ends with a row of the return key and the category container: the container is weighted 85 against the key's 13.1 (`input_kbd_symbol.xml:44-47`) and scrolls (`cls_symbol_tab_container` with `sliderenable="true"`, `style.xml:707-710`), and each category is a transparent pill whose label darkens when it is the current one (`cls_input_kbd_symbol_option`, `style.xml:715-724`: `symbol_option_text` at 60% to `symbol_option_selectedtext` at 85%, `values/colors.xml:16-17`). + *Reason:* our categories ran down a 74dp column at the page's left edge, which no reference page has, so the symbol cells lost a fifth of the page's width. The strip is the chip row this app already uses for the emoji page and the expanded candidate page, so the two symbol surfaces now agree. The cells stay five to a row where the reference draws ten (`input_kbd_symbol.xml:6-15`): ten cells across a phone put each of them under our 48dp touch target. From a7714966dc5316608cde3bd922121e01c70f1529 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:40:45 +0800 Subject: [PATCH 20/94] Give the symbol page its own 123 and delete keys The reference's symbol row closes with its 123 key and its backspace at the two ends (input_kbd_symbol.xml:31-41, weight 15 against the cells' 70), so the page reaches the digits and deletes without being left first. Our cells are five to a row, so both keys take the ends of the tab strip's row, the row the reference gives to the page's own return key. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeKeyboardView.kt | 1 + .../slacker/openime/panel/ImePanelRenderer.kt | 42 ++++++++++++++++--- docs/DECISIONS.md | 4 ++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fa22f6..e91d1632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 符号页底部增加「123」与「退格」两个键(参考皮肤把这两个键放在符号那一行的两端):在符号页里可以直接退格,或一键切到数字键盘,不必先退出符号页。 - 符号页的分类标签移到页面底部:一条可横向滑动的标签栏(参考皮肤把分类放在符号页最后一行、返回键右侧),符号格重新占满页宽;每行仍是 5 格以保证 48dp 的触控尺寸(参考皮肤每行 10 格)。 - 空闲工具栏的按键顺序改为参考皮肤的顺序(剪贴板、表情、切换键盘…),整条图标栏改为可左右滑动:图标保持固定宽度,放不下时滑动查看,多余宽度留在图标栏内部;「收起」键仍在最右、不随滑动移动。 - 笔画键盘对齐参考皮肤:九宫格补齐为三行九格(一 丨 丿 / 丶 折 通配 / @# :;),每格右上角印角标 1–9、上滑输入该数字;「通配」角标 6;新增的「:」「;」直接上屏对应标点,「@#」长按弹出参考皮肤自带的「/ - _ @ # . 7」。九键与笔画底行的空格键改按参考权重(45.5 : 134 : 45.5)加宽。 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 8d725763..df654831 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -691,6 +691,7 @@ open class ImeKeyboardView( ) }, createPanelButton = ::button, + createBackspaceKey = ::backspaceKey, createTitle = ::title, createEmojiCell = emojiCellFactory::create, gridCellParams = ::gridCellParams, diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 67e7961f..0aa00bf8 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -57,6 +57,7 @@ internal class ImePanelRenderer( onTap: () -> Unit, ) -> ImeKeyView, private val createPanelButton: (String, Float, Boolean) -> TextView, + private val createBackspaceKey: () -> ImeKeyView, private val createTitle: (String, Boolean) -> TextView, private val createEmojiCell: (String) -> View, private val gridCellParams: (Int, Int, Int) -> LinearLayout.LayoutParams, @@ -565,20 +566,49 @@ internal class ImePanelRenderer( ), ) - // The reference keeps this page's category strip in its bottom row, - // beside the return key (`input_kbd_symbol.xml:44-47`, weight 85 against - // the key's 13.1, `cls_symbol_tab_container`, `style.xml:707-710`). - body.addView( + // The reference keeps this page's category strip and its two function + // keys in its bottom rows: 123 and 退格 close the symbol row + // (`input_kbd_symbol.xml:31-41`, weight 15 against the cells' 70) and the + // strip shares the next one with the return key + // (`input_kbd_symbol.xml:44-47`, weight 85 against 13.1, + // `cls_symbol_tab_container`, `style.xml:707-710`). Our cells are five to + // a row, so both keys take the ends of the strip's own row. + val stripRow = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + tag = "symbol-strip-row" + } + stripRow.addView( + createKey("123", true, ImeTypographyTokens.BODY_SP) { + onModeSelected(KeyboardMode.DIGITS) + }.apply { contentDescription = "数字键盘" }, + LinearLayout.LayoutParams( + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ), + ) + stripRow.addView( panelChipScroll(SymbolCatalog.CATEGORIES, symbolCategory, { category -> if (category != symbolCategory) { symbolCategory = category renderSymbolContent(body, notifyRebuilt = true) } }, heightDp = 32), + LinearLayout.LayoutParams(0, toPx(32), 1f), + ) + stripRow.addView( + createBackspaceKey(), + LinearLayout.LayoutParams( + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ), + ) + body.addView( + stripRow, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(32), - ).apply { bottomMargin = toPx(6) }, + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ), ) applyTheme() if (notifyRebuilt) onHierarchyRebuilt() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3dfd697f..d3c2b53c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -201,3 +201,7 @@ Add new entries at the end. - **The symbol page keeps its categories in a bottom strip.** The reference's symbol page ends with a row of the return key and the category container: the container is weighted 85 against the key's 13.1 (`input_kbd_symbol.xml:44-47`) and scrolls (`cls_symbol_tab_container` with `sliderenable="true"`, `style.xml:707-710`), and each category is a transparent pill whose label darkens when it is the current one (`cls_input_kbd_symbol_option`, `style.xml:715-724`: `symbol_option_text` at 60% to `symbol_option_selectedtext` at 85%, `values/colors.xml:16-17`). *Reason:* our categories ran down a 74dp column at the page's left edge, which no reference page has, so the symbol cells lost a fifth of the page's width. The strip is the chip row this app already uses for the emoji page and the expanded candidate page, so the two symbol surfaces now agree. The cells stay five to a row where the reference draws ten (`input_kbd_symbol.xml:6-15`): ten cells across a phone put each of them under our 48dp touch target. + +- **The symbol page carries its own 123 and 退格 keys.** + The two keys close the reference's symbol row (`input_kbd_symbol.xml:31-41`): the 123 key and the backspace key sit at that row's two ends, weighted 15 each against the seven cells' 70, so the page can reach the digits and delete without being left first. Our cells are five to a row, so the two keys take the two ends of the tab strip's row — the row the reference gives to the page's own return key (`input_kbd_symbol.xml:44-47`). + *Reason:* the page could only type symbols: deleting a character or reaching the digits meant closing it first. Both keys keep our 48dp touch target and use actions the page family already has (switching to the digits layout leaves the panel, exactly as choosing a keyboard from the 切换键盘 page does). From d18ad19762d5204c1be82286c2324c828cee2e10 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:48:24 +0800 Subject: [PATCH 21/94] List the typed symbols in the symbol board's toolbar The reference fills its symbol board's toolbar with the history of what the user has typed (input_toolbar_quickinput.xml:4-18): a 4-unit lead inset, the scrolling list, the candidate strip's own hairline split, a 3-unit gap, a 40-unit close key and a 4-unit tail. Each cell is 36 units wide with a radius-6 press face over candidate-sized text in the candidate text colour (style.xml:1061-1070), and a tap commits through the same path the board's own cells use, so the list re-reads itself after every symbol. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/SymbolHistoryInstrumentedTest.kt | 141 ++++++++++++++ .../slacker/openime/LocalVoiceImeService.kt | 2 + .../openime/data/SymbolRecentRepository.kt | 52 +++++ .../openime/keyboard/ImeKeyboardView.kt | 35 ++++ .../openime/keyboard/ImeThemeApplier.kt | 17 +- .../slacker/openime/keyboard/ImeTopZone.kt | 180 +++++++++++++++++- .../keyboard/RecentSymbolStripPolicy.kt | 33 ++++ .../openime/RecentSymbolStripPolicyTest.kt | 31 +++ docs/DECISIONS.md | 4 + 10 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/data/SymbolRecentRepository.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/RecentSymbolStripPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/RecentSymbolStripPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e91d1632..a99498c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 符号页的工具栏改为显示最近输入过的符号(参考皮肤在符号页工具栏里放的就是这条「历史符号列表」,可横向滑动,最右是关闭键):点一下即可再次上屏,关闭键收起这条列表(本次进入符号页不再出现,下次进入符号页会重新显示)。没输入过符号时不显示这条列表。 - 符号页底部增加「123」与「退格」两个键(参考皮肤把这两个键放在符号那一行的两端):在符号页里可以直接退格,或一键切到数字键盘,不必先退出符号页。 - 符号页的分类标签移到页面底部:一条可横向滑动的标签栏(参考皮肤把分类放在符号页最后一行、返回键右侧),符号格重新占满页宽;每行仍是 5 格以保证 48dp 的触控尺寸(参考皮肤每行 10 格)。 - 空闲工具栏的按键顺序改为参考皮肤的顺序(剪贴板、表情、切换键盘…),整条图标栏改为可左右滑动:图标保持固定宽度,放不下时滑动查看,多余宽度留在图标栏内部;「收起」键仍在最右、不随滑动移动。 diff --git a/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt new file mode 100644 index 00000000..281d37a1 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt @@ -0,0 +1,141 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.Panel +import llc.slacker.openime.data.SymbolRecentRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import org.junit.After +import org.junit.Assert.assertEquals +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 + +/** + * The symbol board's toolbar strip: the symbols the user has typed, as the + * reference lists them (`input_toolbar_quickinput.xml:4-18`). + */ +@RunWith(AndroidJUnit4::class) +class SymbolHistoryInstrumentedTest { + private val context = ApplicationProvider.getApplicationContext() + private val symbols = mutableListOf() + + @After + fun cleanup() { + SymbolRecentRepository.clear(context) + } + + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "onSymbolSelected" -> { symbols.add(args!![0] as String); null } + "onCharacter" -> { symbols.add(args!![0] as String); null } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { + SymbolRecentRepository.clear(context) + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { test(keyboard); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + @Test + fun theSymbolBoardListsTheSymbolsTheUserTyped() { + SymbolRecentRepository.record(context, "★") + SymbolRecentRepository.record(context, "→") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + val strip = keyboard.findViewWithTag("recent-symbol-strip") + assertNotNull("The symbol board must carry the history strip", strip) + assertEquals(View.VISIBLE, strip!!.visibility) + // The row is the reference's own: lead inset, list, split, gap, close + // and tail (`input_toolbar_quickinput.xml:6-15`). + val row = keyboard.findViewWithTag("recent-symbol-row") as ViewGroup + assertEquals( + "Newest symbol first", + listOf("→", "★"), + (0 until row.childCount).map { row.getChildAt(it).contentDescription.toString().substringAfter(":") }, + ) + listOf("recent-symbol-lead", "recent-symbol-split", "recent-symbol-gap", "recent-symbol-close", "recent-symbol-tail") + .forEach { assertNotNull("$it must be part of the strip", keyboard.findViewWithTag(it)) } + } + } + + @Test + fun tappingAHistoryCellCommitsThroughTheSymbolPath() { + SymbolRecentRepository.record(context, "★") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + assertTrue( + "The cell must commit the symbol it shows", + keyboard.findTestTarget("最近符号:★")!!.performClick(), + ) + assertEquals(listOf("★"), symbols) + } + } + + @Test + fun theCloseKeyEndsTheStripUntilTheBoardIsOpenedAgain() { + SymbolRecentRepository.record(context, "★") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + val strip = keyboard.findViewWithTag("recent-symbol-strip")!! + assertEquals(View.VISIBLE, strip.visibility) + assertTrue(keyboard.findViewWithTag("recent-symbol-close")!!.performClick()) + assertEquals("The close key ends the strip", View.GONE, strip.visibility) + assertNotNull( + "The board itself stays open", + keyboard.findViewWithTag("symbol-strip-row"), + ) + keyboard.showPanel(Panel.SYMBOLS) + assertEquals("Opening the board again shows the strip", View.VISIBLE, strip.visibility) + } + } + + @Test + fun otherPanelsNeverCarryTheHistoryStrip() { + SymbolRecentRepository.record(context, "★") + withKeyboard { keyboard -> + keyboard.showPanel(Panel.SYMBOLS) + keyboard.showPanel(Panel.EMOJI) + assertEquals( + "The strip belongs to the symbol board's toolbar only", + View.GONE, + keyboard.findViewWithTag("recent-symbol-strip")!!.visibility, + ) + } + } + + @Test + fun theHistoryKeepsTheNewestFirstWithoutDuplicates() { + SymbolRecentRepository.clear(context) + SymbolRecentRepository.record(context, "★") + SymbolRecentRepository.record(context, "→") + SymbolRecentRepository.record(context, "★") + assertEquals(listOf("★", "→"), SymbolRecentRepository.load(context)) + } +} diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 7dfd7471..b7cbd470 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -46,6 +46,7 @@ import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.data.PersonalizationRepository import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.data.RimeUserDictionaryArchive +import llc.slacker.openime.data.SymbolRecentRepository import llc.slacker.openime.data.UserPhraseRepository import llc.slacker.openime.editor.EditorInfoAdapter import llc.slacker.openime.editor.InputConnectionGateway @@ -1400,6 +1401,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can prepareForManualInput() commitPendingComposition() keyboardView?.clearAssociationCandidates() + SymbolRecentRepository.record(this, symbol) gateway.commitText(symbol) } diff --git a/app/src/main/java/llc/slacker/openime/data/SymbolRecentRepository.kt b/app/src/main/java/llc/slacker/openime/data/SymbolRecentRepository.kt new file mode 100644 index 00000000..118a4f73 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/data/SymbolRecentRepository.kt @@ -0,0 +1,52 @@ +package llc.slacker.openime.data + +import android.content.Context +import org.json.JSONArray + +/** + * Process-persistent MRU list of the symbols the user has typed, the history the + * reference's symbol board lists in its toolbar (`input_toolbar_quickinput.xml:7-10`, + * 历史符号列表 `style.xml:1054-1070`). + */ +internal object SymbolRecentRepository { + private const val PREFS = "openime_symbol_recent" + private const val KEY_ITEMS = "items" + private const val MAX_ITEMS = 40 + + fun load(context: Context): List { + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_ITEMS, null) + ?: return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (index in 0 until array.length()) { + val value = array.optString(index) + if (value.isNotBlank() && value !in this) add(value) + if (size >= MAX_ITEMS) break + } + } + }.getOrDefault(emptyList()) + } + + fun record(context: Context, symbol: String) { + if (symbol.isBlank()) return + val next = buildList { + add(symbol) + load(context).forEach { if (it != symbol && size < MAX_ITEMS) add(it) } + } + val array = JSONArray() + next.forEach(array::put) + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_ITEMS, array.toString()) + .apply() + } + + fun clear(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .remove(KEY_ITEMS) + .apply() + } +} 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 df654831..0eeff846 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -47,6 +47,7 @@ import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.data.SymbolRecentRepository import llc.slacker.openime.editor.EditorInfoAdapter import llc.slacker.openime.editor.enterKeyFaceFor import llc.slacker.openime.editor.enterKeyPresentationFor @@ -338,6 +339,8 @@ open class ImeKeyboardView( private var lastTextMode = KeyboardMode.PINYIN_26 private var preferredChineseMode = ImeSettingsRepository.loadPreferredChineseMode(context) protected var panel = Panel.NONE + /** True once the symbol board's history strip was closed; the next visit shows it again. */ + private var recentSymbolStripDismissed = false // Nested panel flows must be reversible. For example, Settings -> Fuzzy // settings should return to Settings instead of unexpectedly closing all // the way back to the keyboard. @@ -2471,6 +2474,8 @@ open class ImeKeyboardView( expandedPanel.visibility = View.VISIBLE candidateBarController.resetExpandedState() voicePanelController.detachView() + // Each visit to the symbol board starts with its history strip again. + if (panel == Panel.SYMBOLS) recentSymbolStripDismissed = false when (panel) { Panel.TOOLS -> panelRenderer.renderTools() Panel.KEYBOARD_SELECT -> panelRenderer.renderKeyboardSelect() @@ -2484,11 +2489,41 @@ open class ImeKeyboardView( Panel.FUZZY_SETTINGS -> settingsPanelController.renderFuzzySettings() else -> closePanelToKeyboard() } + syncRecentSymbolStrip(panel) applyTheme() onViewHierarchyRebuilt() focusPanelEntryPoint() } + /** + * The symbol board lists the symbols the user has typed in its toolbar + * (`input_toolbar_quickinput.xml:4-18`); every other surface gets the plain + * toolbar back. + */ + private fun syncRecentSymbolStrip(panel: Panel) { + if (panel != Panel.SYMBOLS) { + topZone.hideRecentSymbols() + return + } + if (recentSymbolStripDismissed) { + topZone.hideRecentSymbols() + return + } + // The tap commits through the same path the board's own cells use, and + // that path records the symbol, so the strip re-reads the list after it. + topZone.showRecentSymbols( + symbols = SymbolRecentRepository.load(context), + onPick = { symbol -> + listener.onSymbolSelected(symbol) + if (panel == Panel.SYMBOLS) syncRecentSymbolStrip(Panel.SYMBOLS) + }, + onClose = { + recentSymbolStripDismissed = true + topZone.hideRecentSymbols() + }, + ) + } + /** Keep hardware-keyboard focus inside the newly visible panel. */ protected fun focusPanelEntryPoint() { expandedPanel.post { 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 df4830e9..b23ed797 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -167,6 +167,16 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } + // A symbol-history cell carries the candidate cell's own pressed + // fill, rounded to the 6 units the reference gives it + // (`cls_history_symbollist_button`, style.xml:1061-1070). + "recent-symbol-cell" -> { + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + toPx(RecentSymbolStripPolicy.CELL_RADIUS_DP), + ) + } // Rows sit inside a card that clips to its corners, so the pressed // fill is square and the card supplies the rounding. "setting-row" -> if (view.isClickable) { @@ -417,6 +427,10 @@ internal class ImeThemeApplier( tag == "candidate-word" -> view.setTextColor(t.candidateText) + // 历史符号列表按钮 takes the candidate text colour (style.xml:1063). + tag == "recent-symbol-label" -> + view.setTextColor(t.candidateText) + tag == "candidate-filter-chip" -> { // 全部 / 单字: the chosen one is the raised surface with plain // text, the other a dimmed label on the group's own background @@ -724,7 +738,8 @@ internal class ImeThemeApplier( "row-hairline" -> view.setBackgroundColor(ImeSurfacePolicy.hairline(t)) // `cls_candidate_split` is filled with @color/canditem_split // (style.xml:504-506), the colour the candidate row's hairlines use. - "candidate-strip-split" -> view.setBackgroundColor(ImeSurfacePolicy.divider(t)) + "candidate-strip-split", "recent-symbol-split" -> + view.setBackgroundColor(ImeSurfacePolicy.divider(t)) } } 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 7badea8e..8aba0c22 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -114,10 +114,24 @@ internal class ImeTopZone( private lateinit var autofillScroll: HorizontalScrollView private val autofillRow = LinearLayout(context) - /** What the toolbar row shows; icons, autofill chips and associations are exclusive. */ - private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION } + /** + * The symbol history the symbol board shows in the toolbar + * (`input_toolbar_quickinput.xml:4-18`, see [RecentSymbolStripPolicy]). + */ + private val recentSymbolStrip = LinearLayout(context) + private val recentSymbolRow = LinearLayout(context) + private lateinit var recentSymbolClose: ImageView + private var recentSymbolsShown = false + + /** + * What the toolbar row shows; icons, autofill chips, associations and the + * symbol history are exclusive. The history takes the row while the symbol + * board is open, as the reference's own symbol toolbar does. + */ + private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION, RECENT_SYMBOLS } private fun idleToolbarMode(): ToolbarMode = when { + recentSymbolsShown -> ToolbarMode.RECENT_SYMBOLS associationsShown -> ToolbarMode.ASSOCIATION autofillShown -> ToolbarMode.AUTOFILL else -> ToolbarMode.NORMAL @@ -258,6 +272,7 @@ internal class ImeTopZone( // of the strip empty: `btn_hide` stays its own button on the right // (`input_toolbar_idle.xml:41-43`, `cls_toolbar_idle_button` width 40, // `style.xml:237-248`). + buildRecentSymbolStrip() keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard) toolbarRow.addView( keyboardHide, @@ -531,6 +546,157 @@ internal class ImeTopZone( refreshToolbar() } + /** + * Lays out the symbol history as the reference's symbol toolbar does: a + * 4-unit lead inset, the scrolling list of the symbols the user has typed, + * the same hairline split its candidate strip uses, a 3-unit gap, the close + * key and a 4-unit tail (`input_toolbar_quickinput.xml:4-18`). The close key + * keeps our 48dp touch target where the reference's box is 40 units. + */ + private fun buildRecentSymbolStrip() { + recentSymbolStrip.apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + tag = "recent-symbol-strip" + visibility = View.GONE + } + recentSymbolStrip.addView( + View(context).apply { tag = "recent-symbol-lead" }, + LinearLayout.LayoutParams( + toPx(RecentSymbolStripPolicy.LEAD_UNITS), + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + recentSymbolRow.apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + tag = "recent-symbol-row" + } + recentSymbolStrip.addView( + HorizontalScrollView(context).apply { + tag = "recent-symbol-scroll" + isHorizontalScrollBarEnabled = false + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + addView( + recentSymbolRow, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ), + ) + }, + LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), + ) + recentSymbolStrip.addView( + View(context).apply { tag = "recent-symbol-split" }, + LinearLayout.LayoutParams(toPx(1).coerceAtLeast(1), LinearLayout.LayoutParams.MATCH_PARENT), + ) + recentSymbolStrip.addView( + View(context).apply { tag = "recent-symbol-gap" }, + LinearLayout.LayoutParams( + toPx(RecentSymbolStripPolicy.GAP_UNITS), + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + recentSymbolClose = ImageView(context).apply { + tag = "recent-symbol-close" + setImageResource(R.drawable.ic_close) + scaleType = ImageView.ScaleType.CENTER_INSIDE + contentDescription = "关闭最近符号" + setPadding(toPx(7), 0, toPx(7), 0) + isClickable = true + isFocusable = true + } + recentSymbolStrip.addView(recentSymbolClose, touchTargetParams()) + recentSymbolStrip.addView( + View(context).apply { tag = "recent-symbol-tail" }, + LinearLayout.LayoutParams( + toPx(RecentSymbolStripPolicy.TAIL_UNITS), + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + toolbarRow.addView(recentSymbolStrip, LinearLayout.LayoutParams(0, toPx(48))) + } + + /** + * Shows [symbols] — the symbols the user has typed, most recent first — in + * the toolbar row, or the normal toolbar when there are none. [onClose] + * ends the strip's own visit: the board stays open. + */ + fun showRecentSymbols( + symbols: List, + onPick: (String) -> Unit, + onClose: () -> Unit, + ) { + recentSymbolRow.removeAllViews() + symbols.forEachIndexed { index, symbol -> + recentSymbolRow.addView( + recentSymbolCell(symbol, onPick), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ).apply { + if (index < symbols.lastIndex) { + marginEnd = toPx(RecentSymbolStripPolicy.CELL_GAP_UNITS) + } + }, + ) + } + recentSymbolClose.setOnClickListener { + onFeedback() + onClose() + } + recentSymbolsShown = symbols.isNotEmpty() + toolbarMode = idleToolbarMode() + refreshToolbar() + } + + /** Takes the symbol history out of the toolbar row (panel closed, or its close key tapped). */ + fun hideRecentSymbols() { + if (!recentSymbolsShown) return + recentSymbolsShown = false + toolbarMode = idleToolbarMode() + refreshToolbar() + } + + /** One history cell: the reference's 36-unit box over candidate-sized text. */ + private fun recentSymbolCell(symbol: String, onPick: (String) -> Unit): View = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + tag = "recent-symbol-cell" + isClickable = true + isFocusable = true + minimumWidth = toPx(RecentSymbolStripPolicy.CELL_UNITS) + contentDescription = "最近符号:$symbol" + addView( + TextView(context).apply { + text = symbol + textSize = ImeTypographyTokens.CANDIDATE_SP + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + includeFontPadding = false + tag = "recent-symbol-label" + isClickable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + setPadding( + toPx(RecentSymbolStripPolicy.CELL_PAD_DP), + 0, + toPx(RecentSymbolStripPolicy.CELL_PAD_DP), + 0, + ) + }, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + setOnClickListener { + onFeedback() + onPick(symbol) + } + } + private var longPressDrag: LongPressDragTarget? = null /** 停靠键盘的高度调节只允许在顶栏范围起手,避免抢候选/输入区的长按。 */ private var longPressToolbarOnly = false @@ -679,6 +845,7 @@ internal class ImeTopZone( val normal = toolbarMode == ToolbarMode.NORMAL val association = toolbarMode == ToolbarMode.ASSOCIATION val autofill = toolbarMode == ToolbarMode.AUTOFILL + val recent = toolbarMode == ToolbarMode.RECENT_SYMBOLS val shown = if (compact) compactToolbarIcons else toolbarIcons (toolbarIcons + compactToolbarIcons).distinct().forEach { it.visibility = if (normal && it in shown) View.VISIBLE else View.GONE @@ -687,15 +854,22 @@ internal class ImeTopZone( 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 + recentSymbolStrip.visibility = if (recent) View.VISIBLE else View.GONE keyboardHide.visibility = View.VISIBLE // The icons keep the reference's own width and their own row takes the - // slack; a suggestion strip or the autofill chips take it instead. + // slack; a suggestion strip, the autofill chips or the symbol history + // take it instead. (toolbarIconScroll.layoutParams as? LinearLayout.LayoutParams)?.let { params -> val flexible = normal params.width = 0 params.weight = if (flexible) 1f else 0f toolbarIconScroll.layoutParams = params } + (recentSymbolStrip.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + params.width = 0 + params.weight = if (recent) 1f else 0f + recentSymbolStrip.layoutParams = params + } } fun setContentInset(contentInsetPx: Int) { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/RecentSymbolStripPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/RecentSymbolStripPolicy.kt new file mode 100644 index 00000000..9a889d48 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/RecentSymbolStripPolicy.kt @@ -0,0 +1,33 @@ +package llc.slacker.openime.keyboard + +/** + * The toolbar strip that lists the symbols the user has typed, as the reference + * draws it while its symbol board is open (`input_toolbar_quickinput.xml:4-18`, + * `toolbar_symbol_recent`). + * + * The row reads `[4] 历史符号(308) [1] [3] 关闭(40) [4]`, the same shape the + * reference gives its candidate strip: a 4-unit lead inset, the scrolling list, + * a hairline split, a 3-unit gap, the close key and a 4-unit tail. One cell is + * 36 units wide with a radius-6 press face, candidate text in the candidate text + * colour and 2.7% padding per side (`cls_history_symbollist_button`, + * `style.xml:1061-1070`), and the list keeps 4 units between cells + * (`cls_history_symbollist` childpadding, `style.xml:1055-1058`). + */ +internal object RecentSymbolStripPolicy { + const val LEAD_UNITS = 4 + const val GAP_UNITS = 3 + const val TAIL_UNITS = 4 + + /** `cls_btn_more_cand_button_adaptive`, the class the close key borrows (`style.xml:468`). */ + const val CLOSE_UNITS = 40 + + /** `width1x="36"`, `bkround="6"` on the history cell. */ + const val CELL_UNITS = 36 + const val CELL_RADIUS_DP = 6 + + /** `childpadding="4"` on the history list. */ + const val CELL_GAP_UNITS = 4 + + /** The reference's own text padding per side on a cell (`textpadding="0.027,0,0.027,0"`). */ + const val CELL_PAD_DP = 3 +} diff --git a/app/src/test/java/llc/slacker/openime/RecentSymbolStripPolicyTest.kt b/app/src/test/java/llc/slacker/openime/RecentSymbolStripPolicyTest.kt new file mode 100644 index 00000000..bf96471a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/RecentSymbolStripPolicyTest.kt @@ -0,0 +1,31 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.RecentSymbolStripPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecentSymbolStripPolicyTest { + /** + * The toolbar strip reads `[4] 历史符号(308) [1] [3] 关闭(40) [4]`, the same + * shape the reference gives its candidate strip (`input_toolbar_quickinput.xml:6-15`). + */ + @Test + fun theStripKeepsTheReferencesOwnRowShape() { + assertEquals(4, RecentSymbolStripPolicy.LEAD_UNITS) + assertEquals(3, RecentSymbolStripPolicy.GAP_UNITS) + assertEquals(40, RecentSymbolStripPolicy.CLOSE_UNITS) + assertEquals(4, RecentSymbolStripPolicy.TAIL_UNITS) + } + + /** One cell is the reference's 36-unit box with its radius-6 press face. */ + @Test + fun eachCellCarriesTheReferencesOwnBox() { + assertEquals(36, RecentSymbolStripPolicy.CELL_UNITS) + assertEquals(6, RecentSymbolStripPolicy.CELL_RADIUS_DP) + assertEquals(4, RecentSymbolStripPolicy.CELL_GAP_UNITS) + assertEquals(3, RecentSymbolStripPolicy.CELL_PAD_DP) + // The close key is the widest thing in the row after the list itself. + assertTrue(RecentSymbolStripPolicy.CLOSE_UNITS > RecentSymbolStripPolicy.CELL_UNITS) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d3c2b53c..79d370e8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -205,3 +205,7 @@ Add new entries at the end. - **The symbol page carries its own 123 and 退格 keys.** The two keys close the reference's symbol row (`input_kbd_symbol.xml:31-41`): the 123 key and the backspace key sit at that row's two ends, weighted 15 each against the seven cells' 70, so the page can reach the digits and delete without being left first. Our cells are five to a row, so the two keys take the two ends of the tab strip's row — the row the reference gives to the page's own return key (`input_kbd_symbol.xml:44-47`). *Reason:* the page could only type symbols: deleting a character or reaching the digits meant closing it first. Both keys keep our 48dp touch target and use actions the page family already has (switching to the digits layout leaves the panel, exactly as choosing a keyboard from the 切换键盘 page does). + +- **The symbol page lists the symbols the user has typed in its toolbar.** + The reference's symbol board fills its toolbar row with the history of typed symbols (`input_toolbar_quickinput.xml:4-18`, the `toolbar_symbol_recent` row the skin keeps hidden until the board needs it): a 4-unit lead inset, the scrolling history list at weight 308, the same hairline split its candidate strip uses, a 3-unit gap, the 40-unit close key and a 4-unit tail. One cell is 36 units wide with a radius-6 press face and candidate text in the candidate text colour (`cls_history_symbollist_button`, `style.xml:1061-1070`, commented 符号键盘 历史符号列表项), the list keeps 4 units between its cells (`cls_history_symbollist` childpadding, `style.xml:1055-1058`), and the close key borrows the candidate expand key's own 40-unit box (`cls_btn_more_cand_button_adaptive`, `style.xml:468`, over the transparent pill `cls_bk_toolbar_button`, `style.xml:231-235`). + *Reason:* our board had no history: the symbols a user had just typed were gone when the page closed, so a phone number's `+`, `-` or `#` had to be found again on every visit. The list keeps the reference's row shape, and our own rules cover what the skin leaves open: the close key keeps the 48dp touch target, the cells keep the board's own five-a-row floor where the reference's box is 36 units, a tap commits through the same path the board's cells use (so the list re-reads itself), and the close key ends the strip for that visit while the board stays open — the next visit shows it again. The 收起 key stays at the row's right end, where our toolbar always keeps it. The strip's own empty case hides it: a user who has typed no symbols sees the plain toolbar, not an empty row. From 54138e448b2e81150c31ec29bda17fc513ee2ec3 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:58:18 +0800 Subject: [PATCH 22/94] Draw the launcher pages on the reference's tile grid The tools page and the keyboard switcher are one surface in the reference: a container 88.9% of the page wide with its own paddings, tiles filling their cells in a 4-by-2 grid capped at 192 units (style.xml:616-619), each tile a radius-14 card filled with setting_button_bk, its pressed colour, or the theme colour when it is the chosen one, and its name dimmed to keybutton_text_3 (style.xml:626-647, values/colors.xml:15,87-93). Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../AuditInteractionInstrumentedTest.kt | 9 +- .../openime/LauncherTileInstrumentedTest.kt | 161 ++++++++++++ .../openime/keyboard/ImeThemeApplier.kt | 45 +++- .../slacker/openime/panel/ImePanelRenderer.kt | 238 ++++++++++-------- .../slacker/openime/panel/TileGridPolicy.kt | 46 ++++ .../slacker/openime/theme/ImeDesignTokens.kt | 1 - .../llc/slacker/openime/TileGridPolicyTest.kt | 40 +++ docs/DECISIONS.md | 4 + 9 files changed, 422 insertions(+), 123 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/panel/TileGridPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/TileGridPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a99498c4..c0f4e171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 工具页与切换键盘页改用参考皮肤的方格磁贴:整格就是一块圆角卡片(圆角 14,按下变深,当前键盘那一格填主题色、图标与文字变白),四列两行、同页磁贴等高,磁贴区域占页宽 88.9% 并留参考皮肤自己的四周留白;原先是 56dp 的图标小方块带底色、名称在方块外面。 - 符号页的工具栏改为显示最近输入过的符号(参考皮肤在符号页工具栏里放的就是这条「历史符号列表」,可横向滑动,最右是关闭键):点一下即可再次上屏,关闭键收起这条列表(本次进入符号页不再出现,下次进入符号页会重新显示)。没输入过符号时不显示这条列表。 - 符号页底部增加「123」与「退格」两个键(参考皮肤把这两个键放在符号那一行的两端):在符号页里可以直接退格,或一键切到数字键盘,不必先退出符号页。 - 符号页的分类标签移到页面底部:一条可横向滑动的标签栏(参考皮肤把分类放在符号页最后一行、返回键右侧),符号格重新占满页宽;每行仍是 5 格以保证 48dp 的触控尺寸(参考皮肤每行 10 格)。 diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index b67c3844..7bd3542b 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -653,10 +653,11 @@ class AuditInteractionInstrumentedTest { keyboard.showPanel(Panel.TOOLS) val card = keyboard.findViewWithTag("tool:表情") assertTrue("Tool card must be keyboard-focusable", card.isFocusable) - // The column is the target; its icon tile mirrors the column's state. - val tile = (card as ViewGroup).getChildAt(0) - assertTrue("Tool tile must expose pressed and focus feedback", tile.background is StateListDrawable) - assertTrue("Tool tile must follow the card's pressed state", tile.isDuplicateParentStateEnabled) + // The whole cell is the reference's own tile: it carries the face and + // the press, and its glyph mirrors the tile's state. + assertTrue("Tool tile must expose pressed and focus feedback", card.background is StateListDrawable) + val icon = (card as ViewGroup).getChildAt(0) + assertTrue("Tool glyph must follow the tile's pressed state", icon.isDuplicateParentStateEnabled) assertEquals("表情", card.contentDescription) val label = card.getChildAt(1) assertTrue( diff --git a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt new file mode 100644 index 00000000..0e0d5d0e --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt @@ -0,0 +1,161 @@ +package llc.slacker.openime + +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.view.View +import android.view.ViewGroup +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.keyboard.ImeKeyboardView +import llc.slacker.openime.panel.TileGridPolicy +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme +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 launcher pages — tools and the keyboard switcher — draw the reference's + * own tile grid (`input_settings.xml:13-45`, `input_switch_keyboard.xml:13-29`). + */ +@RunWith(AndroidJUnit4::class) +class LauncherTileInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { + // The tile colours are the palette's, so pin the palette. + keyboard.applyPersistedSettings( + newTheme = ImeTheme.IOS, + newAppearance = ImeAppearance.LIGHT, + sound = false, + haptic = true, + popup = true, + fuzzy = false, + ) + true + } + harness.awaitMain { test(keyboard); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + /** The default fill of a stateful rounded face. */ + private fun faceColor(view: View): Int? = + ((view.background as StateListDrawable).current as GradientDrawable).color?.defaultColor + + private fun faceRadius(view: View): Float = + ((view.background as StateListDrawable).current as GradientDrawable).cornerRadius + + @Test + fun theToolsPageIsTheReferencesFourByTwoGrid() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.TOOLS) + val grid = keyboard.findViewWithTag("tile-grid") + assertNotNull("The tools page must lay its tiles out as a grid", grid) + val rows = (grid as ViewGroup) + assertEquals("The reference's grid is two rows deep", TileGridPolicy.ROWS, rows.childCount) + (0 until rows.childCount).forEach { index -> + assertEquals( + "Every row of the grid is ${TileGridPolicy.COLUMNS} columns", + TileGridPolicy.COLUMNS, + (rows.getChildAt(index) as ViewGroup).childCount, + ) + } + // Every tile of a page is the same size, as `parent_average` makes it. + val sizes = (0 until rows.childCount).flatMap { row -> + val cells = rows.getChildAt(row) as ViewGroup + (0 until cells.childCount).map { column -> + val tile = (cells.getChildAt(column) as ViewGroup).getChildAt(0) + "${tile.width}x${tile.height}" + } + }.distinct() + assertEquals("One page's tiles share one size", 1, sizes.size) + } + } + + @Test + fun everyTileCarriesTheReferencesRoundedFace() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.TOOLS) + val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) + val tile = keyboard.findViewWithTag("tool:表情")!! + assertTrue("A tile's face is a stateful card", tile.background is StateListDrawable) + assertEquals( + "The tile takes the card colour the reference fills it with", + tokens.toolCardBackground, + faceColor(tile), + ) + assertEquals( + "`bkround=\"14\"` on cls_settings_button", + keyboard.resources.displayMetrics.density * TileGridPolicy.TILE_RADIUS_DP, + faceRadius(tile), + 1f, + ) + } + } + + @Test + fun theChosenKeyboardWearsTheAccentTile() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.KEYBOARD_SELECT) + val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) + val selected = keyboard.findViewWithTag("keyboard-choice-selected") + assertNotNull("The open keyboard's tile must be drawn selected", selected) + assertEquals( + "`selectedbkcolor=\"@color/theme_color\"` on cls_settings_button", + tokens.primary, + faceColor(selected!!), + ) + val others = keyboard.findViewWithTag("keyboard-choice") + assertNotNull("Other keyboards keep the plain tile", others) + assertEquals(tokens.toolCardBackground, faceColor(others!!)) + } + } + + @Test + fun theGridCarriesEveryKeyboardAndTheHandwritingEntry() { + withKeyboard { keyboard -> + keyboard.showPanel(Panel.KEYBOARD_SELECT) + val grid = keyboard.findViewWithTag("tile-grid") as ViewGroup + val labels = (0 until grid.childCount).flatMap { row -> + val cells = grid.getChildAt(row) as ViewGroup + (0 until cells.childCount).map { column -> + val tile = (cells.getChildAt(column) as ViewGroup).getChildAt(0) + (tile as ViewGroup).getChildAt(1)?.let { (it as TextView).text.toString() } + } + } + assertTrue("The switcher keeps the pinyin keyboards", labels.contains("拼音 26 键")) + assertTrue("The switcher keeps the handwriting entry", labels.contains("手写输入")) + keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) + keyboard.showPanel(Panel.KEYBOARD_SELECT) + assertNotNull( + "Switching keyboards moves the selected tile", + keyboard.findViewWithTag("keyboard-choice-selected"), + ) + } + } +} 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 b23ed797..9949d4b7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -14,6 +14,7 @@ import android.widget.SeekBar import android.widget.TextView import llc.slacker.openime.R import llc.slacker.openime.handwriting.HandwritingPadView +import llc.slacker.openime.panel.TileGridPolicy import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSurfacePolicy @@ -200,7 +201,19 @@ internal class ImeThemeApplier( } "nine-symbol-scroll-content", "digits-symbol-scroll-content" -> view.background = null // Tool-style tiles: the icon tile carries the colour, not the card. - "keyboard-choice", "keyboard-choice-selected" -> view.background = null + // A launcher tile is the reference's own rounded card: the whole + // cell takes `setting_button_bk` with `bkround="14"`, the accent + // when it is the chosen one (`cls_settings_button`, style.xml:626-647). + "keyboard-choice" -> view.background = statefulRounded( + t.toolCardBackground, + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + toPx(TileGridPolicy.TILE_RADIUS_DP), + ) + "keyboard-choice-selected" -> view.background = statefulRounded( + t.primary, + ImeSurfacePolicy.primaryPressed(t), + toPx(TileGridPolicy.TILE_RADIUS_DP), + ) "segmented-track" -> view.background = ImeDrawableFactory.rounded( if (hasAncestorTag(view, "settings-panel")) ImeSurfacePolicy.controlTrack(t) else t.functionKeyBackground, toPx(12), @@ -233,8 +246,15 @@ internal class ImeThemeApplier( "compose-zone" -> view.setBackgroundColor(t.candidateBackground) "candidate-field" -> view.background = null else -> { - // A tool is a column (tile + name); its tile paints the press. - if ((view.tag as? String)?.startsWith("tool:") == true) view.background = null + // A tool's own tile is the card: the whole cell takes the + // reference's radius-14 face, its name drawn inside it. + if ((view.tag as? String)?.startsWith("tool:") == true) { + view.background = statefulRounded( + t.toolCardBackground, + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + toPx(TileGridPolicy.TILE_RADIUS_DP), + ) + } } } @@ -350,17 +370,14 @@ internal class ImeThemeApplier( ) } view.tag == "tool-icon-selected" -> { - val selected = ImeSurfacePolicy.selectedSurface(t) - view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.selectedText(t)) - view.background = statefulRounded(selected, ImeSurfacePolicy.pressedSurface(selected, t), toPx(16)) + // The chosen tile is the accent, so its glyph reads on it + // (`setting_button_icon_selected` over a `theme_color` face). + view.imageTintList = ColorStateList.valueOf(ImeDrawableFactory.contrastText(t.primary)) + view.background = null } view.tag == "tool-icon" -> { view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.iconTint(t)) - view.background = statefulRounded( - t.toolCardBackground, - ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), - toPx(16), - ) + view.background = null } view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.tag == "key-panel-back" -> { @@ -569,7 +586,11 @@ internal class ImeThemeApplier( view.typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) } tag == "segment-option-tall" -> { view.setTextColor(t.keySecondaryText); view.background = null } - tag == "tool-label-selected" -> view.setTextColor(ImeSurfacePolicy.selectedText(t)) + // The tile's name takes `keybutton_text_3` (50% black, or 50% white + // in the dark skin, values/colors.xml:15) and white on the chosen + // tile (`setting_button_selectedtext`). + tag == "tool-label" -> view.setTextColor(t.keySecondaryText) + tag == "tool-label-selected" -> view.setTextColor(ImeDrawableFactory.contrastText(t.primary)) tag == "fuzzy-rules" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16)) tag == "textedit-spacer" -> view.background = null tag == "textedit-center" -> { diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 0aa00bf8..e28ed0f5 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -1,6 +1,7 @@ package llc.slacker.openime.panel import android.content.Context +import android.text.TextUtils import android.content.Intent import android.graphics.Canvas import android.graphics.Color @@ -100,7 +101,6 @@ internal class ImePanelRenderer( addHeader("切换键盘") val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(12), toPx(4), 0) tag = "keyboard-select-panel" } val modes = listOf( @@ -116,60 +116,42 @@ internal class ImePanelRenderer( (mode == KeyboardMode.WUBI && !rimeAssetPresent(WUBI_TABLE_ASSET)) || (mode == KeyboardMode.DOUBLE_PINYIN && !rimeAssetPresent(DOUBLE_PINYIN_SCHEMA_ASSET)) } - modes.chunked(TOOL_COLUMNS).forEach { chunk -> - val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - chunk.forEach { (mode, label, iconRes) -> - row.addView(keyboardChoice(mode, label, iconRes), toolCardParams()) - } - repeat(TOOL_COLUMNS - chunk.size) { row.addView(View(context), toolCardParams()) } - body.addView( - row, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), - ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, - ) - } + val tiles = modes.map { (mode, label, iconRes) -> keyboardChoice(mode, label, iconRes) }.toMutableList() // Handwriting is an input surface, not a keyboard mode. Give it the // same entry point as the keyboards so it is found in the same place. if (StrokeMatchHandwritingProvider.assetPresent(context)) { - val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - row.addView( - toolCard(R.drawable.ic_pref_handwriting, "手写输入") { - onShowPanel(Panel.HANDWRITING) - }, - toolCardParams(), - ) - repeat(TOOL_COLUMNS - 1) { row.addView(View(context), toolCardParams()) } - body.addView( - row, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), - ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, + tiles += TileEntry( + label = "手写输入", + iconRes = R.drawable.ic_pref_handwriting, + onTap = { onShowPanel(Panel.HANDWRITING) }, ) } + body.addView( + tileGrid(tiles), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + val scroll = panelVerticalScroll(body, "keyboard-select-scroll") + rememberPanelVerticalScroll(scroll, "keyboard-select") expandedPanel.addView( - body, + scroll, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - imeHeightPx() - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + 0, + 1f, ), ) } - private fun toolCardParams() = - LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), 1f).apply { marginEnd = toPx(8) } - fun renderTools() { addHeader("工具") val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(12), toPx(4), 0) tag = "tools-panel" } - val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - val cards = listOf( + val entries = listOf( ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_pref_clipboard), ToolEntry("表情", Panel.EMOJI, R.drawable.ic_pref_emoji), ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_pref_symbols), @@ -187,42 +169,16 @@ internal class ImePanelRenderer( ToolEntry("数据管理", iconRes = R.drawable.ic_pref_data, action = onOpenDataManagement), ).filter { it.enabled } - cards.chunked(TOOL_COLUMNS).forEach { chunk -> - val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - chunk.forEach { entry -> - val action = entry.action ?: { - entry.target?.let(onShowPanel) - Unit - } - row.addView( - toolCard(entry.iconRes ?: R.drawable.ic_settings, entry.label, action), - LinearLayout.LayoutParams( - 0, - toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), - 1f, - ).apply { marginEnd = toPx(8) }, - ) - } - repeat(TOOL_COLUMNS - chunk.size) { - row.addView( - View(context), - LinearLayout.LayoutParams( - 0, - toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), - 1f, - ).apply { marginEnd = toPx(8) }, - ) - } - grid.addView( - row, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), - ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, - ) - } body.addView( - grid, + tileGrid( + entries.map { entry -> + TileEntry( + label = entry.label, + iconRes = entry.iconRes ?: R.drawable.ic_settings, + onTap = entry.action ?: { entry.target?.let(onShowPanel); Unit }, + ) + }, + ), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, @@ -733,71 +689,142 @@ internal class ImePanelRenderer( } } + /** One tile of a launcher grid: its glyph, its name, and what it opens. */ + private class TileEntry( + val label: String, + val iconRes: Int, + val selected: Boolean = false, + /** Overrides the `tool:$label` tag the tiles of the tools page carry. */ + val tag: String? = null, + val contentDescription: String? = null, + val onTap: () -> Unit, + ) + /** - * One tool: a 56dp icon tile with its name underneath. The whole column is - * the touch target; the tile shows the press. + * The reference's own launcher grid (`input_settings.xml:13-45`, + * `input_switch_keyboard.xml:13-29`): a 4-column, 2-row container 88.9% of + * the page wide with its own paddings, each tile filling its cell with the + * radius-14 face. See [TileGridPolicy] for every quoted value. */ - private fun toolCard( - iconRes: Int, - label: String, - onTap: () -> Unit, - ): LinearLayout { - val card = LinearLayout(context).apply { + private fun tileGrid(entries: List): View { + val pageWidth = expandedPanel.width.takeIf { it > 0 } ?: context.resources.displayMetrics.widthPixels + val containerWidth = (pageWidth * TileGridPolicy.WIDTH_FRACTION).toInt() + val padH = (pageWidth * TileGridPolicy.PAD_H_FRACTION).toInt() + val padTop = (pageWidth * TileGridPolicy.PAD_TOP_FRACTION).toInt() + val padBottom = (pageWidth * TileGridPolicy.PAD_BOTTOM_FRACTION).toInt() + val cellWidth = ((containerWidth - 2 * padH) / TileGridPolicy.COLUMNS).coerceAtLeast(1) + val hPad = (cellWidth * TileGridPolicy.CHILD_PAD_FRACTION).toInt() + // The grid never grows past the reference's own cap, and never past the + // room the panel has left under its header. + val available = (panelBodyHeightPx() - padTop - padBottom).coerceAtLeast(1) + val gridHeight = minOf(toPx(TileGridPolicy.MAX_HEIGHT_UNITS), available) + val rows = TileGridPolicy.rows(entries.size) + val fittedRow = gridHeight / rows + val vPad = (fittedRow * TileGridPolicy.CHILD_V_PAD_FRACTION).toInt() + // A tile never shrinks under our 48dp touch target; a row that would is + // grown back and the panel's own scroll takes the difference. + val tileHeight = (fittedRow - 2 * vPad).coerceAtLeast(toPx(ImeGeometryTokens.TOUCH_TARGET_DP)) + val rowHeight = (tileHeight + 2 * vPad).coerceAtLeast(fittedRow) + + val container = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP + tag = "tile-grid" + setPadding(padH, padTop, padH, padBottom) + } + entries.chunked(TileGridPolicy.COLUMNS).forEach { chunk -> + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + chunk.forEach { entry -> + val cell = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + cell.addView( + toolTile(entry, cellWidth - 2 * hPad, tileHeight), + LinearLayout.LayoutParams( + cellWidth - 2 * hPad, + tileHeight, + ).apply { + leftMargin = hPad + rightMargin = hPad + topMargin = vPad + bottomMargin = vPad + }, + ) + row.addView( + cell, + LinearLayout.LayoutParams(cellWidth, rowHeight).apply { gravity = Gravity.CENTER }, + ) + } + container.addView( + row, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, rowHeight), + ) + } + return container + } + + /** + * One tile: the glyph over its name, the whole cell the touch target and + * the press (`cls_settings_button`; the label sits under the icon as the + * skin's `align="top"` draws it). + */ + private fun toolTile(entry: TileEntry, widthPx: Int, heightPx: Int): LinearLayout { + val tile = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER minimumWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - tag = "tool:$label" - contentDescription = label + tag = entry.tag ?: "tool:${entry.label}" + contentDescription = entry.contentDescription ?: entry.label + isSelected = entry.selected isClickable = true isFocusable = true setOnClickListener { onFeedback() - onTap() + entry.onTap() } } - card.addView( + val iconSide = (heightPx * TileGridPolicy.ICON_FRACTION).toInt().coerceAtLeast(1) + tile.addView( ImageView(context).apply { - setImageResource(iconRes) + setImageResource(entry.iconRes) scaleType = ImageView.ScaleType.CENTER_INSIDE contentDescription = null isDuplicateParentStateEnabled = true - tag = "tool-icon" + tag = if (entry.selected) "tool-icon-selected" else "tool-icon" }, - LinearLayout.LayoutParams(toPx(56), toPx(56)).apply { - bottomMargin = toPx(ImeSpacingTokens.SM_DP) + LinearLayout.LayoutParams(iconSide, iconSide).apply { + bottomMargin = toPx(ImeSpacingTokens.XS_DP) }, ) - card.addView( + tile.addView( TextView(context).apply { - text = label - tag = "tool-label" + text = entry.label + tag = if (entry.selected) "tool-label-selected" else "tool-label" typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) textSize = ImeTypographyTokens.SMALL_SP + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END gravity = Gravity.CENTER includeFontPadding = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, ), ) - return card + return tile } - /** A 工具-style tile; the current keyboard's tile is drawn selected. */ - private fun keyboardChoice(mode: KeyboardMode, label: String, iconRes: Int): View { + /** A launcher tile for a keyboard; the current keyboard's tile is drawn selected. */ + private fun keyboardChoice(mode: KeyboardMode, label: String, iconRes: Int): TileEntry { val selected = currentMode() == mode - return toolCard(iconRes, label) { onModeSelected(mode) }.apply { - tag = if (selected) "keyboard-choice-selected" else "keyboard-choice" - contentDescription = "$label,${if (selected) "已选中" else "未选中"}" - isSelected = selected - if (selected) { - findViewWithTag("tool-icon")?.tag = "tool-icon-selected" - findViewWithTag("tool-label")?.tag = "tool-label-selected" - } - } + return TileEntry( + label = label, + iconRes = iconRes, + selected = selected, + tag = if (selected) "keyboard-choice-selected" else "keyboard-choice", + contentDescription = "$label,${if (selected) "已选中" else "未选中"}", + onTap = { onModeSelected(mode) }, + ) } private fun symbolItems(category: String): List = when (category) { @@ -856,7 +883,6 @@ internal class ImePanelRenderer( ) private companion object { - const val TOOL_COLUMNS = 4 /** Longer than this, a symbol is text (kaomoji, 【】 pairs...) and needs a wide cell. */ const val WIDE_SYMBOL_CODE_POINTS = 3 /** Compiled wubi table; absent from builds without the local sources. */ diff --git a/app/src/main/java/llc/slacker/openime/panel/TileGridPolicy.kt b/app/src/main/java/llc/slacker/openime/panel/TileGridPolicy.kt new file mode 100644 index 00000000..63b8e9a1 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/panel/TileGridPolicy.kt @@ -0,0 +1,46 @@ +package llc.slacker.openime.panel + +/** + * The launcher grid the reference draws on both its settings page and its + * keyboard switcher (`input_settings.xml:13-45`, `input_switch_keyboard.xml:13-29`). + * + * The container takes 88.9% of the page with its own paddings (5.6% at each + * side, 7.2% on top and 10.1% at the foot) and lays its tiles out 4 columns by + * 2 rows, its height capped at 192 units (`cls_settings_bk_adaptive`, + * `style.xml:616-619`). Each tile fills its cell with a radius-14 face filled + * with `setting_button_bk`, `setting_button_pushedbk` while pressed and the + * theme colour when it is the chosen one, its name in `keybutton_text_3` and + * white on the chosen tile (`cls_settings_button`, `style.xml:626-647`; + * `values/colors.xml:15,87-93`). + */ +internal object TileGridPolicy { + const val COLUMNS = 4 + const val ROWS = 2 + const val PAGE_SIZE = COLUMNS * ROWS + + /** `width_weight="0.88888"` and the container's own `padding_decimal`. */ + const val WIDTH_FRACTION = 0.88888f + const val PAD_H_FRACTION = 0.05555f + const val PAD_TOP_FRACTION = 0.0721f + const val PAD_BOTTOM_FRACTION = 0.10096f + + /** `maxheight="192"`, the cap on the two rows together. */ + const val MAX_HEIGHT_UNITS = 192 + + /** `childpadding_decimal="0.05"`, `childvpadding_decimal="0.11111"`. */ + const val CHILD_PAD_FRACTION = 0.05f + const val CHILD_V_PAD_FRACTION = 0.11111f + + /** `bkround="14"` on `cls_settings_button`. */ + const val TILE_RADIUS_DP = 14 + + /** Our own share: the name sits under a glyph half the tile tall. */ + const val ICON_FRACTION = 0.52f + + /** The rows a list of [entries] needs, never fewer than the reference's two. */ + fun rows(entries: Int): Int { + if (entries <= 0) return ROWS + val needed = (entries + COLUMNS - 1) / COLUMNS + return maxOf(ROWS, needed) + } +} 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 9eb41652..08223e3f 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -47,7 +47,6 @@ internal object ImeGeometryTokens { const val FIELD_HEIGHT_DP = 56 const val PRIMARY_ROW_HEIGHT_DP = 52 const val SETTING_ROW_HEIGHT_DP = 56 - const val TOOL_CARD_HEIGHT_DP = 80 const val VOICE_CONTROL_HEIGHT_DP = TOUCH_TARGET_DP // iOS switch geometry: a 51x31 track and a 27 knob, so the knob sits 2 // from every edge, on and off alike. diff --git a/app/src/test/java/llc/slacker/openime/TileGridPolicyTest.kt b/app/src/test/java/llc/slacker/openime/TileGridPolicyTest.kt new file mode 100644 index 00000000..ed0c65c3 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/TileGridPolicyTest.kt @@ -0,0 +1,40 @@ +package llc.slacker.openime + +import llc.slacker.openime.panel.TileGridPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TileGridPolicyTest { + /** + * The container takes 88.9% of the page with its own paddings and lays its + * tiles out 4 by 2, capped at 192 units (`cls_settings_bk_adaptive`, + * `style.xml:616-619`). + */ + @Test + fun theGridKeepsTheReferencesOwnRuler() { + assertEquals(4, TileGridPolicy.COLUMNS) + assertEquals(2, TileGridPolicy.ROWS) + assertEquals(8, TileGridPolicy.PAGE_SIZE) + assertEquals(0.88888f, TileGridPolicy.WIDTH_FRACTION, 0.00001f) + assertEquals(0.05555f, TileGridPolicy.PAD_H_FRACTION, 0.00001f) + assertEquals(0.0721f, TileGridPolicy.PAD_TOP_FRACTION, 0.00001f) + assertEquals(0.10096f, TileGridPolicy.PAD_BOTTOM_FRACTION, 0.00001f) + assertEquals(192, TileGridPolicy.MAX_HEIGHT_UNITS) + assertEquals(0.05f, TileGridPolicy.CHILD_PAD_FRACTION, 0.00001f) + assertEquals(0.11111f, TileGridPolicy.CHILD_V_PAD_FRACTION, 0.00001f) + assertEquals(14, TileGridPolicy.TILE_RADIUS_DP) + } + + /** A page is two rows deep; anything longer takes another row rather than a hidden entry. */ + @Test + fun theRowsCoverEveryEntry() { + assertEquals(2, TileGridPolicy.rows(0)) + assertEquals(2, TileGridPolicy.rows(1)) + assertEquals(2, TileGridPolicy.rows(8)) + assertEquals(3, TileGridPolicy.rows(9)) + assertEquals(3, TileGridPolicy.rows(12)) + // The glyph leaves room for the name inside the tile. + assertTrue(TileGridPolicy.ICON_FRACTION in 0.3f..0.7f) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 79d370e8..ffad5039 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -209,3 +209,7 @@ Add new entries at the end. - **The symbol page lists the symbols the user has typed in its toolbar.** The reference's symbol board fills its toolbar row with the history of typed symbols (`input_toolbar_quickinput.xml:4-18`, the `toolbar_symbol_recent` row the skin keeps hidden until the board needs it): a 4-unit lead inset, the scrolling history list at weight 308, the same hairline split its candidate strip uses, a 3-unit gap, the 40-unit close key and a 4-unit tail. One cell is 36 units wide with a radius-6 press face and candidate text in the candidate text colour (`cls_history_symbollist_button`, `style.xml:1061-1070`, commented 符号键盘 历史符号列表项), the list keeps 4 units between its cells (`cls_history_symbollist` childpadding, `style.xml:1055-1058`), and the close key borrows the candidate expand key's own 40-unit box (`cls_btn_more_cand_button_adaptive`, `style.xml:468`, over the transparent pill `cls_bk_toolbar_button`, `style.xml:231-235`). *Reason:* our board had no history: the symbols a user had just typed were gone when the page closed, so a phone number's `+`, `-` or `#` had to be found again on every visit. The list keeps the reference's row shape, and our own rules cover what the skin leaves open: the close key keeps the 48dp touch target, the cells keep the board's own five-a-row floor where the reference's box is 36 units, a tap commits through the same path the board's cells use (so the list re-reads itself), and the close key ends the strip for that visit while the board stays open — the next visit shows it again. The 收起 key stays at the row's right end, where our toolbar always keeps it. The strip's own empty case hides it: a user who has typed no symbols sees the plain toolbar, not an empty row. + +- **The launcher pages draw the reference's own tile grid.** + The tools page and the keyboard switcher are one surface shape in the reference: a container 88.9% of the page wide with its own paddings (5.6% at each side, 7.2% on top, 10.1% at the foot) and tiles laid out 4 columns by 2 rows with the grid capped at 192 units (`cls_settings_bk_adaptive`, `style.xml:616-619`; `input_settings.xml:13-45`, `input_switch_keyboard.xml:13-29`). Each tile fills its cell with a radius-14 face — `setting_button_bk` under it, `setting_button_pushedbk` while pressed, the theme colour when it is the chosen one — and its name reads in `keybutton_text_3`, white on the chosen tile (`cls_settings_button`, `style.xml:626-647`; `values/colors.xml:15,87-93`). The glyph sits over the name, as `ficon_text` over `text` with `align="top"` draws it. + *Reason:* our tiles were bare columns: a 56-unit icon chip carried the fill and the name sat outside it on the page background, so the reference's card — the whole cell — was missing, and our names took the plain key text where the reference dims them to `keybutton_text_3`. Our own card colour stays (`toolCardBackground`, opaque #FFFFFF light and #303032 dark) where the reference's is white at 88% and white at 12%, and the glyph's share of the tile is ours (52%) because the skin's `ficon_height="0.7647"` measures its own icon font box. Two of the reference's own rules do not carry over, both stated here: a tile never shrinks under our 48dp touch target — a short keyboard grows the rows back and the page scrolls instead of hiding an entry — and an entry list longer than a page takes a third row rather than the reference's paging, so its page dots (`cls_settings_tab_point_container`, `style.xml:670-677`) are not built. From 6b6a1993a83534561b86d44b4193ab1a93d600cd Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:07:02 +0800 Subject: [PATCH 23/94] Meet the floating keyboard with the reference's tip banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference fills the toolbar row with a tip while the floating keyboard is up (`input_toolbar_tip.xml:43-50`): one pill across the row, a 3.571% lead space, a left-aligned ellipsized label and a round close key whose width is its own height. Each of those numbers is now a constant with the line it came from, and the close key keeps our 48dp touch target. Ours names our own way back — a tap on the handle — where the reference asks for a drag to the bottom edge, because the docked keyboard is what our handle restores. The tip settles for the stay afloat once its close key is tapped, and the next stay shows it again. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/FloatingTipInstrumentedTest.kt | 124 ++++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 26 ++++ .../openime/keyboard/ImeThemeApplier.kt | 50 +++++++ .../slacker/openime/keyboard/ImeTopZone.kt | 121 ++++++++++++++++- .../openime/keyboard/ToolbarTipPolicy.kt | 29 ++++ .../slacker/openime/theme/ImeDesignTokens.kt | 28 ++++ .../slacker/openime/ToolbarTipPolicyTest.kt | 37 ++++++ docs/DECISIONS.md | 4 + 9 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f4e171..4d0fec99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 悬浮键盘时工具栏改为显示一条提示(参考皮肤在悬浮键盘上显示的就是这条提示):圆角药丸底、左边是说明文字「轻点顶部把手可恢复默认键盘」、右边一个圆形关闭键;按关闭键本次悬浮期间不再出现,收起重开悬浮键盘会再次显示。 - 工具页与切换键盘页改用参考皮肤的方格磁贴:整格就是一块圆角卡片(圆角 14,按下变深,当前键盘那一格填主题色、图标与文字变白),四列两行、同页磁贴等高,磁贴区域占页宽 88.9% 并留参考皮肤自己的四周留白;原先是 56dp 的图标小方块带底色、名称在方块外面。 - 符号页的工具栏改为显示最近输入过的符号(参考皮肤在符号页工具栏里放的就是这条「历史符号列表」,可横向滑动,最右是关闭键):点一下即可再次上屏,关闭键收起这条列表(本次进入符号页不再出现,下次进入符号页会重新显示)。没输入过符号时不显示这条列表。 - 符号页底部增加「123」与「退格」两个键(参考皮肤把这两个键放在符号那一行的两端):在符号页里可以直接退格,或一键切到数字键盘,不必先退出符号页。 diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt new file mode 100644 index 00000000..1784215c --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt @@ -0,0 +1,124 @@ +package llc.slacker.openime + +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 llc.slacker.openime.keyboard.ToolbarTipPolicy +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 floating keyboard meets the user with the reference's tip banner in the + * toolbar row (`input_toolbar_tip.xml:43-50`), whose exit is our own handle tap + * rather than the reference's drag to the bottom edge. + */ +@RunWith(AndroidJUnit4::class) +class FloatingTipInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { test(keyboard); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun tip(keyboard: ImeKeyboardView): View? = keyboard.findViewWithTag("toolbar-tip") + + @Test + fun theFloatingKeyboardOpensWithTheTipBanner() { + withKeyboard { keyboard -> + assertEquals(View.GONE, tip(keyboard)?.visibility) + keyboard.setFloatingWindowMode(true) + assertEquals(View.VISIBLE, tip(keyboard)?.visibility) + val label = keyboard.findViewWithTag("toolbar-tip-label") + assertNotNull("The banner says what the handle does", label) + assertTrue( + "Our tip names our own way back to the docked keyboard", + label.text.toString().contains("把手"), + ) + assertNotNull("The banner keeps the reference's round close key", keyboard.findViewWithTag("toolbar-tip-close")) + } + } + + @Test + fun theCloseKeySettlesTheTipForTheStayAfloat() { + withKeyboard { keyboard -> + keyboard.setFloatingWindowMode(true) + keyboard.findViewWithTag("toolbar-tip-close")!!.performClick() + assertEquals(View.GONE, tip(keyboard)?.visibility) + // A later floating update keeps it away: the user has read it. + keyboard.setFloatingWindowMode(true, compact = true) + assertEquals(View.GONE, tip(keyboard)?.visibility) + // Docking and floating again shows it once more. + keyboard.setFloatingWindowMode(false) + keyboard.setFloatingWindowMode(true) + assertEquals(View.VISIBLE, tip(keyboard)?.visibility) + } + } + + @Test + fun theTipTakesTheRowButNotTheCollectKey() { + withKeyboard { keyboard -> + keyboard.setFloatingWindowMode(true) + assertEquals(View.VISIBLE, tip(keyboard)?.visibility) + val hide = keyboard.findViewWithTag("keyboard-hide") + assertNotNull("The collect-the-keyboard key stays reachable", hide) + assertEquals(View.VISIBLE, hide.visibility) + assertTrue( + "The banner is wider than a toolbar icon: it takes the row", + tip(keyboard)!!.width > hide.width, + ) + } + } + + @Test + fun theBannerKeepsTheReferencesOwnProportions() { + withKeyboard { keyboard -> + keyboard.setFloatingWindowMode(true) + val strip = tip(keyboard)!! + val row = keyboard.findViewWithTag("toolbar-row")!! + val density = keyboard.resources.displayMetrics.density + assertEquals( + "The container keeps its own 3.333% padding", + (row.width * ToolbarTipPolicy.CONTAINER_PAD_FRACTION).toInt().toLong(), + strip.paddingLeft.toLong(), + ) + val lead = keyboard.findViewWithTag("toolbar-tip-lead")!! + assertEquals( + "`width_weight=\"0.03571\"` before the label", + (row.width * ToolbarTipPolicy.LEAD_WEIGHT).toInt().toLong(), + lead.width.toLong(), + ) + val close = keyboard.findViewWithTag("toolbar-tip-close")!! + assertEquals( + "The close key keeps the 48dp touch target", + (48 * density).toInt().toLong(), + close.width.toLong(), + ) + } + } +} 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 0eeff846..8bb16ee2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -159,6 +159,13 @@ open class ImeKeyboardView( private const val SCALE_CHANGE_EPSILON = 0.01f private const val FLOATING_NAV_STRIP_DP = 6 + /** + * The floating tip's own wording. The reference's tip asks for a drag to + * the bottom edge (`input_toolbar_tip.xml:48`); our floating keyboard + * docks again on a tap of its handle, so the tip says that instead. + */ + private const val FLOATING_TIP_TEXT = "轻点顶部把手可恢复默认键盘" + /** How often a deferred row rebuild re-checks whether the press ended. */ private const val ROW_REBUILD_POLL_MS = 40L @@ -325,6 +332,8 @@ open class ImeKeyboardView( private var floatingOpacityPercent = ImeSettingsRepository.loadFloatingOpacityPercent(context) private var referenceScale = if (standalonePanel) 1f else ImeReferenceSizing.scale(context) private var floatingWindowMode = false + /** True once the floating tip's close key was tapped: it stays away until the next afloat stay. */ + private var floatingTipDismissed = false /** Floating that the landscape setting started: it keeps the compact landscape rows. */ private var floatingCompact = false private var layoutMetrics = buildLayoutMetrics() @@ -1536,11 +1545,28 @@ open class ImeKeyboardView( /** Keep content geometry local when the service changes the window bounds. */ fun setFloatingWindowMode(enabled: Boolean, compact: Boolean = false) { + val wasFloating = floatingWindowMode floatingWindowMode = enabled floatingCompact = enabled && compact navigationBottomInsetPx = bottomInsetFor(systemBottomInsetPx) floatingKeyboardController.setEnabled(enabled) topZone.setCompactToolbar(enabled) + // The reference meets the floating keyboard with a tip in the toolbar row + // (`input_toolbar_tip.xml:43-50`); ours names our own way back, a tap on + // the handle. Its close key settles the tip for this stay afloat. + if (enabled) { + if (!wasFloating) floatingTipDismissed = false + if (floatingTipDismissed) { + topZone.hideTooltip() + } else { + topZone.showTooltip(FLOATING_TIP_TEXT) { + floatingTipDismissed = true + topZone.hideTooltip() + } + } + } else { + topZone.hideTooltip() + } layoutMetrics = buildLayoutMetrics() renderedMode = null topZone.setLongPressDrag( 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 9949d4b7..a501356f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -178,6 +178,33 @@ internal class ImeThemeApplier( toPx(RecentSymbolStripPolicy.CELL_RADIUS_DP), ) } + // The tip banner: the reference's own pill, painted inside the row's + // touch height (`cls_toolbar_tip_container` bkround 999, + // style.xml:1273-1274), with the label in the key text colour + // (`cls_toolbar_tip_label`, style.xml:1267-1270). + "toolbar-tip" -> { + view.background = paintedWithinTarget( + ImeDrawableFactory.rounded( + ImeSurfacePolicy.tipBackground(t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ), + ToolbarTipPolicy.PILL_UNITS, + padsView = false, + ) + } + // The close key keeps the reference's square of the pill's height + // (`width="match_height"`, style.xml:1306-1307) and its own + // translucent faces (`toolbar_tip_close_button_bk`, colors.xml:163). + "toolbar-tip-close" -> { + view.background = paintedSquareWithinTarget( + statefulRounded( + ImeSurfacePolicy.tipCloseBackground(t), + ImeSurfacePolicy.tipClosePressed(t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ), + ToolbarTipPolicy.PILL_UNITS, + ) + } // Rows sit inside a card that clips to its corners, so the pressed // fill is square and the card supplies the rounding. "setting-row" -> if (view.isClickable) { @@ -448,6 +475,11 @@ internal class ImeThemeApplier( tag == "recent-symbol-label" -> view.setTextColor(t.candidateText) + // The tip's label takes `keybutton_text` (cls_toolbar_tip_label, + // style.xml:1267-1270) — the same key text colour the toolbar uses. + tag == "toolbar-tip-label" -> + view.setTextColor(t.keyText) + tag == "candidate-filter-chip" -> { // 全部 / 单字: the chosen one is the raised surface with plain // text, the other a dimmed label on the group's own background @@ -786,6 +818,24 @@ internal class ImeThemeApplier( } } + /** + * Paint [drawable] as a [paintedDp] square, centred in the full touch + * target: the reference's close keys are as wide as the pill is high + * (`width="match_height"`), and our touch target stays 48dp. + */ + private fun paintedSquareWithinTarget( + drawable: android.graphics.drawable.Drawable, + paintedDp: Int, + ): android.graphics.drawable.Drawable { + val inset = (toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - toPx(paintedDp)) / 2 + return object : InsetDrawable(drawable, inset, inset, inset, inset) { + override fun getPadding(padding: android.graphics.Rect): Boolean { + padding.set(0, 0, 0, 0) + return false + } + } + } + private fun hasAncestorTag(view: View, tag: String): Boolean { var parent = view.parent while (parent is View) { 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 8aba0c22..cee1455e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -124,16 +124,28 @@ internal class ImeTopZone( private var recentSymbolsShown = false /** - * What the toolbar row shows; icons, autofill chips, associations and the - * symbol history are exclusive. The history takes the row while the symbol - * board is open, as the reference's own symbol toolbar does. + * The tip banner the reference shows in the toolbar row while the floating + * keyboard is up (`input_toolbar_tip.xml:43-50`, see [ToolbarTipPolicy]). */ - private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION, RECENT_SYMBOLS } + private val tipStrip = LinearLayout(context) + private val tipLead = View(context) + private lateinit var tipLabel: TextView + private lateinit var tipClose: ImageView + private var tipShown = false + + /** + * What the toolbar row shows; icons, autofill chips, associations, the + * symbol history and the tip are exclusive. The history takes the row while + * the symbol board is open, as the reference's own symbol toolbar does; the + * tip gives to every strip that has something to say while the user types. + */ + private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION, RECENT_SYMBOLS, TIP } private fun idleToolbarMode(): ToolbarMode = when { recentSymbolsShown -> ToolbarMode.RECENT_SYMBOLS associationsShown -> ToolbarMode.ASSOCIATION autofillShown -> ToolbarMode.AUTOFILL + tipShown -> ToolbarMode.TIP else -> ToolbarMode.NORMAL } @@ -273,6 +285,7 @@ internal class ImeTopZone( // (`input_toolbar_idle.xml:41-43`, `cls_toolbar_idle_button` width 40, // `style.xml:237-248`). buildRecentSymbolStrip() + buildTipStrip() keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard) toolbarRow.addView( keyboardHide, @@ -659,6 +672,99 @@ internal class ImeTopZone( refreshToolbar() } + /** + * Lays out the reference's tip banner: one pill across the toolbar row, a + * lead space, the label at the left of the rest and a round close key + * (`input_toolbar_tip.xml:43-50`, `style.xml:442`, `:1265-1313`). The strip + * is the row's full 48 units and the pill is painted inside it, as + * [ToolbarTipPolicy.PILL_UNITS] describes. + */ + private fun buildTipStrip() { + tipStrip.apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + tag = "toolbar-tip" + visibility = View.GONE + } + tipLead.apply { tag = "toolbar-tip-lead" } + tipStrip.addView( + tipLead, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT), + ) + tipLabel = TextView(context).apply { + tag = "toolbar-tip-label" + textSize = ImeTypographyTokens.BODY_SP + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + gravity = Gravity.CENTER_VERTICAL or Gravity.START + includeFontPadding = false + isClickable = false + } + tipStrip.addView( + tipLabel, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f), + ) + tipClose = ImageView(context).apply { + tag = "toolbar-tip-close" + setImageResource(R.drawable.ic_close) + scaleType = ImageView.ScaleType.CENTER_INSIDE + contentDescription = "关闭提示" + isClickable = true + isFocusable = true + } + tipStrip.addView(tipClose, touchTargetParams()) + toolbarRow.addView(tipStrip, LinearLayout.LayoutParams(0, toPx(48))) + } + + /** + * Shows the tip banner with [text] in the toolbar row, or the normal toolbar + * again when [hideTooltip] runs. The label is left-aligned and ellipsized + * (`cls_toolbar_tip_label`, `style.xml:1267-1270`), and the close key is the + * same 48dp touch target as every other toolbar control. + */ + fun showTooltip(text: String, onClose: () -> Unit) { + tipLabel.text = text + tipClose.setOnClickListener { + onFeedback() + onClose() + } + val changed = !tipShown + tipShown = true + if (changed) { + toolbarMode = idleToolbarMode() + refreshToolbar() + } + } + + /** Takes the tip banner out of the toolbar row (floating mode ended, or its close key tapped). */ + fun hideTooltip() { + if (!tipShown) return + tipShown = false + toolbarMode = idleToolbarMode() + refreshToolbar() + } + + /** + * The banner's own fractions of the row width: the container's 3.333% + * horizontal padding, its 3.571% lead space and the 1.2% the label keeps + * before its text (`style.xml:442`, `:1265`, `:1278`). + */ + private fun applyTipGeometry(rowWidth: Int) { + fun units(fraction: Float): Int = (rowWidth * fraction).toInt().coerceAtLeast(0) + val pad = units(ToolbarTipPolicy.CONTAINER_PAD_FRACTION) + tipStrip.setPadding(pad, 0, pad, 0) + (tipLead.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + params.width = units(ToolbarTipPolicy.LEAD_WEIGHT) + tipLead.layoutParams = params + } + tipLabel.setPadding(units(ToolbarTipPolicy.LABEL_PAD_FRACTION), 0, 0, 0) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (w > 0 && w != oldw) applyTipGeometry(w) + } + /** One history cell: the reference's 36-unit box over candidate-sized text. */ private fun recentSymbolCell(symbol: String, onPick: (String) -> Unit): View = LinearLayout(context).apply { @@ -846,6 +952,7 @@ internal class ImeTopZone( val association = toolbarMode == ToolbarMode.ASSOCIATION val autofill = toolbarMode == ToolbarMode.AUTOFILL val recent = toolbarMode == ToolbarMode.RECENT_SYMBOLS + val tip = toolbarMode == ToolbarMode.TIP val shown = if (compact) compactToolbarIcons else toolbarIcons (toolbarIcons + compactToolbarIcons).distinct().forEach { it.visibility = if (normal && it in shown) View.VISIBLE else View.GONE @@ -855,6 +962,7 @@ internal class ImeTopZone( associationScroll.visibility = if (association) View.VISIBLE else View.GONE autofillScroll.visibility = if (autofill) View.VISIBLE else View.GONE recentSymbolStrip.visibility = if (recent) View.VISIBLE else View.GONE + tipStrip.visibility = if (tip) View.VISIBLE else View.GONE keyboardHide.visibility = View.VISIBLE // The icons keep the reference's own width and their own row takes the // slack; a suggestion strip, the autofill chips or the symbol history @@ -870,6 +978,11 @@ internal class ImeTopZone( params.weight = if (recent) 1f else 0f recentSymbolStrip.layoutParams = params } + (tipStrip.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + params.width = 0 + params.weight = if (tip) 1f else 0f + tipStrip.layoutParams = params + } } fun setContentInset(contentInsetPx: Int) { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt new file mode 100644 index 00000000..298979cd --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt @@ -0,0 +1,29 @@ +package llc.slacker.openime.keyboard + +/** + * The reference's toolbar tip banner ("引导"): a pill that fills the toolbar + * row with a lead space, a left-aligned label and a round close key + * (`layout/input_toolbar_tip.xml:43-50`). + */ +internal object ToolbarTipPolicy { + /** The container's own horizontal padding, 3.333% of the row (`style.xml:442`). */ + const val CONTAINER_PAD_FRACTION = 0.03333f + + /** `cls_toolbar_tip_leading_space_adaptive` width_weight (`style.xml:1278`). */ + const val LEAD_WEIGHT = 0.03571f + + /** `cls_toolbar_tip_label_adaptive` padding_decimal (`style.xml:1265`). */ + const val LABEL_PAD_FRACTION = 0.012f + + /** + * The pill's height. The container is the full row minus its own vertical + * padding, 0.17857 + 0.10714 of the row (`style.xml:442`), which leaves + * 0.71429 of a 48-unit row — the 34dp our painted toolbar controls use. The + * close key is a square of that height (`width="match_height"`, + * `style.xml:1306-1307`). + */ + const val PILL_UNITS = 34 + + /** `bkround="999"` on the container and on the close key (`style.xml:1274`, `:1310`). */ + const val RADIUS_UNITS = 999 +} 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 08223e3f..b4dc1af3 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -346,6 +346,34 @@ internal object ImeSurfacePolicy { fun switchKnob(tokens: ImeTheme.Tokens): Int = if (isDark(tokens)) Color.parseColor("#F2F3F5") else Color.WHITE + /** + * The tip banner's pill: the reference fills it with `keybutton_bk`, opaque + * white on the light skin and white at 28% over the keyboard on the dark one + * (`values/colors.xml:19`, `values/dark_colors.xml:18`). + */ + fun tipBackground(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + Color.WHITE, + tokens.keyboardBackground, + if (isDark(tokens)) 0.28f else 1f, + ) + + /** The tip's close key, `toolbar_tip_close_button_bk` (black 4% / white 7% over the pill). */ + fun tipCloseBackground(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + if (isDark(tokens)) Color.WHITE else Color.BLACK, + tipBackground(tokens), + if (isDark(tokens)) 0.07f else 0.04f, + ) + + /** Its pressed face, `toolbar_tip_close_button_pushedbk` (black 14% / white 17%). */ + fun tipClosePressed(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + if (isDark(tokens)) Color.WHITE else Color.BLACK, + tipBackground(tokens), + if (isDark(tokens)) 0.17f else 0.14f, + ) + /** Inset separator between rows of one grouped card. */ fun hairline(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.blend(tokens.keyText, tokens.toolCardBackground, if (isDark(tokens)) 0.10f else 0.08f) diff --git a/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt new file mode 100644 index 00000000..95a4c8df --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt @@ -0,0 +1,37 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.ToolbarTipPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ToolbarTipPolicyTest { + /** + * The banner's own fractions of the row width: the container padding + * (`cls_toolbar_tip_adaptive`, `style.xml:442`), the lead space + * (`style.xml:1278`) and the label's own inset (`style.xml:1265`). + */ + @Test + fun theBannerKeepsTheReferencesOwnRuler() { + assertEquals(0.03333f, ToolbarTipPolicy.CONTAINER_PAD_FRACTION, 0.00001f) + assertEquals(0.03571f, ToolbarTipPolicy.LEAD_WEIGHT, 0.00001f) + assertEquals(0.012f, ToolbarTipPolicy.LABEL_PAD_FRACTION, 0.00001f) + assertEquals(999, ToolbarTipPolicy.RADIUS_UNITS) + } + + /** + * The pill is the row minus the container's two vertical fractions + * (0.17857 + 0.10714 of the 48-unit row), which leaves the 34dp our painted + * toolbar controls use. + */ + @Test + fun thePillIsTheRowsOwnRemainder() { + val rowUnits = 48f + val left = rowUnits * (1f - 0.17857f - 0.10714f) + assertEquals(ToolbarTipPolicy.PILL_UNITS.toFloat(), left, 0.5f) + assertTrue( + "A 48dp touch target still fits around the pill", + ToolbarTipPolicy.PILL_UNITS < 48, + ) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ffad5039..7403a0b6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -213,3 +213,7 @@ Add new entries at the end. - **The launcher pages draw the reference's own tile grid.** The tools page and the keyboard switcher are one surface shape in the reference: a container 88.9% of the page wide with its own paddings (5.6% at each side, 7.2% on top, 10.1% at the foot) and tiles laid out 4 columns by 2 rows with the grid capped at 192 units (`cls_settings_bk_adaptive`, `style.xml:616-619`; `input_settings.xml:13-45`, `input_switch_keyboard.xml:13-29`). Each tile fills its cell with a radius-14 face — `setting_button_bk` under it, `setting_button_pushedbk` while pressed, the theme colour when it is the chosen one — and its name reads in `keybutton_text_3`, white on the chosen tile (`cls_settings_button`, `style.xml:626-647`; `values/colors.xml:15,87-93`). The glyph sits over the name, as `ficon_text` over `text` with `align="top"` draws it. *Reason:* our tiles were bare columns: a 56-unit icon chip carried the fill and the name sat outside it on the page background, so the reference's card — the whole cell — was missing, and our names took the plain key text where the reference dims them to `keybutton_text_3`. Our own card colour stays (`toolCardBackground`, opaque #FFFFFF light and #303032 dark) where the reference's is white at 88% and white at 12%, and the glyph's share of the tile is ours (52%) because the skin's `ficon_height="0.7647"` measures its own icon font box. Two of the reference's own rules do not carry over, both stated here: a tile never shrinks under our 48dp touch target — a short keyboard grows the rows back and the page scrolls instead of hiding an entry — and an entry list longer than a page takes a third row rather than the reference's paging, so its page dots (`cls_settings_tab_point_container`, `style.xml:670-677`) are not built. + +- **The floating keyboard carries the reference's own tip banner.** + The reference meets the floating keyboard with a tip row: `input_toolbar_tip.xml:43-50` is one `cls_toolbar_tip_container` pill — the full row's width, `bkcolor="@color/keybutton_bk"`, `bkround="999"` (`style.xml:1272-1274`) — with its own 3.333% horizontal padding (`cls_toolbar_tip_adaptive`, `style.xml:442`), holding a 3.571% lead space (`cls_toolbar_tip_leading_space_adaptive`, `style.xml:1278`), a left-aligned ellipsized label in `keybutton_text` with its own 1.2% inset (`cls_toolbar_tip_label` over `cls_toolbar_tip_label_adaptive`, `style.xml:1265-1270`), and one round close key as wide as it is tall, filled `toolbar_tip_close_button_bk` and pressed `toolbar_tip_close_button_pushedbk` — black 4% and 14% on the light skin, white 7% and 17% on the dark one (`cls_toolbar_tip_close_button`, `style.xml:1305-1313`; `values/colors.xml:163-164`, `values/dark_colors.xml:157-158`). The container's two vertical fractions (0.17857 + 0.10714 of the row, `style.xml:442`) leave 0.71429 of it, the 34dp our painted toolbar controls already use. + *Reason:* our floating keyboard's only way back is a tap on its handle, and that instruction lived in the handle's accessibility label alone, so a sighted user had no way to learn it. The tip is the reference's own surface for exactly that message, so the row is ported with our own truthful wording (轻点顶部把手可恢复默认键盘) in place of the reference's drag to the bottom edge (`input_toolbar_tip.xml:48`), because our handle docks the keyboard again on a tap. The close key settles the tip for that stay afloat and the next one shows it again, the rule the symbol history uses; 收起 keeps its place at the row's right end, and the tip gives the row back to candidates, associations and the symbol history whenever they have something to say. From 45bb0470a1437f064fd98ce9c57343a7e2406b64 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:11:34 +0800 Subject: [PATCH 24/94] Cap the pre-edit at the reference's half of the page The typing toolbar holds the pinyin block in a wrapper of at most half the page (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:7`), with a 5-unit lead of its own and the row's 3-unit top inset; the other half goes to an empty control, the room the reference keeps beside it (line 18). The string scrolls inside that cap, as the reference's own composition container does, and the block carries the container's own press tint (`comp_pushedbk`, black 8% light and white 12% dark). Our pre-edit ran the full width of the row in portrait and stopped at a 160dp cap of our own making in landscape, and it gave no press feedback while it was edited. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/PreEditWidthInstrumentedTest.kt | 110 ++++++++++++++++++ .../openime/keyboard/CompositionPolicy.kt | 20 ++++ .../openime/keyboard/ImeThemeApplier.kt | 11 ++ .../slacker/openime/keyboard/ImeTopZone.kt | 47 +++++++- .../slacker/openime/CompositionPolicyTest.kt | 20 ++++ docs/DECISIONS.md | 4 + 7 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0fec99..781426ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 拼音预编辑框宽度改为参考皮肤的一半页宽(参考皮肤的这个输入块最多占半页,右半留给旁边的控件),超出部分在框内左右滚动;左侧内边距改为参考的 5 单位;编辑时按参考皮肤变暗(浅色 8% 黑、深色 12% 白)。原先竖屏铺满整行、横屏用我们自己定的 160dp 上限,且编辑时没有按下反馈。 - 悬浮键盘时工具栏改为显示一条提示(参考皮肤在悬浮键盘上显示的就是这条提示):圆角药丸底、左边是说明文字「轻点顶部把手可恢复默认键盘」、右边一个圆形关闭键;按关闭键本次悬浮期间不再出现,收起重开悬浮键盘会再次显示。 - 工具页与切换键盘页改用参考皮肤的方格磁贴:整格就是一块圆角卡片(圆角 14,按下变深,当前键盘那一格填主题色、图标与文字变白),四列两行、同页磁贴等高,磁贴区域占页宽 88.9% 并留参考皮肤自己的四周留白;原先是 56dp 的图标小方块带底色、名称在方块外面。 - 符号页的工具栏改为显示最近输入过的符号(参考皮肤在符号页工具栏里放的就是这条「历史符号列表」,可横向滑动,最右是关闭键):点一下即可再次上屏,关闭键收起这条列表(本次进入符号页不再出现,下次进入符号页会重新显示)。没输入过符号时不显示这条列表。 diff --git a/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt new file mode 100644 index 00000000..d95c9fc6 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt @@ -0,0 +1,110 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.keyboard.CompositionPolicy +import llc.slacker.openime.keyboard.ImeKeyboardView +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 pre-edit keeps the reference's own half of the page + * (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:7`) and scrolls inside + * it rather than running the full width of the candidate row. + */ +@RunWith(AndroidJUnit4::class) +class PreEditWidthInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun tap(harness: DirectActivityHarness, view: View) { + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + SystemClock.sleep(150) + } + + private fun type(harness: DirectActivityHarness, keyboard: ImeKeyboardView, keys: String) { + keys.forEach { letter -> + val key = harness.awaitMain { + keyboard.findViewWithTag("key:$letter")?.takeIf { it.width > 0 } + } + assertNotNull("The $letter key must be on the surface", key) + tap(harness, key!!) + } + } + + @Test + fun thePreEditStopsAtHalfThePage() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + type(harness, keyboard, "nihao") + val zone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + val editor = harness.awaitMain { + keyboard.findViewWithTag("pinyin-composition-editor") + ?.takeIf { it.text.isNotEmpty() } + } + assertNotNull("Typing pinyin must open the pre-edit", editor) + val cap = CompositionPolicy.maxWidthPx(zone.width) + assertEquals("The pre-edit keeps the reference's half of the row", cap, editor!!.width) + assertEquals("The cap is the field's own limit too", cap, editor.maxWidth) + assertTrue( + "The other half of the row is left to the surface beside it", + editor.right <= (zone.width / 2) + 2, + ) + // The string scrolls inside the cap: it never widens the row. + type(harness, keyboard, "shijiede") + val longer = harness.awaitMain { + keyboard.findViewWithTag("pinyin-composition-editor") + ?.takeIf { it.text.length > 5 }?.width ?: -1 + } + assertEquals("A longer string keeps the same cap", cap, longer) + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt new file mode 100644 index 00000000..8152e6e0 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt @@ -0,0 +1,20 @@ +package llc.slacker.openime.keyboard + +/** + * The typing toolbar's pre-edit block: a scrollable composition at most half + * the page wide, with the rest of its row left empty for the surface beside it + * (`layout/input_toolbar_typing.xml:5-19`). + */ +internal object CompositionPolicy { + /** + * `maxwidth="halfMaxWidth"` on the composition's wrapper, the block that + * holds the two labels (`input_toolbar_typing.xml:7`; the row leaves the + * other half to an empty control, line 18). + */ + const val MAX_WIDTH_FRACTION = 0.5f + + /** The block's own left inset, `padding="5,0,0,0"` (`input_toolbar_typing.xml:7`). */ + const val LEAD_UNITS = 5 + + fun maxWidthPx(zoneWidth: Int): Int = (zoneWidth * MAX_WIDTH_FRACTION).toInt() +} 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 a501356f..f25a1a23 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -178,6 +178,17 @@ internal class ImeThemeApplier( toPx(RecentSymbolStripPolicy.CELL_RADIUS_DP), ) } + // The pre-edit block carries the reference's own press tint while the + // user edits the string (`cls_comp_container` pushedbkcolor + // @color/comp_pushedbk, style.xml:575-577 — black 8%, white 12%, + // the alphas our pressed surfaces already use). + "pinyin-composition-editor" -> { + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.toolbarBackground, t), + 0, + ) + } // The tip banner: the reference's own pill, painted inside the row's // touch height (`cls_toolbar_tip_container` bkround 999, // style.xml:1273-1274), with the label in the key text colour 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 cee1455e..3f5a62d9 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -132,6 +132,8 @@ internal class ImeTopZone( private lateinit var tipLabel: TextView private lateinit var tipClose: ImageView private var tipShown = false + /** True while the zone draws the landscape strip, where the pre-edit sits beside the candidates. */ + private var landscapeStrip = false /** * What the toolbar row shows; icons, autofill chips, associations, the @@ -323,7 +325,10 @@ internal class ImeTopZone( setSelectAllOnFocus(false) background = null includeFontPadding = false - setPadding(toPx(14), toPx(3), toPx(14), 0) + // The block's own insets: a 5-unit lead and the row's 3-unit top + // (`input_toolbar_typing.xml:5,7`); the row leaves its right side to + // the surface beside it (line 18) and the string scrolls inside it. + setPadding(toPx(CompositionPolicy.LEAD_UNITS), toPx(3), 0, 0) minimumHeight = toPx(COMPOSITION_LINE_DP) addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit @@ -762,7 +767,33 @@ internal class ImeTopZone( override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) - if (w > 0 && w != oldw) applyTipGeometry(w) + if (w > 0 && w != oldw) { + applyTipGeometry(w) + applyCompositionGeometry(w) + } + } + + /** + * The pre-edit's own cap: half the page (`maxwidth="halfMaxWidth"` on the + * composition's wrapper, `input_toolbar_typing.xml:7`). The row above the + * candidates gives the other half back to the surface beside it, and a long + * string scrolls inside the cap the way the reference's own block does + * (`sliderenable="true"` on `cls_comp_container`, `style.xml:575-577`). + */ + private fun compositionMaxWidthPx(): Int = CompositionPolicy.maxWidthPx( + if (width > 0) width else resources.displayMetrics.widthPixels, + ) + + private fun applyCompositionGeometry(zoneWidth: Int) { + val cap = CompositionPolicy.maxWidthPx(zoneWidth) + composition.maxWidth = cap + (composition.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + val desired = if (landscapeStrip) LinearLayout.LayoutParams.WRAP_CONTENT else cap + if (params.width != desired) { + params.width = desired + composition.layoutParams = params + } + } } /** One history cell: the reference's 36-unit box over candidate-sized text. */ @@ -893,6 +924,7 @@ internal class ImeTopZone( * the pinyin sits at the left of the candidates instead of above them. */ fun setLandscapeStrip(on: Boolean) { + landscapeStrip = on val height = toPx( if (on) ImeGeometryTokens.LANDSCAPE_TOP_ZONE_HEIGHT_DP else ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP, ) @@ -911,12 +943,12 @@ internal class ImeTopZone( it.width = LinearLayout.LayoutParams.WRAP_CONTENT it.height = LinearLayout.LayoutParams.MATCH_PARENT } else { - it.width = LinearLayout.LayoutParams.MATCH_PARENT + it.width = compositionMaxWidthPx() it.height = toPx(COMPOSITION_LINE_DP) } composition.layoutParams = it } - composition.maxWidth = if (on) toPx(160) else Int.MAX_VALUE + composition.maxWidth = compositionMaxWidthPx() composition.gravity = Gravity.CENTER_VERTICAL or Gravity.START (candidateField.layoutParams as? LinearLayout.LayoutParams)?.let { if (on) { @@ -987,7 +1019,12 @@ internal class ImeTopZone( fun setContentInset(contentInsetPx: Int) { toolbarRow.setPadding(contentInsetPx + toPx(6), 0, contentInsetPx + toPx(6), 0) - composition.setPadding(contentInsetPx + toPx(14), toPx(3), contentInsetPx + toPx(14), 0) + composition.setPadding( + contentInsetPx + toPx(CompositionPolicy.LEAD_UNITS), + toPx(3), + contentInsetPx, + 0, + ) } fun renderState( diff --git a/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt new file mode 100644 index 00000000..489137ea --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt @@ -0,0 +1,20 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.CompositionPolicy +import org.junit.Assert.assertEquals +import org.junit.Test + +class CompositionPolicyTest { + /** + * The pre-edit block is at most half the page: `maxwidth="halfMaxWidth"` on + * the composition's wrapper, whose sibling control takes the other half + * (`input_toolbar_typing.xml:7,18`). + */ + @Test + fun thePreEditKeepsHalfThePage() { + assertEquals(0.5f, CompositionPolicy.MAX_WIDTH_FRACTION, 0.00001f) + assertEquals(5, CompositionPolicy.LEAD_UNITS) + assertEquals(195, CompositionPolicy.maxWidthPx(390)) + assertEquals(0, CompositionPolicy.maxWidthPx(0)) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7403a0b6..5632c3b8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -217,3 +217,7 @@ Add new entries at the end. - **The floating keyboard carries the reference's own tip banner.** The reference meets the floating keyboard with a tip row: `input_toolbar_tip.xml:43-50` is one `cls_toolbar_tip_container` pill — the full row's width, `bkcolor="@color/keybutton_bk"`, `bkround="999"` (`style.xml:1272-1274`) — with its own 3.333% horizontal padding (`cls_toolbar_tip_adaptive`, `style.xml:442`), holding a 3.571% lead space (`cls_toolbar_tip_leading_space_adaptive`, `style.xml:1278`), a left-aligned ellipsized label in `keybutton_text` with its own 1.2% inset (`cls_toolbar_tip_label` over `cls_toolbar_tip_label_adaptive`, `style.xml:1265-1270`), and one round close key as wide as it is tall, filled `toolbar_tip_close_button_bk` and pressed `toolbar_tip_close_button_pushedbk` — black 4% and 14% on the light skin, white 7% and 17% on the dark one (`cls_toolbar_tip_close_button`, `style.xml:1305-1313`; `values/colors.xml:163-164`, `values/dark_colors.xml:157-158`). The container's two vertical fractions (0.17857 + 0.10714 of the row, `style.xml:442`) leave 0.71429 of it, the 34dp our painted toolbar controls already use. *Reason:* our floating keyboard's only way back is a tap on its handle, and that instruction lived in the handle's accessibility label alone, so a sighted user had no way to learn it. The tip is the reference's own surface for exactly that message, so the row is ported with our own truthful wording (轻点顶部把手可恢复默认键盘) in place of the reference's drag to the bottom edge (`input_toolbar_tip.xml:48`), because our handle docks the keyboard again on a tap. The close key settles the tip for that stay afloat and the next one shows it again, the rule the symbol history uses; 收起 keeps its place at the row's right end, and the tip gives the row back to candidates, associations and the symbol history whenever they have something to say. + +- **The pre-edit keeps the reference's own half of the page.** + The typing toolbar's composition row holds the pinyin block in a wrapper of at most half the page's width (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:7`) with a 5-unit lead of its own (`padding="5,0,0,0"`, line 7) under the row's 3-unit top inset (line 5); the other half goes to an empty control, the room the reference keeps for the surface beside it (line 18). The string scrolls inside that cap, as the reference's own composition container does (`sliderenable="true" sliderstopslowdown="true"`, `cls_comp_container`, `style.xml:575-577`), and the block carries the container's own press tint while it is edited (`pushedbkcolor="@color/comp_pushedbk"` — black 8% on the light skin, white 12% on the dark one, `values/colors.xml:84`, `values/dark_colors.xml:82`, the alphas our pressed surfaces already use). + *Reason:* our pre-edit ran the full width of the row in portrait, and in landscape it stopped at a 160dp cap of our own making with no source, so the line was twice the reference's and the row it shares had no room reserved beside it; the field also gave no press feedback while it was being edited, although the reference tints it. Our 14dp side insets are replaced by the block's own 5-unit lead, which is what the skin declares. The caret still follows the string, so a pinyin line past the cap scrolls to its newest letters. From 1d51bc1a721bbb60c64e8db599b9fb6675dad358 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:18:03 +0800 Subject: [PATCH 25/94] Wear the reference's quick-input row for our own suggestions A fresh copy or a text message is answered by the reference's own quick-input toolbar (`input_toolbar_quickinput.xml`): the text at the candidate font in `canditem_text` with the glyph that names where it came from (lines 39-45), and the row's own close key borrowed from `cls_history_close_button` (line 46) instead of a back control. Our chips were filled pills at the body size behind a back chevron, a shape no reference toolbar draws. The system's autofill chips keep that back control, because they are the platform's own views in the same strip; every row keeps our 48dp touch target. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/QuickInputRowInstrumentedTest.kt | 143 ++++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 56 +++++-- .../openime/keyboard/ImeThemeApplier.kt | 14 +- .../slacker/openime/keyboard/ImeTopZone.kt | 38 ++++- docs/DECISIONS.md | 4 + 6 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 781426ca..4ac22b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小图标(消息/剪贴板),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 - 拼音预编辑框宽度改为参考皮肤的一半页宽(参考皮肤的这个输入块最多占半页,右半留给旁边的控件),超出部分在框内左右滚动;左侧内边距改为参考的 5 单位;编辑时按参考皮肤变暗(浅色 8% 黑、深色 12% 白)。原先竖屏铺满整行、横屏用我们自己定的 160dp 上限,且编辑时没有按下反馈。 - 悬浮键盘时工具栏改为显示一条提示(参考皮肤在悬浮键盘上显示的就是这条提示):圆角药丸底、左边是说明文字「轻点顶部把手可恢复默认键盘」、右边一个圆形关闭键;按关闭键本次悬浮期间不再出现,收起重开悬浮键盘会再次显示。 - 工具页与切换键盘页改用参考皮肤的方格磁贴:整格就是一块圆角卡片(圆角 14,按下变深,当前键盘那一格填主题色、图标与文字变白),四列两行、同页磁贴等高,磁贴区域占页宽 88.9% 并留参考皮肤自己的四周留白;原先是 56dp 的图标小方块带底色、名称在方块外面。 diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt new file mode 100644 index 00000000..ebf06983 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt @@ -0,0 +1,143 @@ +package llc.slacker.openime + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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 llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeTheme +import llc.slacker.openime.theme.ImeTypographyTokens +import org.junit.Assert.assertEquals +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 + +/** + * The clipboard and message suggestions the app builds for itself wear the + * reference's quick-input rows (`input_toolbar_quickinput.xml:23-46`): flat + * text at the candidate font with the glyph that says where it came from, and + * the reference's own close key at the row's end. + */ +@RunWith(AndroidJUnit4::class) +class QuickInputRowInstrumentedTest { + private val characters = mutableListOf() + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + if (method.name == "onCharacter") characters += args!![0] as String + if (method.returnType == java.lang.Boolean.TYPE) false else null + } as ImeKeyboardView.Listener + + private fun withClipboard(text: String, test: (ImeKeyboardView, (String) -> Unit) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + val clipboard = harness.awaitMain { + keyboard.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + } + val copy: (String) -> Unit = { copied -> + harness.awaitMain { + clipboard.setPrimaryClip(ClipData.newPlainText("openime-test", copied)) + true + } + Unit + } + harness.awaitMain { + keyboard.applyPersistedSettings( + newTheme = ImeTheme.IOS, + newAppearance = ImeAppearance.LIGHT, + sound = false, + haptic = true, + popup = true, + fuzzy = false, + ) + true + } + copy(text) + harness.awaitMain { keyboard.refreshSuggestions(); true } + harness.awaitMain { test(keyboard, copy); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + @Test + fun theClipboardSuggestionIsTheReferencesOwnRow() { + withClipboard("hello from the clipboard") { keyboard, _ -> + val row = keyboard.findViewWithTag("suggestion-row") + assertNotNull("A copied text must offer a row", row) + val label = keyboard.findViewWithTag("suggestion-text") + assertNotNull("The row carries the text itself", label) + assertEquals( + "The quick-input text reads at the candidate font", + ImeTypographyTokens.CANDIDATE_SP, + label!!.textSize / keyboard.resources.displayMetrics.scaledDensity, + 0.5f, + ) + assertNotNull("and the glyph that says where it came from", keyboard.findViewWithTag("suggestion-icon")) + val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) + assertEquals( + "`canditem_text` over the row (`input_toolbar_quickinput.xml:40`)", + tokens.candidateText, + label.currentTextColor, + ) + val close = keyboard.findViewWithTag("autofill-close") + assertNotNull("The row ends with the reference's close key", close) + assertEquals(View.VISIBLE, close.visibility) + assertEquals( + "The strip's back control belongs to the system's chips", + View.GONE, + keyboard.findViewWithTag("association-back")?.visibility, + ) + } + } + + @Test + fun tappingTheRowTypesTheTextAndTheCloseKeyDropsIt() { + withClipboard("123456", { keyboard, copy -> + characters.clear() + keyboard.findViewWithTag("suggestion-row")!!.performClick() + assertTrue("A tap types the copied text", characters.contains("123456")) + assertNull( + "The row goes away with the tap", + keyboard.findViewWithTag("suggestion-row"), + ) + // A later copy is a new suggestion, so the row is back. + copy("654321") + keyboard.refreshSuggestions() + assertNotNull( + "A fresh copy brings the row back", + keyboard.findViewWithTag("suggestion-row"), + ) + keyboard.findViewWithTag("autofill-close")!!.performClick() + assertNull( + "The close key drops the row for this text", + keyboard.findViewWithTag("suggestion-row"), + ) + keyboard.refreshSuggestions() + assertNull( + "and keeps it away until the clipboard changes", + keyboard.findViewWithTag("suggestion-row"), + ) + }) + } +} 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 8bb16ee2..91a07ec9 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -22,6 +22,7 @@ import android.view.WindowInsets import android.view.animation.DecelerateInterpolator import android.widget.EditText import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import llc.slacker.openime.QuickPhraseEditActivity @@ -383,39 +384,68 @@ open class ImeKeyboardView( 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 + // The reference's own quick-input rows are flat: the text at the + // candidate font, then the glyph that names where it came from + // (`input_toolbar_quickinput.xml:39-45` for the clipboard row, whose + // `quickinput_clipboard_icon1` follows `btn_quickinput_clipboard`; + // `:23-28` for the message row). + val sms = suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE + val row = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL 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) { + tag = "suggestion-row" + isClickable = true + isFocusable = true + contentDescription = if (sms) { "验证码${suggestion.text},点击输入" } else { "刚复制的内容,点击输入" } + addView( + TextView(context).apply { + text = suggestion.label + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + textSize = ImeTypographyTokens.CANDIDATE_SP + gravity = Gravity.CENTER_VERTICAL + includeFontPadding = false + tag = "suggestion-text" + }, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ), + ) + addView( + ImageView(context).apply { + tag = "suggestion-icon" + setImageResource(if (sms) R.drawable.ic_bubble else R.drawable.ic_clipboard) + scaleType = ImageView.ScaleType.CENTER_INSIDE + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, + LinearLayout.LayoutParams(dp(18), LinearLayout.LayoutParams.MATCH_PARENT), + ) setOnClickListener { feedback() if (!insertIntoInlineEditor(suggestion.text)) listener.onCharacter(suggestion.text) - if (suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE) dismissedSmsCode = suggestion.text + if (sms) dismissedSmsCode = suggestion.text else dismissedSuggestion = suggestion.text ownSuggestionsShown = false topZone.setAutofillChips(emptyList()) } } + // The row is built after the last theme pass, so it takes the palette + // itself, the way the nine-key rail does. + applyThemeToSubtree(row) + row } topZone.onAutofillDismissed = { found.lastOrNull { it.kind == ToolbarSuggestion.Kind.CLIPBOARD }?.let { dismissedSuggestion = it.text } found.firstOrNull { it.kind == ToolbarSuggestion.Kind.SMS_CODE }?.let { dismissedSmsCode = it.text } } ownSuggestionsShown = true - topZone.setAutofillChips(chips, chipHeightPx = dp(36)) + topZone.setAutofillChips(chips, chipHeightPx = dp(36), own = true) } internal fun refreshAuxiliaryContent() { 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 f25a1a23..97cac326 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -185,7 +185,7 @@ internal class ImeThemeApplier( "pinyin-composition-editor" -> { view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.toolbarBackground, t), + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), 0, ) } @@ -418,6 +418,12 @@ internal class ImeThemeApplier( view.background = null } view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + // The quick-input row's glyph takes `keybutton_text_2` + // (`fi_toolbar_tip_superscript` glyphs, `input_toolbar_quickinput.xml:44`). + view.tag == "suggestion-icon" -> { + view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + view.background = null + } view.tag == "key-panel-back" -> { view.imageTintList = ColorStateList.valueOf(t.keyText) view.background = statefulRounded( @@ -491,6 +497,12 @@ internal class ImeThemeApplier( tag == "toolbar-tip-label" -> view.setTextColor(t.keyText) + // Our own quick-input rows read at the candidate font in + // `canditem_text`, the colour the reference gives the quick-input + // text (`input_toolbar_quickinput.xml:40`). + tag == "suggestion-text" -> + view.setTextColor(t.candidateText) + tag == "candidate-filter-chip" -> { // 全部 / 单字: the chosen one is the raised surface with plain // text, the other a dimmed label on the group's own background 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 3f5a62d9..3011eb0c 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -113,6 +113,9 @@ internal class ImeTopZone( /** Chips from the system autofill service, hosted in a strip of their own. */ private lateinit var autofillScroll: HorizontalScrollView private val autofillRow = LinearLayout(context) + private lateinit var autofillClose: ImageView + /** True while the strip carries this app's own suggestion rows rather than the system's chips. */ + private var autofillOwn = false /** * The symbol history the symbol board shows in the toolbar @@ -281,6 +284,25 @@ internal class ImeTopZone( autofillScroll, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), ) + // Our own quick-input rows end with the reference's own close key + // (`btn_quickinput_close` over `cls_history_close_button`, the 40-unit box + // the symbol history's close uses; `input_toolbar_quickinput.xml:45-46` for + // the clipboard row, `:31-32` for the message row). The system's autofill + // chips keep the strip's back control instead. + autofillClose = ImageView(context).apply { + tag = "autofill-close" + setImageResource(R.drawable.ic_close) + scaleType = ImageView.ScaleType.CENTER_INSIDE + contentDescription = "关闭建议" + visibility = View.GONE + isClickable = true + isFocusable = true + setOnClickListener { + onFeedback() + dismissAutofillChips() + } + } + toolbarRow.addView(autofillClose, touchTargetParams()) // The reference keeps the icons at a fixed 40 units and leaves the slack // of the strip empty: `btn_hide` stays its own button on the right @@ -532,9 +554,15 @@ internal class ImeTopZone( /** * Shows the autofill [chips] (already inflated by the system) in the toolbar * row, or the normal toolbar again when the list is empty. Associations keep - * priority for as long as they are showing. + * priority for as long as they are showing. [own] marks the rows this app + * built for its own suggestions, which close with the reference's own key. */ - fun setAutofillChips(chips: List, chipWidthPx: Int = 0, chipHeightPx: Int = 0) { + fun setAutofillChips( + chips: List, + chipWidthPx: Int = 0, + chipHeightPx: Int = 0, + own: Boolean = false, + ) { autofillRow.removeAllViews() chips.forEach { chip -> (chip.parent as? ViewGroup)?.removeView(chip) @@ -550,6 +578,7 @@ internal class ImeTopZone( ) } autofillShown = chips.isNotEmpty() + autofillOwn = chips.isNotEmpty() && own autofillScroll.scrollTo(0, 0) toolbarMode = idleToolbarMode() refreshToolbar() @@ -560,6 +589,7 @@ internal class ImeTopZone( onAutofillDismissed?.invoke() autofillRow.removeAllViews() autofillShown = false + autofillOwn = false toolbarMode = idleToolbarMode() refreshToolbar() } @@ -990,7 +1020,9 @@ internal class ImeTopZone( 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 + associationBack.visibility = + if (association || (autofill && !autofillOwn)) View.VISIBLE else View.GONE + autofillClose.visibility = if (autofill && autofillOwn) View.VISIBLE else View.GONE associationScroll.visibility = if (association) View.VISIBLE else View.GONE autofillScroll.visibility = if (autofill) View.VISIBLE else View.GONE recentSymbolStrip.visibility = if (recent) View.VISIBLE else View.GONE diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5632c3b8..da0c56ba 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -221,3 +221,7 @@ Add new entries at the end. - **The pre-edit keeps the reference's own half of the page.** The typing toolbar's composition row holds the pinyin block in a wrapper of at most half the page's width (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:7`) with a 5-unit lead of its own (`padding="5,0,0,0"`, line 7) under the row's 3-unit top inset (line 5); the other half goes to an empty control, the room the reference keeps for the surface beside it (line 18). The string scrolls inside that cap, as the reference's own composition container does (`sliderenable="true" sliderstopslowdown="true"`, `cls_comp_container`, `style.xml:575-577`), and the block carries the container's own press tint while it is edited (`pushedbkcolor="@color/comp_pushedbk"` — black 8% on the light skin, white 12% on the dark one, `values/colors.xml:84`, `values/dark_colors.xml:82`, the alphas our pressed surfaces already use). *Reason:* our pre-edit ran the full width of the row in portrait, and in landscape it stopped at a 160dp cap of our own making with no source, so the line was twice the reference's and the row it shares had no room reserved beside it; the field also gave no press feedback while it was being edited, although the reference tints it. Our 14dp side insets are replaced by the block's own 5-unit lead, which is what the skin declares. The caret still follows the string, so a pinyin line past the cap scrolls to its newest letters. + +- **Our own clipboard and message suggestions wear the reference's quick-input row.** + The reference answers a fresh copy or a text message with its own quick-input toolbar (`layout/input_toolbar_quickinput.xml`): the clipboard row is `[12] text [glyph] [close] [4]`, the text at `cand_font` in `canditem_text`, left-aligned inside a scrolling container weighted 304 (lines 36-48, glyph `quickinput_clipboard_icon1` at `\ue320` in `keybutton_text_2` under `fi_toolbar_tip_superscript`, lines 43-45), and the message row centres its code with the message glyph `\ue363` (lines 20-31). Both end with `cls_history_close_button` — the 40-unit close box the symbol history already uses (`style.xml:1050-1052`), which `btn_quickinput_close` borrows through `cls_btn_more_cand_button_adaptive` (line 46). + *Reason:* our chips were filled key-coloured pills at the body size behind a back chevron, a shape no reference toolbar draws — the reference's own suggestion is flat text at the candidate size with the glyph that names where it came from, and it closes with its own key rather than with a control borrowed from another strip. The system's autofill chips keep that back control, because they are the platform's own views in the same strip, and every row keeps our 48dp touch target. Our own drawables stand in for the skin's icon font: the message bubble for `\ue363` and the clipboard for `\ue320`. From 59b03c7ef9e041ff0b8f5c82fda43ce23b4dc2b5 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:22:09 +0800 Subject: [PATCH 26/94] Point every new citation at its own line The tip, pre-edit and quick-input comments quoted nearby lines for the container, the lead space, the close key and the composition wrapper. Each now names the line the attribute is actually on. Co-Authored-By: Claude Code --- .../llc/slacker/openime/FloatingTipInstrumentedTest.kt | 2 +- .../llc/slacker/openime/PreEditWidthInstrumentedTest.kt | 2 +- .../llc/slacker/openime/QuickInputRowInstrumentedTest.kt | 2 +- .../llc/slacker/openime/keyboard/CompositionPolicy.kt | 4 ++-- .../java/llc/slacker/openime/keyboard/ImeKeyboardView.kt | 8 ++++---- .../java/llc/slacker/openime/keyboard/ImeThemeApplier.kt | 6 +++--- .../main/java/llc/slacker/openime/keyboard/ImeTopZone.kt | 8 ++++---- .../java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt | 8 ++++---- .../java/llc/slacker/openime/CompositionPolicyTest.kt | 2 +- .../test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt | 2 +- docs/DECISIONS.md | 8 ++++---- 11 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt index 1784215c..096a6b4f 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt @@ -15,7 +15,7 @@ import java.lang.reflect.Proxy /** * The floating keyboard meets the user with the reference's tip banner in the - * toolbar row (`input_toolbar_tip.xml:43-50`), whose exit is our own handle tap + * toolbar row (`input_toolbar_tip.xml:44-49`), whose exit is our own handle tap * rather than the reference's drag to the bottom edge. */ @RunWith(AndroidJUnit4::class) diff --git a/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt index d95c9fc6..1b8e223e 100644 --- a/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/PreEditWidthInstrumentedTest.kt @@ -18,7 +18,7 @@ import java.lang.reflect.Proxy /** * The pre-edit keeps the reference's own half of the page - * (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:7`) and scrolls inside + * (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:6`) and scrolls inside * it rather than running the full width of the candidate row. */ @RunWith(AndroidJUnit4::class) diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt index ebf06983..7b080637 100644 --- a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt @@ -21,7 +21,7 @@ import java.lang.reflect.Proxy /** * The clipboard and message suggestions the app builds for itself wear the - * reference's quick-input rows (`input_toolbar_quickinput.xml:23-46`): flat + * reference's quick-input rows (`input_toolbar_quickinput.xml:20-51`): flat * text at the candidate font with the glyph that says where it came from, and * the reference's own close key at the row's end. */ diff --git a/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt index 8152e6e0..b6a8e9f8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CompositionPolicy.kt @@ -8,12 +8,12 @@ package llc.slacker.openime.keyboard internal object CompositionPolicy { /** * `maxwidth="halfMaxWidth"` on the composition's wrapper, the block that - * holds the two labels (`input_toolbar_typing.xml:7`; the row leaves the + * holds the two labels (`input_toolbar_typing.xml:6`; the row leaves the * other half to an empty control, line 18). */ const val MAX_WIDTH_FRACTION = 0.5f - /** The block's own left inset, `padding="5,0,0,0"` (`input_toolbar_typing.xml:7`). */ + /** The block's own left inset, `padding="5,0,0,0"` (`input_toolbar_typing.xml:6`). */ const val LEAD_UNITS = 5 fun maxWidthPx(zoneWidth: Int): Int = (zoneWidth * MAX_WIDTH_FRACTION).toInt() 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 91a07ec9..8d2e6d85 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -162,7 +162,7 @@ open class ImeKeyboardView( /** * The floating tip's own wording. The reference's tip asks for a drag to - * the bottom edge (`input_toolbar_tip.xml:48`); our floating keyboard + * the bottom edge (`input_toolbar_tip.xml:47`); our floating keyboard * docks again on a tap of its handle, so the tip says that instead. */ private const val FLOATING_TIP_TEXT = "轻点顶部把手可恢复默认键盘" @@ -387,9 +387,9 @@ open class ImeKeyboardView( val chips = found.map { suggestion -> // The reference's own quick-input rows are flat: the text at the // candidate font, then the glyph that names where it came from - // (`input_toolbar_quickinput.xml:39-45` for the clipboard row, whose + // (`input_toolbar_quickinput.xml:41-46` for the clipboard row, whose // `quickinput_clipboard_icon1` follows `btn_quickinput_clipboard`; - // `:23-28` for the message row). + // `:24-29` for the message row). val sms = suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL @@ -1582,7 +1582,7 @@ open class ImeKeyboardView( floatingKeyboardController.setEnabled(enabled) topZone.setCompactToolbar(enabled) // The reference meets the floating keyboard with a tip in the toolbar row - // (`input_toolbar_tip.xml:43-50`); ours names our own way back, a tap on + // (`input_toolbar_tip.xml:44-49`); ours names our own way back, a tap on // the handle. Its close key settles the tip for this stay afloat. if (enabled) { if (!wasFloating) floatingTipDismissed = false 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 97cac326..c90ada8f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -204,7 +204,7 @@ internal class ImeThemeApplier( ) } // The close key keeps the reference's square of the pill's height - // (`width="match_height"`, style.xml:1306-1307) and its own + // (`width="match_height"`, style.xml:1307) and its own // translucent faces (`toolbar_tip_close_button_bk`, colors.xml:163). "toolbar-tip-close" -> { view.background = paintedSquareWithinTarget( @@ -419,7 +419,7 @@ internal class ImeThemeApplier( } view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) // The quick-input row's glyph takes `keybutton_text_2` - // (`fi_toolbar_tip_superscript` glyphs, `input_toolbar_quickinput.xml:44`). + // (`fi_toolbar_tip_superscript` glyphs, `input_toolbar_quickinput.xml:44-46`). view.tag == "suggestion-icon" -> { view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.background = null @@ -499,7 +499,7 @@ internal class ImeThemeApplier( // Our own quick-input rows read at the candidate font in // `canditem_text`, the colour the reference gives the quick-input - // text (`input_toolbar_quickinput.xml:40`). + // text (`input_toolbar_quickinput.xml:41-43`). tag == "suggestion-text" -> view.setTextColor(t.candidateText) 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 3011eb0c..db4c1f82 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -128,7 +128,7 @@ internal class ImeTopZone( /** * The tip banner the reference shows in the toolbar row while the floating - * keyboard is up (`input_toolbar_tip.xml:43-50`, see [ToolbarTipPolicy]). + * keyboard is up (`input_toolbar_tip.xml:44-49`, see [ToolbarTipPolicy]). */ private val tipStrip = LinearLayout(context) private val tipLead = View(context) @@ -286,7 +286,7 @@ internal class ImeTopZone( ) // Our own quick-input rows end with the reference's own close key // (`btn_quickinput_close` over `cls_history_close_button`, the 40-unit box - // the symbol history's close uses; `input_toolbar_quickinput.xml:45-46` for + // the symbol history's close uses; `input_toolbar_quickinput.xml:48-49` for // the clipboard row, `:31-32` for the message row). The system's autofill // chips keep the strip's back control instead. autofillClose = ImageView(context).apply { @@ -710,7 +710,7 @@ internal class ImeTopZone( /** * Lays out the reference's tip banner: one pill across the toolbar row, a * lead space, the label at the left of the rest and a round close key - * (`input_toolbar_tip.xml:43-50`, `style.xml:442`, `:1265-1313`). The strip + * (`input_toolbar_tip.xml:44-49`, `style.xml:442`, `:1267-1270`, `:1307-1313`). The strip * is the row's full 48 units and the pill is painted inside it, as * [ToolbarTipPolicy.PILL_UNITS] describes. */ @@ -805,7 +805,7 @@ internal class ImeTopZone( /** * The pre-edit's own cap: half the page (`maxwidth="halfMaxWidth"` on the - * composition's wrapper, `input_toolbar_typing.xml:7`). The row above the + * composition's wrapper, `input_toolbar_typing.xml:6`). The row above the * candidates gives the other half back to the surface beside it, and a long * string scrolls inside the cap the way the reference's own block does * (`sliderenable="true"` on `cls_comp_container`, `style.xml:575-577`). diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt index 298979cd..b5313247 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarTipPolicy.kt @@ -3,13 +3,13 @@ package llc.slacker.openime.keyboard /** * The reference's toolbar tip banner ("引导"): a pill that fills the toolbar * row with a lead space, a left-aligned label and a round close key - * (`layout/input_toolbar_tip.xml:43-50`). + * (`layout/input_toolbar_tip.xml:44-49`). */ internal object ToolbarTipPolicy { /** The container's own horizontal padding, 3.333% of the row (`style.xml:442`). */ const val CONTAINER_PAD_FRACTION = 0.03333f - /** `cls_toolbar_tip_leading_space_adaptive` width_weight (`style.xml:1278`). */ + /** `cls_toolbar_tip_leading_space_adaptive` width_weight (`style.xml:1277`). */ const val LEAD_WEIGHT = 0.03571f /** `cls_toolbar_tip_label_adaptive` padding_decimal (`style.xml:1265`). */ @@ -20,10 +20,10 @@ internal object ToolbarTipPolicy { * padding, 0.17857 + 0.10714 of the row (`style.xml:442`), which leaves * 0.71429 of a 48-unit row — the 34dp our painted toolbar controls use. The * close key is a square of that height (`width="match_height"`, - * `style.xml:1306-1307`). + * `style.xml:1307`). */ const val PILL_UNITS = 34 - /** `bkround="999"` on the container and on the close key (`style.xml:1274`, `:1310`). */ + /** `bkround="999"` on the container and on the close key (`style.xml:1275`, `:1310`). */ const val RADIUS_UNITS = 999 } diff --git a/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt index 489137ea..4af89ae8 100644 --- a/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/CompositionPolicyTest.kt @@ -8,7 +8,7 @@ class CompositionPolicyTest { /** * The pre-edit block is at most half the page: `maxwidth="halfMaxWidth"` on * the composition's wrapper, whose sibling control takes the other half - * (`input_toolbar_typing.xml:7,18`). + * (`input_toolbar_typing.xml:6,18`). */ @Test fun thePreEditKeepsHalfThePage() { diff --git a/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt index 95a4c8df..7d0b45fe 100644 --- a/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ToolbarTipPolicyTest.kt @@ -9,7 +9,7 @@ class ToolbarTipPolicyTest { /** * The banner's own fractions of the row width: the container padding * (`cls_toolbar_tip_adaptive`, `style.xml:442`), the lead space - * (`style.xml:1278`) and the label's own inset (`style.xml:1265`). + * (`style.xml:1277`) and the label's own inset (`style.xml:1265`). */ @Test fun theBannerKeepsTheReferencesOwnRuler() { diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index da0c56ba..63077437 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -215,13 +215,13 @@ Add new entries at the end. *Reason:* our tiles were bare columns: a 56-unit icon chip carried the fill and the name sat outside it on the page background, so the reference's card — the whole cell — was missing, and our names took the plain key text where the reference dims them to `keybutton_text_3`. Our own card colour stays (`toolCardBackground`, opaque #FFFFFF light and #303032 dark) where the reference's is white at 88% and white at 12%, and the glyph's share of the tile is ours (52%) because the skin's `ficon_height="0.7647"` measures its own icon font box. Two of the reference's own rules do not carry over, both stated here: a tile never shrinks under our 48dp touch target — a short keyboard grows the rows back and the page scrolls instead of hiding an entry — and an entry list longer than a page takes a third row rather than the reference's paging, so its page dots (`cls_settings_tab_point_container`, `style.xml:670-677`) are not built. - **The floating keyboard carries the reference's own tip banner.** - The reference meets the floating keyboard with a tip row: `input_toolbar_tip.xml:43-50` is one `cls_toolbar_tip_container` pill — the full row's width, `bkcolor="@color/keybutton_bk"`, `bkround="999"` (`style.xml:1272-1274`) — with its own 3.333% horizontal padding (`cls_toolbar_tip_adaptive`, `style.xml:442`), holding a 3.571% lead space (`cls_toolbar_tip_leading_space_adaptive`, `style.xml:1278`), a left-aligned ellipsized label in `keybutton_text` with its own 1.2% inset (`cls_toolbar_tip_label` over `cls_toolbar_tip_label_adaptive`, `style.xml:1265-1270`), and one round close key as wide as it is tall, filled `toolbar_tip_close_button_bk` and pressed `toolbar_tip_close_button_pushedbk` — black 4% and 14% on the light skin, white 7% and 17% on the dark one (`cls_toolbar_tip_close_button`, `style.xml:1305-1313`; `values/colors.xml:163-164`, `values/dark_colors.xml:157-158`). The container's two vertical fractions (0.17857 + 0.10714 of the row, `style.xml:442`) leave 0.71429 of it, the 34dp our painted toolbar controls already use. - *Reason:* our floating keyboard's only way back is a tap on its handle, and that instruction lived in the handle's accessibility label alone, so a sighted user had no way to learn it. The tip is the reference's own surface for exactly that message, so the row is ported with our own truthful wording (轻点顶部把手可恢复默认键盘) in place of the reference's drag to the bottom edge (`input_toolbar_tip.xml:48`), because our handle docks the keyboard again on a tap. The close key settles the tip for that stay afloat and the next one shows it again, the rule the symbol history uses; 收起 keeps its place at the row's right end, and the tip gives the row back to candidates, associations and the symbol history whenever they have something to say. + The reference meets the floating keyboard with a tip row: `input_toolbar_tip.xml:44-49` is one `cls_toolbar_tip_container` pill — the full row's width, `bkcolor="@color/keybutton_bk"`, `bkround="999"` (`style.xml:1273-1275`) — with its own 3.333% horizontal padding (`cls_toolbar_tip_adaptive`, `style.xml:442`), holding a 3.571% lead space (`cls_toolbar_tip_leading_space_adaptive`, `style.xml:1277`), a left-aligned ellipsized label in `keybutton_text` with its own 1.2% inset (`cls_toolbar_tip_label` over `cls_toolbar_tip_label_adaptive`, `style.xml:1265-1270`), and one round close key as wide as it is tall, filled `toolbar_tip_close_button_bk` and pressed `toolbar_tip_close_button_pushedbk` — black 4% and 14% on the light skin, white 7% and 17% on the dark one (`cls_toolbar_tip_close_button`, `style.xml:1307-1313`; `values/colors.xml:163-164`, `values/dark_colors.xml:157-158`). The container's two vertical fractions (0.17857 + 0.10714 of the row, `style.xml:442`) leave 0.71429 of it, the 34dp our painted toolbar controls already use. + *Reason:* our floating keyboard's only way back is a tap on its handle, and that instruction lived in the handle's accessibility label alone, so a sighted user had no way to learn it. The tip is the reference's own surface for exactly that message, so the row is ported with our own truthful wording (轻点顶部把手可恢复默认键盘) in place of the reference's drag to the bottom edge (`input_toolbar_tip.xml:47`), because our handle docks the keyboard again on a tap. The close key settles the tip for that stay afloat and the next one shows it again, the rule the symbol history uses; 收起 keeps its place at the row's right end, and the tip gives the row back to candidates, associations and the symbol history whenever they have something to say. - **The pre-edit keeps the reference's own half of the page.** - The typing toolbar's composition row holds the pinyin block in a wrapper of at most half the page's width (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:7`) with a 5-unit lead of its own (`padding="5,0,0,0"`, line 7) under the row's 3-unit top inset (line 5); the other half goes to an empty control, the room the reference keeps for the surface beside it (line 18). The string scrolls inside that cap, as the reference's own composition container does (`sliderenable="true" sliderstopslowdown="true"`, `cls_comp_container`, `style.xml:575-577`), and the block carries the container's own press tint while it is edited (`pushedbkcolor="@color/comp_pushedbk"` — black 8% on the light skin, white 12% on the dark one, `values/colors.xml:84`, `values/dark_colors.xml:82`, the alphas our pressed surfaces already use). + The typing toolbar's composition row holds the pinyin block in a wrapper of at most half the page's width (`maxwidth="halfMaxWidth"`, `input_toolbar_typing.xml:6`) with a 5-unit lead of its own (`padding="5,0,0,0"`, line 6) under the row's 3-unit top inset (line 5); the other half goes to an empty control, the room the reference keeps for the surface beside it (line 18). The string scrolls inside that cap, as the reference's own composition container does (`sliderenable="true" sliderstopslowdown="true"`, `cls_comp_container`, `style.xml:575-577`), and the block carries the container's own press tint while it is edited (`pushedbkcolor="@color/comp_pushedbk"` — black 8% on the light skin, white 12% on the dark one, `values/colors.xml:84`, `values/dark_colors.xml:82`, the alphas our pressed surfaces already use). *Reason:* our pre-edit ran the full width of the row in portrait, and in landscape it stopped at a 160dp cap of our own making with no source, so the line was twice the reference's and the row it shares had no room reserved beside it; the field also gave no press feedback while it was being edited, although the reference tints it. Our 14dp side insets are replaced by the block's own 5-unit lead, which is what the skin declares. The caret still follows the string, so a pinyin line past the cap scrolls to its newest letters. - **Our own clipboard and message suggestions wear the reference's quick-input row.** - The reference answers a fresh copy or a text message with its own quick-input toolbar (`layout/input_toolbar_quickinput.xml`): the clipboard row is `[12] text [glyph] [close] [4]`, the text at `cand_font` in `canditem_text`, left-aligned inside a scrolling container weighted 304 (lines 36-48, glyph `quickinput_clipboard_icon1` at `\ue320` in `keybutton_text_2` under `fi_toolbar_tip_superscript`, lines 43-45), and the message row centres its code with the message glyph `\ue363` (lines 20-31). Both end with `cls_history_close_button` — the 40-unit close box the symbol history already uses (`style.xml:1050-1052`), which `btn_quickinput_close` borrows through `cls_btn_more_cand_button_adaptive` (line 46). + The reference answers a fresh copy or a text message with its own quick-input toolbar (`layout/input_toolbar_quickinput.xml`): the clipboard row is `[12] text [glyph] [close] [4]`, the text at `cand_font` in `canditem_text`, left-aligned inside a scrolling container weighted 304 (lines 36-51; the text at lines 41-43, then `quickinput_clipboard_icon1` at `\ue320` in `keybutton_text_2` under `fi_toolbar_tip_superscript`, lines 44-46), and the message row centres its code with the message glyph `\ue363` (lines 20-32). Both end with `cls_history_close_button` — the 40-unit close box the symbol history already uses (`style.xml:1050-1052`), which `btn_quickinput_close` borrows through `cls_btn_more_cand_button_adaptive` (lines 48-49). *Reason:* our chips were filled key-coloured pills at the body size behind a back chevron, a shape no reference toolbar draws — the reference's own suggestion is flat text at the candidate size with the glyph that names where it came from, and it closes with its own key rather than with a control borrowed from another strip. The system's autofill chips keep that back control, because they are the platform's own views in the same strip, and every row keeps our 48dp touch target. Our own drawables stand in for the skin's icon font: the message bubble for `\ue363` and the clipboard for `\ue320`. From 111604f89ebe6fdfdc38caaba950fc707c053fc9 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:27:50 +0800 Subject: [PATCH 27/94] Ride the suggestion's origin glyph above its text The reference's quick-input row measures both parts against the row (`input_toolbar_quickinput.xml:42,44-45`): the text box is inset 0.055/0.111 of it, and the glyph that names where the text came from is a superscript at the row's top edge, `fi_toolbar_tip_superscript`'s 30 units against `cand_font`'s 56 (`style.xml:138,83`). Our first pass drew the glyph as a fixed 18dp icon centred on the row, which reads as a second key beside the text rather than as the mark of its origin. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- .../openime/QuickInputRowInstrumentedTest.kt | 11 +++++++ .../openime/keyboard/ImeKeyboardView.kt | 21 ++++++++++-- .../openime/keyboard/QuickInputRowPolicy.kt | 33 +++++++++++++++++++ .../openime/QuickInputRowPolicyTest.kt | 33 +++++++++++++++++++ docs/DECISIONS.md | 4 +++ 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac22b10..9ce84bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小图标(消息/剪贴板),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 +- 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小标(消息/剪贴板;参考皮肤里这个标是上标,约候选字号的 53%,贴着这一行的上沿,文字框也按参考的比例上下留白),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 - 拼音预编辑框宽度改为参考皮肤的一半页宽(参考皮肤的这个输入块最多占半页,右半留给旁边的控件),超出部分在框内左右滚动;左侧内边距改为参考的 5 单位;编辑时按参考皮肤变暗(浅色 8% 黑、深色 12% 白)。原先竖屏铺满整行、横屏用我们自己定的 160dp 上限,且编辑时没有按下反馈。 - 悬浮键盘时工具栏改为显示一条提示(参考皮肤在悬浮键盘上显示的就是这条提示):圆角药丸底、左边是说明文字「轻点顶部把手可恢复默认键盘」、右边一个圆形关闭键;按关闭键本次悬浮期间不再出现,收起重开悬浮键盘会再次显示。 - 工具页与切换键盘页改用参考皮肤的方格磁贴:整格就是一块圆角卡片(圆角 14,按下变深,当前键盘那一格填主题色、图标与文字变白),四列两行、同页磁贴等高,磁贴区域占页宽 88.9% 并留参考皮肤自己的四周留白;原先是 56dp 的图标小方块带底色、名称在方块外面。 diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt index 7b080637..31525d19 100644 --- a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.QuickInputRowPolicy import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme import llc.slacker.openime.theme.ImeTypographyTokens @@ -94,6 +95,16 @@ class QuickInputRowInstrumentedTest { 0.5f, ) assertNotNull("and the glyph that says where it came from", keyboard.findViewWithTag("suggestion-icon")) + val badge = keyboard.findViewWithTag("suggestion-icon")!! + assertEquals( + "The glyph rides at `fi_toolbar_tip_superscript` against the candidate font", + (label.textSize * QuickInputRowPolicy.BADGE_FONT_RATIO + 0.5f).toInt().toLong(), + badge.height.toLong(), + ) + assertTrue( + "as a superscript above the text, not a centred icon", + badge.top - row.top < row.height / 2, + ) val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) assertEquals( "`canditem_text` over the row (`input_toolbar_quickinput.xml:40`)", 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 8d2e6d85..071752a1 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -391,6 +391,10 @@ open class ImeKeyboardView( // `quickinput_clipboard_icon1` follows `btn_quickinput_clipboard`; // `:24-29` for the message row). val sms = suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE + // The row's own geometry: the reference insets the text box + // (`input_toolbar_quickinput.xml:42`) and rides the glyph above its + // right shoulder as a superscript (`:44-45`). + val rowHeightPx = dp(36) val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL @@ -415,8 +419,18 @@ open class ImeKeyboardView( LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT, - ), + ).apply { + topMargin = (rowHeightPx * QuickInputRowPolicy.TEXT_TOP_FRACTION).toInt() + bottomMargin = (rowHeightPx * QuickInputRowPolicy.TEXT_BOTTOM_FRACTION).toInt() + }, ) + // The badge is a share of the text it marks, so it is measured in + // the text's own unit rather than the keyboard's dp ruler. + val badgePx = kotlin.math.round( + ImeTypographyTokens.CANDIDATE_SP * + QuickInputRowPolicy.BADGE_FONT_RATIO * + resources.displayMetrics.scaledDensity, + ).toInt() addView( ImageView(context).apply { tag = "suggestion-icon" @@ -424,7 +438,10 @@ open class ImeKeyboardView( scaleType = ImageView.ScaleType.CENTER_INSIDE importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, - LinearLayout.LayoutParams(dp(18), LinearLayout.LayoutParams.MATCH_PARENT), + LinearLayout.LayoutParams(badgePx, badgePx).apply { + gravity = Gravity.TOP + topMargin = (rowHeightPx * QuickInputRowPolicy.BADGE_TOP_FRACTION).toInt() + }, ) setOnClickListener { feedback() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt new file mode 100644 index 00000000..32d46d2a --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt @@ -0,0 +1,33 @@ +package llc.slacker.openime.keyboard + +/** + * The reference's quick-input rows for the app's own suggestions: the text at + * the candidate font with the origin glyph riding above its right shoulder as a + * superscript (`layout/input_toolbar_quickinput.xml:41-46`, the clipboard row). + */ +internal object QuickInputRowPolicy { + /** + * The text's own box inside the row: `margin_decimal="0,0.055,0,0.111"` on + * `btn_quickinput_clipboard` (`input_toolbar_quickinput.xml:42`). The + * smaller top share lifts the line slightly above the row's centre. + */ + const val TEXT_TOP_FRACTION = 0.055f + const val TEXT_BOTTOM_FRACTION = 0.111f + + /** + * The badge's height against the candidate font: `fi_toolbar_tip_superscript` + * is 30 units where `cand_font` is 56 (`style.xml:138`, `:83`), so the glyph + * is a little over half the text it marks. + */ + const val BADGE_FONT_RATIO = 30f / 56f + + /** + * The badge keeps the row's top edge with the reference's own lede: + * `margin_decimal="0,0.08,0,0.111"` with `align="top"` + * (`input_toolbar_quickinput.xml:44-45`). + */ + const val BADGE_TOP_FRACTION = 0.08f + + /** The badge's diameter in dp for a candidate line of [candidateSp]. */ + fun badgeDiameterDp(candidateSp: Float): Float = candidateSp * BADGE_FONT_RATIO +} diff --git a/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt b/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt new file mode 100644 index 00000000..7efaa0ec --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt @@ -0,0 +1,33 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.QuickInputRowPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuickInputRowPolicyTest { + /** + * The row's own numbers: the text box's vertical margins and the badge that + * rides above its right shoulder (`input_toolbar_quickinput.xml:42,44-45`). + */ + @Test + fun theRowKeepsTheReferencesOwnRuler() { + assertEquals(0.055f, QuickInputRowPolicy.TEXT_TOP_FRACTION, 0.00001f) + assertEquals(0.111f, QuickInputRowPolicy.TEXT_BOTTOM_FRACTION, 0.00001f) + assertEquals(0.08f, QuickInputRowPolicy.BADGE_TOP_FRACTION, 0.00001f) + } + + /** + * The badge is the candidate font scaled by `fi_toolbar_tip_superscript`'s + * 30 units against `cand_font`'s 56. + */ + @Test + fun theBadgeIsASuperscriptOfTheCandidateFont() { + assertEquals(30f / 56f, QuickInputRowPolicy.BADGE_FONT_RATIO, 0.00001f) + assertEquals(18f * 30f / 56f, QuickInputRowPolicy.badgeDiameterDp(18f), 0.001f) + assertTrue( + "The badge stays well under the text it marks", + QuickInputRowPolicy.BADGE_FONT_RATIO < 0.6f, + ) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 63077437..1574abf7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -225,3 +225,7 @@ Add new entries at the end. - **Our own clipboard and message suggestions wear the reference's quick-input row.** The reference answers a fresh copy or a text message with its own quick-input toolbar (`layout/input_toolbar_quickinput.xml`): the clipboard row is `[12] text [glyph] [close] [4]`, the text at `cand_font` in `canditem_text`, left-aligned inside a scrolling container weighted 304 (lines 36-51; the text at lines 41-43, then `quickinput_clipboard_icon1` at `\ue320` in `keybutton_text_2` under `fi_toolbar_tip_superscript`, lines 44-46), and the message row centres its code with the message glyph `\ue363` (lines 20-32). Both end with `cls_history_close_button` — the 40-unit close box the symbol history already uses (`style.xml:1050-1052`), which `btn_quickinput_close` borrows through `cls_btn_more_cand_button_adaptive` (lines 48-49). *Reason:* our chips were filled key-coloured pills at the body size behind a back chevron, a shape no reference toolbar draws — the reference's own suggestion is flat text at the candidate size with the glyph that names where it came from, and it closes with its own key rather than with a control borrowed from another strip. The system's autofill chips keep that back control, because they are the platform's own views in the same strip, and every row keeps our 48dp touch target. Our own drawables stand in for the skin's icon font: the message bubble for `\ue363` and the clipboard for `\ue320`. + +- **The quick-input row's origin glyph rides above the text as a superscript.** + The reference's own suggestion row measures both of its parts against the row: the text box carries `margin_decimal="0,0.055,0,0.111"` on `btn_quickinput_clipboard` (`input_toolbar_quickinput.xml:42`), and the glyph that follows it takes `margin_decimal="0,0.08,0,0.111"` with `align="top"` under `fi_toolbar_tip_superscript` (`input_toolbar_quickinput.xml:44-45`) — a font of 30 units where `cand_font` is 56 (`style.xml:138`, `:83`), so the badge is a little over half the line it marks and keeps the row's top edge instead of its centre. + *Reason:* our first pass drew the glyph as a fixed 18dp icon centred on the row, which reads as a second key beside the text rather than as the mark of where the text came from; the reference's own proportion ties it to the candidate font, so the size is a share of the text rather than a number of its own. The reference's 1% side padding on the glyph is not ported: it is a share of the row's own width in the skin's decimal mode, and a badge sized against the text has no such box to pad. From dac543536d8317b52f18a5348976e99a1990e9ce Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:32:07 +0800 Subject: [PATCH 28/94] Point two candidate-strip citations at the lines they quote The strip's container is `input_toolbar_typing.xml:23-32`, not 16-30, in the keyboard's own comment and in DECISIONS.md. Co-Authored-By: Claude Code --- app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt | 2 +- docs/DECISIONS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 db4c1f82..671aea89 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -45,7 +45,7 @@ private const val CANDIDATE_ROW_DP = 42 /** * The reference's candidate strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` - * (`input_toolbar_typing.xml:16-30`, weights from `style.xml:530-534`, 506 and 468): + * (`input_toolbar_typing.xml:23-32`, weights from `style.xml:530-534`, 506 and 468): * a small inset at each end, a hairline split before the expand key and a 3-unit * gap ahead of it. */ diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1574abf7..c01e18e2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -179,7 +179,7 @@ Add new entries at the end. *Reason:* our pre-edit was one uniform accent-coloured run at 14sp, so it could not show which syllables were settled. Only a plain prefix of the shown string counts as fixed — a divider the decoder inserted is not a choice the user made — so the colour follows the reading the user tapped in the nine-key rail instead of every boundary the engine guesses. The 26-key path has no user-fixed prefix yet, so it stays in one colour until it does. - **The candidate strip carries the reference's own insets, split and expand key.** - The strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` (`layout/input_toolbar_typing.xml:16-30`): a small inset at each end, a hairline split immediately after the list, a 3-unit gap, then the expand key. The list's own weight is `cls_cand_list_weight_no_speech` 308 (`style.xml:530-534`), the split is filled with `@color/canditem_split` (`cls_candidate_split`, `style.xml:504-506`, the same colour the list's hairlines use), and the expand key is `cls_bk_toolbar_button`: transparent until pressed, pill radius, press tint `canditem_pushedbk` (`style.xml:231-235`, `cls_btn_morecand` `style.xml:482-488`). + The strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` (`layout/input_toolbar_typing.xml:23-32`): a small inset at each end, a hairline split immediately after the list, a 3-unit gap, then the expand key. The list's own weight is `cls_cand_list_weight_no_speech` 308 (`style.xml:530-534`), the split is filled with `@color/canditem_split` (`cls_candidate_split`, `style.xml:504-506`, the same colour the list's hairlines use), and the expand key is `cls_bk_toolbar_button`: transparent until pressed, pill radius, press tint `canditem_pushedbk` (`style.xml:231-235`, `cls_btn_morecand` `style.xml:482-488`). *Reason:* openIME had a 6dp pad on one side and nothing on the other, no split, and a square 8dp-radius press tint on the expand key. The key keeps its 48dp touch target — our own accessibility rule, which the reference's 40-unit box does not meet — so only its shape and spacing follow the skin. - **Idle toolbar keys are the reference's pill buttons, and the strip keeps its slack.** From c2ba905771a8ca3c9d67c87ec030cec4e89aa31a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:33:25 +0800 Subject: [PATCH 29/94] Measure the origin glyph from the line it marks The theme pass rescales every text view it meets by the reference scale, so a badge sized from the design token alone drifted from its own label on a device whose scale is not 1. The badge now takes the label's measured size at layout, which also drops the policy helper the view no longer needs. Co-Authored-By: Claude Code --- .../openime/keyboard/ImeKeyboardView.kt | 54 +++++++++++-------- .../openime/keyboard/QuickInputRowPolicy.kt | 3 -- .../openime/QuickInputRowPolicyTest.kt | 2 +- 3 files changed, 32 insertions(+), 27 deletions(-) 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 071752a1..e2734ac6 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -395,6 +395,21 @@ open class ImeKeyboardView( // (`input_toolbar_quickinput.xml:42`) and rides the glyph above its // right shoulder as a superscript (`:44-45`). val rowHeightPx = dp(36) + val label = TextView(context).apply { + text = suggestion.label + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + textSize = ImeTypographyTokens.CANDIDATE_SP + gravity = Gravity.CENTER_VERTICAL + includeFontPadding = false + tag = "suggestion-text" + } + val badge = ImageView(context).apply { + tag = "suggestion-icon" + setImageResource(if (sms) R.drawable.ic_bubble else R.drawable.ic_clipboard) + scaleType = ImageView.ScaleType.CENTER_INSIDE + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL @@ -407,15 +422,7 @@ open class ImeKeyboardView( "刚复制的内容,点击输入" } addView( - TextView(context).apply { - text = suggestion.label - maxLines = 1 - ellipsize = android.text.TextUtils.TruncateAt.END - textSize = ImeTypographyTokens.CANDIDATE_SP - gravity = Gravity.CENTER_VERTICAL - includeFontPadding = false - tag = "suggestion-text" - }, + label, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT, @@ -424,21 +431,9 @@ open class ImeKeyboardView( bottomMargin = (rowHeightPx * QuickInputRowPolicy.TEXT_BOTTOM_FRACTION).toInt() }, ) - // The badge is a share of the text it marks, so it is measured in - // the text's own unit rather than the keyboard's dp ruler. - val badgePx = kotlin.math.round( - ImeTypographyTokens.CANDIDATE_SP * - QuickInputRowPolicy.BADGE_FONT_RATIO * - resources.displayMetrics.scaledDensity, - ).toInt() addView( - ImageView(context).apply { - tag = "suggestion-icon" - setImageResource(if (sms) R.drawable.ic_bubble else R.drawable.ic_clipboard) - scaleType = ImageView.ScaleType.CENTER_INSIDE - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, - LinearLayout.LayoutParams(badgePx, badgePx).apply { + badge, + LinearLayout.LayoutParams(0, 0).apply { gravity = Gravity.TOP topMargin = (rowHeightPx * QuickInputRowPolicy.BADGE_TOP_FRACTION).toInt() }, @@ -455,6 +450,19 @@ open class ImeKeyboardView( // The row is built after the last theme pass, so it takes the palette // itself, the way the nine-key rail does. applyThemeToSubtree(row) + // The badge is a share of the line it marks, and that line's size is + // the theme pass's (it rescales every text view it meets), so the + // badge is measured from the label rather than from the token. + row.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + val size = kotlin.math.round(label.textSize * QuickInputRowPolicy.BADGE_FONT_RATIO).toInt() + val params = badge.layoutParams as? LinearLayout.LayoutParams + ?: return@addOnLayoutChangeListener + if (params.width != size || params.height != size) { + params.width = size + params.height = size + badge.layoutParams = params + } + } row } topZone.onAutofillDismissed = { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt index 32d46d2a..73c3d738 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/QuickInputRowPolicy.kt @@ -27,7 +27,4 @@ internal object QuickInputRowPolicy { * (`input_toolbar_quickinput.xml:44-45`). */ const val BADGE_TOP_FRACTION = 0.08f - - /** The badge's diameter in dp for a candidate line of [candidateSp]. */ - fun badgeDiameterDp(candidateSp: Float): Float = candidateSp * BADGE_FONT_RATIO } diff --git a/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt b/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt index 7efaa0ec..ac50627d 100644 --- a/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/QuickInputRowPolicyTest.kt @@ -24,7 +24,7 @@ class QuickInputRowPolicyTest { @Test fun theBadgeIsASuperscriptOfTheCandidateFont() { assertEquals(30f / 56f, QuickInputRowPolicy.BADGE_FONT_RATIO, 0.00001f) - assertEquals(18f * 30f / 56f, QuickInputRowPolicy.badgeDiameterDp(18f), 0.001f) + assertEquals(9.64f, 18f * QuickInputRowPolicy.BADGE_FONT_RATIO, 0.01f) assertTrue( "The badge stays well under the text it marks", QuickInputRowPolicy.BADGE_FONT_RATIO < 0.6f, From 167d9f773af10cda74cc37d8fdcf5a61ef6002fb Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:21:36 +0800 Subject: [PATCH 30/94] Settle the sixteen instrumented failures the alignment left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed the same sixteen tests on every API level, in four groups. The tip banner (6b6a199) took the floating toolbar row: the quick-phrase and long-press-drag tests read a row that no longer carries icons, and the banner's own width tests measured before the first layout pass. They now settle the banner as the user does and wait for the row to place itself. The quick-input rows belong to a real editor session, which a keyboard view built by hand has none of, so they now declare the editor options they are drawn for through the new `editorOptionsOverride` seam. The symbol history tests cleared the store inside their own harness, wiping the history they had just arranged; the `@After` cleanup already empties it between tests, so the record stands. The rest follow the reference rather than our earlier guess: - English's merged key commits "." and corners "," , as its layout draws it (`input_kbd_english26.xml:154-161`), and its up swipe types the comma. - The launcher grid packs four tiles per row and leaves the last row short (`input_settings.xml:13-45`); the cells keep the page's one column width. - The tile radius goes through the page's own reference scale, as every other reference unit does. - The handwriting tile follows the strokes table only local builds ship. - The bottom row is measured with the reference's own key widths (123 70.6, merged 37.6, both symbol keys 35.5, space 133.6, 中/英 38.6, 确定 74.6, `style.xml:960-978`), which put a hair more weight on the left than the right, so the space bar sits just left of the centre line. - The font test counts only keys the user can see, and names the key it reports. The merged and symbol keys of the 26-key bottom row now shrink their mark rather than ellipsizing it, as the reference gives them their own fonts for (`text_comma_cn` 72 units against `key_letter` 57, `style.xml:127`, `:4`). Co-Authored-By: Claude Code --- .../DisplayEnvironmentInstrumentedTest.kt | 5 +- .../FloatingGameKeyboardInstrumentedTest.kt | 21 ++++++- .../openime/FloatingTipInstrumentedTest.kt | 24 +++++--- .../KeyboardBottomRowInstrumentedTest.kt | 59 +++++++++++++++---- .../openime/LauncherTileInstrumentedTest.kt | 39 +++++++++--- .../openime/QuickInputRowInstrumentedTest.kt | 10 ++++ ...aceCursorAndLetterHintsInstrumentedTest.kt | 8 ++- .../openime/SymbolHistoryInstrumentedTest.kt | 3 +- .../openime/keyboard/ImeKeyboardView.kt | 10 +++- .../keyboard/Pinyin26KeyboardRenderer.kt | 5 ++ .../llc/slacker/openime/widget/ImeKeyView.kt | 23 +++++++- 11 files changed, 173 insertions(+), 34 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt index 35f2e4f2..42b06668 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -161,8 +161,11 @@ class DisplayEnvironmentInstrumentedTest { ) { keyboard -> val clipped = allViews(keyboard) .filterIsInstance() + // Only what the user can see counts: a key inside a panel + // that is closed keeps whatever layout it was last given. + .filter { it.isShown } .filter { !it.mainLabelFits() } - .map { "'${it.currentMainText}'" } + .map { "${it.tag}'${it.currentMainText}'" } .toList() assertEquals("labels cut off at font scale $fontScale in $mode", emptyList(), clipped) } diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt index 18c2212a..45b435c0 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -78,6 +78,18 @@ class FloatingGameKeyboardInstrumentedTest { private fun View.visibleByTag(tag: String): Boolean = findViewWithTag(tag)?.visibility == View.VISIBLE + /** + * The floating keyboard meets the user with its one-time banner across the + * toolbar row (`input_toolbar_tip.xml:44-49`). A test that reads the toolbar + * or presses its icons settles the banner first, as the user does. + */ + private fun settleTip(harness: DirectActivityHarness, keyboard: ImeKeyboardView) { + harness.awaitMain { + keyboard.findViewWithTag("toolbar-tip-close")?.performClick() + true + } + } + /** The icons live in the toolbar's own scrolling strip, so search the row's subtree. */ private fun View.toolbarIconByDescription(description: String): View? { val row = findViewWithTag("toolbar-row") ?: return null @@ -125,6 +137,10 @@ class FloatingGameKeyboardInstrumentedTest { assertFalse("Docked toolbar has no quick-phrase shortcut", keyboard.visibleByTag("quick-phrase-toolbar")) keyboard.setFloatingWindowMode(true) + // The floating keyboard meets the user with its tip banner, which + // takes the toolbar row (`input_toolbar_tip.xml:44-49`); settling it + // gives the row back to the icons. + keyboard.findViewWithTag("toolbar-tip-close")?.performClick() assertTrue("Floating toolbar exposes quick phrases", keyboard.visibleByTag("quick-phrase-toolbar")) assertFalse(keyboard.visibleByTag("clipboard-toolbar")) assertFalse(keyboard.visibleByTag("undo-toolbar")) @@ -167,12 +183,15 @@ class FloatingGameKeyboardInstrumentedTest { @Test fun longPressOnTopZoneDragsTheFloatingKeyboardWithoutDockingOrClicking() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + settleTip(harness, keyboard) val topZone = harness.awaitMain { keyboard.findViewWithTag("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("quick-phrase-toolbar") - val loc = IntArray(2).also { icon.getLocationInWindow(it) } + // The row is laid out once the banner has given it back. + if (icon?.width == 0) return@awaitMain null + 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) } diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt index 096a6b4f..eafc0612 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt @@ -25,7 +25,7 @@ class FloatingTipInstrumentedTest { arrayOf(ImeKeyboardView.Listener::class.java), ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener - private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> harness.launch() val keyboard = harness.awaitMain { activity -> @@ -35,7 +35,7 @@ class FloatingTipInstrumentedTest { } try { harness.awaitMain { if (keyboard.width > 0) true else null } - harness.awaitMain { test(keyboard); true } + harness.awaitMain { test(harness, keyboard); true } } finally { harness.awaitMain { keyboard.shutdown() @@ -48,11 +48,17 @@ class FloatingTipInstrumentedTest { private fun tip(keyboard: ImeKeyboardView): View? = keyboard.findViewWithTag("toolbar-tip") + /** Floats the keyboard and lets the banner take its place in the row. */ + private fun showFloatingTip(harness: DirectActivityHarness, keyboard: ImeKeyboardView) { + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + harness.awaitMain { if ((tip(keyboard)?.width ?: 0) > 0) true else null } + } + @Test fun theFloatingKeyboardOpensWithTheTipBanner() { - withKeyboard { keyboard -> + withKeyboard { harness, keyboard -> assertEquals(View.GONE, tip(keyboard)?.visibility) - keyboard.setFloatingWindowMode(true) + showFloatingTip(harness, keyboard) assertEquals(View.VISIBLE, tip(keyboard)?.visibility) val label = keyboard.findViewWithTag("toolbar-tip-label") assertNotNull("The banner says what the handle does", label) @@ -66,7 +72,7 @@ class FloatingTipInstrumentedTest { @Test fun theCloseKeySettlesTheTipForTheStayAfloat() { - withKeyboard { keyboard -> + withKeyboard { _, keyboard -> keyboard.setFloatingWindowMode(true) keyboard.findViewWithTag("toolbar-tip-close")!!.performClick() assertEquals(View.GONE, tip(keyboard)?.visibility) @@ -82,8 +88,8 @@ class FloatingTipInstrumentedTest { @Test fun theTipTakesTheRowButNotTheCollectKey() { - withKeyboard { keyboard -> - keyboard.setFloatingWindowMode(true) + withKeyboard { harness, keyboard -> + showFloatingTip(harness, keyboard) assertEquals(View.VISIBLE, tip(keyboard)?.visibility) val hide = keyboard.findViewWithTag("keyboard-hide") assertNotNull("The collect-the-keyboard key stays reachable", hide) @@ -97,8 +103,8 @@ class FloatingTipInstrumentedTest { @Test fun theBannerKeepsTheReferencesOwnProportions() { - withKeyboard { keyboard -> - keyboard.setFloatingWindowMode(true) + withKeyboard { harness, keyboard -> + showFloatingTip(harness, keyboard) val strip = tip(keyboard)!! val row = keyboard.findViewWithTag("toolbar-row")!! val density = keyboard.resources.displayMetrics.density diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt index ab355eec..61ae036d 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt @@ -6,6 +6,7 @@ import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.ProductionKeyPolicy import llc.slacker.openime.voice.VoiceModelLifecycleState import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -63,20 +64,49 @@ class KeyboardBottomRowInstrumentedTest { private fun settled(harness: DirectActivityHarness, keyboard: ImeKeyboardView, tag: String): View = harness.awaitMain { keyboard.findViewWithTag(tag)?.takeIf { it.width > 0 } } + /** + * The bottom row carries the reference's own key widths — 123 70.6, the + * merged key 37.6, both symbol keys 35.5, the space bar 133.6, 中/英 38.6, + * 确定 74.6 (`style.xml:960-978`) — in the order its layout puts them + * (`input_kbd_pinyin26.xml:134-158`). The reference's two sides are not + * equal, so the space bar sits where its own numbers put it, a hair left of + * the keyboard's centre line rather than exactly on it. + */ @Test - fun twentySixKeySpaceIsCenteredAndTheRowMirrors() = withKeyboard { harness, _, keyboard -> + fun theBottomRowCarriesTheReferencesOwnKeyWidths() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } val space = settled(harness, keyboard, "key-space") - val mode = settled(harness, keyboard, "key:mode") - val punctuation = settled(harness, keyboard, "key-punctuation") - val digits = settled(harness, keyboard, "key:123") - val enter = settled(harness, keyboard, "key-enter") + val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() + val shares = listOf( + "key:123" to weights.leftOuter, + "key-punctuation" to weights.merged, + "key-symbol-comma" to weights.symbol, + "key-space" to weights.space, + "key-symbol-period" to weights.symbol, + "key:mode" to weights.rightInner, + "key-enter" to weights.rightOuter, + ) harness.awaitMain { - val tolerance = 3f - assertEquals(keyboard.width / 2f, space.centerX(keyboard), tolerance) - assertEquals(mode.width.toFloat(), punctuation.width.toFloat(), tolerance) - assertEquals(digits.width.toFloat(), enter.width.toFloat(), tolerance) - assertTrue("the mode key must be narrower than the digits key", mode.width < digits.width) + val row = space.parent as ViewGroup + val unit = row.width / shares.sumOf { it.second.toDouble() }.toFloat() + shares.forEach { (tag, weight) -> + val key = keyboard.findViewWithTag(tag) + assertNotNull("$tag must be in the bottom row", key) + assertEquals( + "$tag takes its share of the row", + weight * unit, + key!!.width.toFloat(), + 2f, + ) + } + val before = shares.takeWhile { it.first != "key-space" } + .sumOf { it.second.toDouble() }.toFloat() + assertEquals( + "the space bar sits where the reference's own weights put it", + (before + weights.space / 2f) * unit, + space.centerX(row), + 2f, + ) true } } @@ -117,6 +147,13 @@ class KeyboardBottomRowInstrumentedTest { } } + /** + * The reference's merged key carries the full stop in its own language and + * the mark it hides in its corner: Chinese draws `text=","` with + * `ficon_text="。"` (`input_kbd_pinyin26.xml:145-152`), English draws + * `text="."` with `ficon_text=","` (`input_kbd_english26.xml:154-161`), so a + * long press commits the corner mark in both. + */ @Test fun commaKeyTapsCommaAndLongPressesPeriodInBothLanguages() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } @@ -128,7 +165,7 @@ class KeyboardBottomRowInstrumentedTest { harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } val en = harness.awaitMain { keyboard.findViewWithTag("key-punctuation")?.takeIf { it.width > 0 } } harness.awaitMain { assertTrue(en.performClick()); assertTrue(en.performLongClick()); true } - assertEquals(listOf(",", "."), recorder.characters.toList()) + assertEquals(listOf(".", ","), recorder.characters.toList()) } @Test diff --git a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt index 0e0d5d0e..cd22ab6f 100644 --- a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt @@ -8,9 +8,11 @@ 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.handwriting.StrokeMatchHandwritingProvider import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.panel.TileGridPolicy import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeReferenceSizing import llc.slacker.openime.theme.ImeTheme import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -78,13 +80,23 @@ class LauncherTileInstrumentedTest { assertNotNull("The tools page must lay its tiles out as a grid", grid) val rows = (grid as ViewGroup) assertEquals("The reference's grid is two rows deep", TileGridPolicy.ROWS, rows.childCount) - (0 until rows.childCount).forEach { index -> - assertEquals( - "Every row of the grid is ${TileGridPolicy.COLUMNS} columns", - TileGridPolicy.COLUMNS, - (rows.getChildAt(index) as ViewGroup).childCount, - ) + // The reference fills a row with four tiles and leaves a short last + // row short (`input_settings.xml:13-45` packs its own buttons four + // to a row); the cells keep the grid's own column width either way. + assertEquals( + "The first row carries the reference's four columns", + TileGridPolicy.COLUMNS, + (rows.getChildAt(0) as ViewGroup).childCount, + ) + val cellWidths = (0 until rows.childCount).flatMap { index -> + val cells = rows.getChildAt(index) as ViewGroup + (0 until cells.childCount).map { cells.getChildAt(it).width } } + assertEquals( + "Every cell keeps the page's one column width: $cellWidths", + 1, + cellWidths.distinct().size, + ) // Every tile of a page is the same size, as `parent_average` makes it. val sizes = (0 until rows.childCount).flatMap { row -> val cells = rows.getChildAt(row) as ViewGroup @@ -109,9 +121,14 @@ class LauncherTileInstrumentedTest { tokens.toolCardBackground, faceColor(tile), ) + // The radius goes through the view's own `dp`: the reference's units + // scale with the page (`ImeKeyboardView.dp`), so the test measures + // the tile with the same reference scale. assertEquals( "`bkround=\"14\"` on cls_settings_button", - keyboard.resources.displayMetrics.density * TileGridPolicy.TILE_RADIUS_DP, + with(keyboard.resources.displayMetrics) { + density * ImeReferenceSizing.scale(keyboard.context, keyboard.width) * TileGridPolicy.TILE_RADIUS_DP + }, faceRadius(tile), 1f, ) @@ -149,7 +166,13 @@ class LauncherTileInstrumentedTest { } } assertTrue("The switcher keeps the pinyin keyboards", labels.contains("拼音 26 键")) - assertTrue("The switcher keeps the handwriting entry", labels.contains("手写输入")) + // The handwriting tile follows the strokes table, which only local + // builds ship (`StrokeMatchHandwritingProvider.assetPresent`). + assertEquals( + "The switcher carries the handwriting entry with its strokes table", + StrokeMatchHandwritingProvider.assetPresent(keyboard.context), + labels.contains("手写输入"), + ) keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) keyboard.showPanel(Panel.KEYBOARD_SELECT) assertNotNull( diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt index 31525d19..7ffa7bea 100644 --- a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.content.ClipboardManager import android.content.Context import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.keyboard.ImeKeyboardView @@ -66,10 +67,19 @@ class QuickInputRowInstrumentedTest { popup = true, fuzzy = false, ) + // The quick-input rows belong to a real editor session; + // this view has none, so it declares the options an editor + // without the no-personalisation flag would give it. + keyboard.editorOptionsOverride = EditorInfo.IME_ACTION_NONE true } copy(text) harness.awaitMain { keyboard.refreshSuggestions(); true } + // Let the row place itself: the assertions measure a laid-out glyph. + harness.awaitMain { + val icon = keyboard.findViewWithTag("suggestion-icon") + if ((icon?.height ?: 0) > 0) true else null + } harness.awaitMain { test(keyboard, copy); true } } finally { harness.awaitMain { diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index bf982b43..fe801e82 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -411,6 +411,12 @@ class SpaceCursorAndLetterHintsInstrumentedTest { } } + /** + * The merged key commits its own mark and hides the other in its corner: + * Chinese 逗号 over 句号, English the full stop over the comma + * (`input_kbd_english26.xml:154-161`, `input_kbd_pinyin26.xml:145-152`), so + * an up swipe types the corner mark — 句号 in Chinese, the comma in English. + */ @Test fun swipeUpOnTheCommaKeyTypesThePeriod() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } @@ -420,7 +426,7 @@ class SpaceCursorAndLetterHintsInstrumentedTest { recorder.characters.clear() harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } swipeUp(harness, settled(harness, keyboard, "key-punctuation")) - assertEquals(listOf("."), recorder.characters.toList()) + assertEquals(listOf(","), recorder.characters.toList()) } @Test diff --git a/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt index 281d37a1..c93837d2 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt @@ -42,7 +42,8 @@ class SymbolHistoryInstrumentedTest { } as ImeKeyboardView.Listener private fun withKeyboard(test: (ImeKeyboardView) -> Unit) { - SymbolRecentRepository.clear(context) + // The history each test records before this call is the input it arranges; + // the @After cleanup empties the store between tests instead. DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> harness.launch() val keyboard = harness.awaitMain { activity -> 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 e2734ac6..6236211f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1776,8 +1776,16 @@ open class ImeKeyboardView( syncEnterKeyPresentation(state.editorInfo?.imeOptions) } + /** + * The editor the toolbar chips and the enter key are drawn for. The service + * supplies it on every input start; instrumented tests build this view + * without an input session and set it here instead. + */ + internal var editorOptionsOverride: Int? = null + private fun currentImeOptions(): Int? = - (context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions + editorOptionsOverride + ?: (context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions /** The editor the keys are being drawn for; null outside a real input session. */ private fun currentEditorInfo(): android.view.inputmethod.EditorInfo? = 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 eb3ee396..6a70f721 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -167,6 +167,10 @@ internal class Pinyin26KeyboardRenderer( contentDescription = "${spec.merged},上滑或长按输入${spec.mergedCorner}" setSecondaryVisible(true) setSecondaryAlpha(1f) + // The key is a mark and a corner in 8.8% of the row: the mark shrinks + // rather than showing an ellipsis when the key or the system font + // leaves it no room. + allowLabelShrink() // Up swipes the corner mark, as the reference keyboard does. onSwipeUp = { onCommitCharacter(spec.mergedCorner) } setOnLongClickListener { @@ -187,6 +191,7 @@ internal class Pinyin26KeyboardRenderer( }.apply { this.tag = tag contentDescription = text + allowLabelShrink() } private fun segmentKey(): ImeKeyView = 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 9eb9c715..ceef38f3 100644 --- a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -105,7 +105,7 @@ class ImeKeyView( private var touchFeedbackPending = false private var touchGeneration = 0L private val baseMainTextSize = mainTextSize - private val fitMain = fitMainText + private var fitMain = fitMainText /** * Key labels follow the system font size only up to [MAX_LABEL_FONT_SCALE]. @@ -379,6 +379,27 @@ class ImeKeyView( mainTextView?.maxLines = 2 } + /** + * Let a wide single glyph shrink to its key instead of turning into "…". + * + * The reference gives the bottom row's punctuation keys their own fonts so + * the mark always fits (`text_comma_cn` is 72 units for the merged key + * against `key_letter`'s 57, `style.xml:127`, `:4`); our one shared size + * would ellipsize the mark in a key that is only 8% of the row. The key + * keeps its label either way, as it does for the function keys. + */ + fun allowLabelShrink() { + fitMain = true + // The same controlled range the function keys keep, so a later + // [applyMainTextScale] leaves the key in the shape it asks for. + val smallest = labelPx((baseMainTextSize * 0.68f).coerceAtLeast(10f)).toInt() + val largest = labelPx(baseMainTextSize.coerceAtLeast(12f)).toInt() + mainTextView?.apply { + ellipsize = null + setAutoSizeTextTypeUniformWithConfiguration(smallest, largest, 1, TypedValue.COMPLEX_UNIT_PX) + } + } + fun setIcon(value: Int) { if (iconView == null) return iconView.setImageResource(value) From 6b5be72ec35702e116846d749b309c3d63d14470 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:34:58 +0800 Subject: [PATCH 31/94] Draw every key cap with its own class's reference margins The skin paints each key cap inside its cell with per-class margins: `cls_keyboard_button_margin` keeps 2.25 units sideways and 4.75 up and down (`style.xml:289-290`), `cls_keyboard9_button_margin` 2.5 and 2.75 (`style.xml:373-374`), and the 26-key board's two big side keys leave 8 units on the side that faces the letters (`cls_button_left_big`, `cls_button_right_big`, `style.xml:907-912`). We drew one uniform 6-unit gap instead. ImeGeometryTokens now carries the reference's numbers. ImeKeyView keeps its margins per instance and ImeThemeApplier insets the cap by them, so ImeKeyboardView can set the family's values before it renders a board and Pinyin26KeyboardRenderer can give the two big keys their inner 8. Key cells, weights and board height are untouched. KeyMarginsInstrumentedTest reads the cap's insets back off the drawn background for all three families and for a panel opened from nine-key. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 + .../openime/KeyMarginsInstrumentedTest.kt | 183 ++++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 19 ++ .../openime/keyboard/ImeThemeApplier.kt | 14 +- .../keyboard/Pinyin26KeyboardRenderer.kt | 19 +- .../slacker/openime/theme/ImeDesignTokens.kt | 15 ++ .../llc/slacker/openime/widget/ImeKeyView.kt | 30 +++ 7 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce84bc1..45527767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 键帽边距改为按参考皮肤每一类按键自己的数值绘制:26 键是左右 2.25、上下 4.75 单位(原来所有键统一 6),九键、笔画和数字页是 2.5 与 2.75;26 键那一行的「分词」「退格」两个大键朝字母区一侧各留 8 单位。键位大小和键盘高度都不变,只是键帽在格子里按参考的比例收边。 +- 大系统字号下,26 键底行的「,。」「,」「。」几个标点键不再被省略号截断:参考皮肤给这几个键单独的字号(72 对 57 单位),我们让这个标记在键内收缩到合适大小。 - 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小标(消息/剪贴板;参考皮肤里这个标是上标,约候选字号的 53%,贴着这一行的上沿,文字框也按参考的比例上下留白),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 - 拼音预编辑框宽度改为参考皮肤的一半页宽(参考皮肤的这个输入块最多占半页,右半留给旁边的控件),超出部分在框内左右滚动;左侧内边距改为参考的 5 单位;编辑时按参考皮肤变暗(浅色 8% 黑、深色 12% 白)。原先竖屏铺满整行、横屏用我们自己定的 160dp 上限,且编辑时没有按下反馈。 - 悬浮键盘时工具栏改为显示一条提示(参考皮肤在悬浮键盘上显示的就是这条提示):圆角药丸底、左边是说明文字「轻点顶部把手可恢复默认键盘」、右边一个圆形关闭键;按关闭键本次悬浮期间不再出现,收起重开悬浮键盘会再次显示。 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt new file mode 100644 index 00000000..2f7ad209 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -0,0 +1,183 @@ +package llc.slacker.openime + +import android.graphics.Rect +import android.graphics.drawable.InsetDrawable +import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.Panel +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeGeometryTokens +import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.voice.VoiceModelLifecycleState +import llc.slacker.openime.widget.ImeKeyView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * Every key cap is painted inside its cell by that key class's own margins: the + * 26-key board's keys keep 2.25 units sideways and 4.75 up and down + * (`cls_keyboard_button_margin`, `style.xml:289-290`), the nine-key family — + * nine-key, stroke and the number page — 2.5 and 2.75 + * (`cls_keyboard9_button_margin`, `style.xml:373-374`), and the 26-key board's + * two big side keys clear the letter column by 8 units on their inner side + * (`cls_button_left_big`, `cls_button_right_big`, `style.xml:907-912`). + */ +@RunWith(AndroidJUnit4::class) +class KeyMarginsInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + harness.awaitMain { test(harness, keyboard); true } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + /** One reference unit in pixels, the measure `ImeKeyboardView.dp` uses. */ + private fun unitOf(keyboard: ImeKeyboardView): Float = + keyboard.resources.displayMetrics.density * ImeReferenceSizing.scale(keyboard.context, keyboard.width) + + /** The margins the cap is painted with: the background reports its insets as padding. */ + private fun capInsets(key: View): Rect = Rect().also { + (key.background as InsetDrawable).getPadding(it) + } + + private fun assertMargins( + keyboard: ImeKeyboardView, + tag: String, + left: Float, + top: Float, + right: Float, + bottom: Float, + ) { + val key = keyboard.findViewWithTag(tag) + assertNotNull("$tag must be on the board", key) + assertMargins(keyboard, key!!, tag, left, top, right, bottom) + } + + private fun assertMargins( + keyboard: ImeKeyboardView, + key: View, + label: String, + left: Float, + top: Float, + right: Float, + bottom: Float, + ) { + val unit = unitOf(keyboard) + val insets = capInsets(key) + assertEquals("$label keeps the reference's left margin", Math.round(left * unit).toLong(), insets.left.toLong()) + assertEquals("$label keeps the reference's top margin", Math.round(top * unit).toLong(), insets.top.toLong()) + assertEquals("$label keeps the reference's right margin", Math.round(right * unit).toLong(), insets.right.toLong()) + assertEquals("$label keeps the reference's bottom margin", Math.round(bottom * unit).toLong(), insets.bottom.toLong()) + } + + private fun allViews(root: View): Sequence = sequence { + yield(root) + if (root is ViewGroup) { + for (index in 0 until root.childCount) yieldAll(allViews(root.getChildAt(index))) + } + } + + @Test + fun theTwentySixKeyBoardKeepsTheReferenceKeyMargins() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + val x = ImeGeometryTokens.KEY_MARGIN_X_DP + val y = ImeGeometryTokens.KEY_MARGIN_Y_DP + listOf("key-space", "key:123", "key:mode", "key-enter", "key-punctuation", "key:z").forEach { tag -> + assertMargins(keyboard, tag, x, y, x, y) + } + } + } + + @Test + fun theBigSideKeysClearTheLetterColumnByTheirOwnMargin() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + val inner = ImeGeometryTokens.KEY_INNER_MARGIN_DP + val x = ImeGeometryTokens.KEY_MARGIN_X_DP + val y = ImeGeometryTokens.KEY_MARGIN_Y_DP + // 分词 on the left clears the letters to its right, 删除 on the right to its left. + assertMargins(keyboard, "key-shift", x, y, inner, y) + assertMargins(keyboard, "key-backspace", inner, y, x, y) + } + } + + @Test + fun theNineKeyFamilyKeepsItsTighterMargin() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_9); true } + val x = ImeGeometryTokens.NINE_KEY_MARGIN_X_DP + val y = ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP + listOf("key-space", "key:123", "key:mode", "key-enter", "key-9:2").forEach { tag -> + assertMargins(keyboard, tag, x, y, x, y) + } + } + } + + @Test + fun theStrokeAndNumberBoardsKeepTheNineKeyMargin() { + withKeyboard { harness, keyboard -> + val x = ImeGeometryTokens.NINE_KEY_MARGIN_X_DP + val y = ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP + harness.awaitMain { keyboard.setMode(KeyboardMode.STROKE); true } + assertMargins(keyboard, "key-space", x, y, x, y) + harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); true } + assertMargins(keyboard, "key:5", x, y, x, y) + } + } + + @Test + fun aPanelKeepsTheKeyMarginWhateverBoardOpenedIt() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_9); true } + harness.awaitMain { + keyboard.showPanel(Panel.SYMBOLS) + true + } + // A panel's keys are the reference's own 26-key key whatever board + // it was opened from (`cls_symbol_keyboard_button`, `style.xml:1332`). + val symbol = allViews(keyboard) + .filterIsInstance() + .firstOrNull { it.isShown && it.width > 0 && capInsets(it).left > 0 } + assertNotNull("the symbol page carries keys", symbol) + assertMargins( + keyboard, + symbol!!, + "a symbol page key", + ImeGeometryTokens.KEY_MARGIN_X_DP, + ImeGeometryTokens.KEY_MARGIN_Y_DP, + ImeGeometryTokens.KEY_MARGIN_X_DP, + ImeGeometryTokens.KEY_MARGIN_Y_DP, + ) + } + } +} 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 6236211f..b0edeb8b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -304,6 +304,10 @@ open class ImeKeyboardView( // removeAllViews()+rebuild when focus moves between fields without a layout // change. private var renderedMode: KeyboardMode? = null + // The key margins the keys being built wear, set per board family when a + // body or a panel is rendered (see renderModeBody / renderPanel). + private var keyMarginX = ImeGeometryTokens.KEY_MARGIN_X_DP + private var keyMarginY = ImeGeometryTokens.KEY_MARGIN_Y_DP // Android drops a gesture the moment its view leaves the hierarchy, so a row // rebuild that lands while a key is held swallows that press (the space tap, // a letter, the backspace repeat). Remember the request and replay it once @@ -2271,6 +2275,16 @@ open class ImeKeyboardView( candidateOverlay.visibility = View.GONE candidateBarController.resetExpandedState() candidateBarController.syncExpandControl(currentCandidates.isNotEmpty()) + // Each board paints its key caps with its own family's margins: the + // nine-key family — nine-key, stroke and the number page — keeps the + // tighter 2.5 × 2.75 (`cls_keyboard9_button_margin`, `style.xml:373-374`), + // the 26-key boards 2.25 × 4.75 (`cls_keyboard_button_margin`, + // `style.xml:289-290`, see ImeGeometryTokens). + val nineKeyFamily = mode == KeyboardMode.PINYIN_9 || + mode == KeyboardMode.STROKE || + mode == KeyboardMode.DIGITS + keyMarginX = if (nineKeyFamily) ImeGeometryTokens.NINE_KEY_MARGIN_X_DP else ImeGeometryTokens.KEY_MARGIN_X_DP + keyMarginY = if (nineKeyFamily) ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP else ImeGeometryTokens.KEY_MARGIN_Y_DP when (mode) { KeyboardMode.PINYIN_26 -> renderPinyin26() KeyboardMode.ENGLISH_26 -> renderEnglish26() @@ -2563,6 +2577,10 @@ open class ImeKeyboardView( expandedPanel.visibility = View.VISIBLE candidateBarController.resetExpandedState() voicePanelController.detachView() + // A panel's keys are the reference's own 26-key key whatever board it + // was opened from (`cls_symbol_keyboard_button`, `style.xml:1332`). + keyMarginX = ImeGeometryTokens.KEY_MARGIN_X_DP + keyMarginY = ImeGeometryTokens.KEY_MARGIN_Y_DP // Each visit to the symbol board starts with its history strip again. if (panel == Panel.SYMBOLS) recentSymbolStripDismissed = false when (panel) { @@ -3530,6 +3548,7 @@ open class ImeKeyboardView( ).apply { tag = "key:$text" setTag(MARK_FUNCTION_KEY, func) + setReferenceMargins(keyMarginX, keyMarginY, keyMarginX, keyMarginY) contentDescription = if (text.isNotEmpty()) text else if (iconRes != 0) "功能键" else " " if (!func && secondary != null && !showSecondaryHints) setSecondaryVisible(false) minimumHeight = dp(ImeGeometryTokens.TOUCH_TARGET_DP) 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 c90ada8f..6b4c91f0 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -114,8 +114,18 @@ internal class ImeThemeApplier( val keyRadius = toPx(ImeGeometryTokens.KEY_RADIUS_DP) val keyFace = statefulRounded(color, pressedColor, keyRadius) val keyCap = ImeDrawableFactory.keyCap(keyFace, pressedColor, keyRadius, toPx(1)) - val halfGap = toPx(ImeGeometryTokens.KEY_GAP_DP) / 2 - view.background = InsetDrawable(keyCap, halfGap, halfGap, halfGap, halfGap) + // The cap is painted inside the cell by the key's own margins, as the + // reference's key classes set them (2.25 × 4.75 on the 26-key board, + // 2.5 × 2.75 on the nine-key family, 8 on a big side key's inner side). + val unit = view.resources.displayMetrics.density * referenceScale() + fun cap(dp: Float): Int = Math.round(dp * unit) + view.background = InsetDrawable( + keyCap, + cap(view.marginLeftDp), + cap(view.marginTopDp), + cap(view.marginRightDp), + cap(view.marginBottomDp), + ) view.elevation = 0f when { 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 6a70f721..98e37ea0 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -5,6 +5,7 @@ import android.view.Gravity import android.view.View import android.widget.LinearLayout import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView @@ -77,7 +78,17 @@ internal class Pinyin26KeyboardRenderer( } if (rowIndex == 2) { - row.addView(createBackspaceKey(), flexKeyParams(EDGE_KEY_WEIGHT)) + // The right big key clears the letter column by 8 units on its + // inner side (`cls_button_right_big`, `style.xml:911-912`). + row.addView( + createBackspaceKey().apply { + setReferenceMargins( + left = ImeGeometryTokens.KEY_INNER_MARGIN_DP, + right = ImeGeometryTokens.KEY_MARGIN_X_DP, + ) + }, + flexKeyParams(EDGE_KEY_WEIGHT), + ) } keyboardBody.addView(row, rowParams()) } @@ -205,6 +216,12 @@ internal class Pinyin26KeyboardRenderer( private fun shiftKey(shiftState: ShiftState): ImeKeyView { val iconRes = EnglishShiftPolicy.icon(shiftState) return createKey("", true, null, null, iconRes, onShift).apply { + // The reference's left big key clears the letter column by 8 units + // on its inner side (`cls_button_left_big`, `style.xml:907-908`). + setReferenceMargins( + left = ImeGeometryTokens.KEY_MARGIN_X_DP, + right = ImeGeometryTokens.KEY_INNER_MARGIN_DP, + ) tag = when (shiftState) { ShiftState.LOWERCASE -> "key-shift" ShiftState.SHIFT_ONCE -> "key-shift-active" 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 b4dc1af3..cfb42a52 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -62,6 +62,21 @@ 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 + + /** + * The reference paints every key cap inside its cell with that key class's + * own margins: the 26-key board's keys keep 2.25 units sideways and 4.75 up + * and down (`cls_keyboard_button_margin`, `style.xml:289-290`), the nine-key + * family — nine-key, stroke and the number page — 2.5 and 2.75 + * (`cls_keyboard9_button_margin`, `style.xml:373-374`). The 26-key board's + * two big side keys clear the letter column by 8 units on their inner side + * (`cls_button_left_big`, `cls_button_right_big`, `style.xml:907-912`). + */ + const val KEY_MARGIN_X_DP = 2.25f + const val KEY_MARGIN_Y_DP = 4.75f + const val NINE_KEY_MARGIN_X_DP = 2.5f + const val NINE_KEY_MARGIN_Y_DP = 2.75f + const val KEY_INNER_MARGIN_DP = 8f const val KEY_POPUP_HEIGHT_DP = 52 const val KEY_POPUP_MIN_WIDTH_DP = 40 const val KEY_POPUP_WIDTH_SCALE = 1.08f 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 ceef38f3..5f8130b6 100644 --- a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -14,6 +14,7 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import llc.slacker.openime.R +import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeTypographyTokens /** @@ -107,6 +108,35 @@ class ImeKeyView( private val baseMainTextSize = mainTextSize private var fitMain = fitMainText + /** + * The key's own margins in reference units: the cap is painted inside the + * cell by this much, as the reference's key classes give every key its own + * margin (`cls_keyboard_button_margin`, `style.xml:289-290`; + * `cls_keyboard9_button_margin`, `style.xml:373-374`). The theme applier + * reads them; a board sets its family's values on the keys it builds. + */ + var marginLeftDp = ImeGeometryTokens.KEY_MARGIN_X_DP + private set + var marginTopDp = ImeGeometryTokens.KEY_MARGIN_Y_DP + private set + var marginRightDp = ImeGeometryTokens.KEY_MARGIN_X_DP + private set + var marginBottomDp = ImeGeometryTokens.KEY_MARGIN_Y_DP + private set + + /** Sets the margins a board family paints its keys with. */ + fun setReferenceMargins( + left: Float = marginLeftDp, + top: Float = marginTopDp, + right: Float = marginRightDp, + bottom: Float = marginBottomDp, + ) { + marginLeftDp = left + marginTopDp = top + marginRightDp = right + marginBottomDp = bottom + } + /** * Key labels follow the system font size only up to [MAX_LABEL_FONT_SCALE]. * A key has a fixed width, so at 200% a plain "m" no longer fits and became From 2c51a431a4a079e900ebd9ad6dfc93add2559374 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:39:16 +0800 Subject: [PATCH 32/94] Round every key and the long-press bubble the way the skin does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every key class in the reference asks for the same corner: the white and the gray key caps both set `bkround="6"` (`style.xml:224-227`, `:276-281`), the symbol and history cells round at 6 (`style.xml:1065`, `:1082`, `:1111`), the back keys spell out 6 (`style.xml:848`, `:854`, `:858`) and so does the long-press bubble's own button (`style.xml:1137`). We drew 8. The bubble container is also its own element, not a key: it fills with `fusiyin_bubble_container_bk` — opaque white on the light skin, opaque gray (96,96,96) on the dark one (`values/colors.xml:172`, `values/dark_colors.xml:162`) — rounds at 8 and carries a shadow but no edge (`cls_fusiyin_bubble_container`, `style.xml:1128-1133`). We used the keyboard's translucent white, a 12 radius and an extra border, which let the key behind the bubble show through it in the dark theme. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 ++ .../openime/keyboard/KeyPopupController.kt | 12 +++------ .../slacker/openime/theme/ImeDesignTokens.kt | 27 ++++++++++++++++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45527767..124fe91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 键帽圆角改为参考皮肤的 6 单位(原来 8):参考皮肤里白键、灰功能键、符号格、历史符号格和三个返回键写的都是 bkround 6。 +- 长按气泡改为参考皮肤自己的底色与圆角:浅色是不透明白、深色是实色灰(96,96,96),不再用键盘上那种半透明的白;圆角按参考的 8 单位(原来 12),并且去掉我们自加的描边,只留参考的那层阴影(原来是半透明底加一圈边,深色下能透出底下的键)。 - 键帽边距改为按参考皮肤每一类按键自己的数值绘制:26 键是左右 2.25、上下 4.75 单位(原来所有键统一 6),九键、笔画和数字页是 2.5 与 2.75;26 键那一行的「分词」「退格」两个大键朝字母区一侧各留 8 单位。键位大小和键盘高度都不变,只是键帽在格子里按参考的比例收边。 - 大系统字号下,26 键底行的「,。」「,」「。」几个标点键不再被省略号截断:参考皮肤给这几个键单独的字号(72 对 57 单位),我们让这个标记在键内收缩到合适大小。 - 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小标(消息/剪贴板;参考皮肤里这个标是上标,约候选字号的 53%,贴着这一行的上沿,文字框也按参考的比例上下留白),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 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 4db77ff4..d155a011 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -178,10 +178,8 @@ internal class KeyPopupController( } setTextColor(t.keyText) background = ImeDrawableFactory.rounded( - t.keyBackground, - dp(ImeGeometryTokens.CONTROL_RADIUS_DP), - ImeSurfacePolicy.divider(t), - dp(1).coerceAtLeast(1), + ImeSurfacePolicy.keyPopupBackground(t), + dp(ImeGeometryTokens.KEY_POPUP_RADIUS_DP), ) } @@ -221,10 +219,8 @@ internal class KeyPopupController( gravity = Gravity.CENTER_HORIZONTAL setPadding(dp(POPUP_PAD_DP), dp(POPUP_PAD_DP), dp(POPUP_PAD_DP), dp(POPUP_PAD_DP)) background = ImeDrawableFactory.rounded( - t.keyBackground, - dp(ImeGeometryTokens.CONTROL_RADIUS_DP), - ImeSurfacePolicy.divider(t), - dp(1).coerceAtLeast(1), + ImeSurfacePolicy.keyPopupBackground(t), + dp(ImeGeometryTokens.KEY_POPUP_RADIUS_DP), ) elevation = dp(2).toFloat() contentDescription = "长按符号选择" 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 cfb42a52..7686a836 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -33,7 +33,16 @@ internal object ImeGeometryTokens { /** Portrait key row height on the reference design's 390-unit canvas. */ const val KEY_ROW_HEIGHT_DP = 54 - const val KEY_RADIUS_DP = 8 + + /** + * A key cap's corner radius. Every key class in the reference asks for the + * same round: `cls_bk_white_circle` and `cls_bk_gray_circle` both set + * `bkround="6"` (`style.xml:224-227`, `:276-281`), the symbol and history + * cells round at 6 (`style.xml:1065`, `:1082`, `:1111`), the three back keys + * spell out 6 as well (`style.xml:848`, `:854`, `:858`), and so does the + * long-press bubble's own button (`style.xml:1137`). + */ + const val KEY_RADIUS_DP = 6 const val CONTROL_RADIUS_DP = 12 const val CARD_RADIUS_DP = 16 const val BADGE_RADIUS_DP = 8 @@ -78,6 +87,12 @@ internal object ImeGeometryTokens { const val NINE_KEY_MARGIN_Y_DP = 2.75f const val KEY_INNER_MARGIN_DP = 8f const val KEY_POPUP_HEIGHT_DP = 52 + /** + * The long-press bubble is rounder than a key and carries no edge of its + * own: `cls_fusiyin_bubble_container` rounds at 8 and sets only a shadow + * (`style.xml:1128-1133`). + */ + const val KEY_POPUP_RADIUS_DP = 8 const val KEY_POPUP_MIN_WIDTH_DP = 40 const val KEY_POPUP_WIDTH_SCALE = 1.08f const val KEY_POPUP_VERTICAL_GAP_DP = 8 @@ -389,6 +404,16 @@ internal object ImeSurfacePolicy { if (isDark(tokens)) 0.17f else 0.14f, ) + /** + * The long-press bubble's fill, `fusiyin_bubble_container_bk`: an opaque + * white on the light skin and an opaque gray (96,96,96) on the dark one — + * not the keyboard's translucent white, which would let the key behind the + * bubble show through the letter on it (`values/colors.xml:172`, + * `values/dark_colors.xml:162`). + */ + fun keyPopupBackground(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#606060") else Color.WHITE + /** Inset separator between rows of one grouped card. */ fun hairline(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.blend(tokens.keyText, tokens.toolCardBackground, if (isDark(tokens)) 0.10f else 0.08f) From cd34267004104417e4015df0a2ef50d463958bf1 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:04:10 +0800 Subject: [PATCH 33/94] Shrink a one-line mark to its key instead of trusting autosize `keyLabelsStayReadableAtEverySystemFontSize` still named the marks beside the space bar as cut off: at font scale 1.3 the label was drawn at 68px inside a 55px box. The platform's autosize cannot fix that. It rejects a candidate size only when the text overflows the line count or the height (`TextView#suggestedSizeFitsInSpace`, `frameworks/base/core/java/android/widget/TextView.java:11549-11577`), and a single line that is merely too wide wraps nowhere and keeps the largest size. So ImeKeyView measures the label itself: `allowLabelShrink` turns autosize off and the key shrinks its single-line label to the width the cap really has on every layout pass, never below the 0.68 floor the function keys keep. The size the applier asks for stays on record, so a wider cap grows the label back. The tests that drove the tip, the quick-input row and the margins ran their bodies on the main thread and called back into `awaitMain`; they now run off it and touch views through it. The quick-input row reads at the candidate font as drawn (reference scale), the banner's close key is a keyboard-sized 48 units, and the symbol history clears the store another test leaves behind. Co-Authored-By: Claude Code --- .../openime/FloatingTipInstrumentedTest.kt | 55 ++++++++------ .../openime/KeyMarginsInstrumentedTest.kt | 14 +++- .../openime/QuickInputRowInstrumentedTest.kt | 76 ++++++++++++------- .../openime/SymbolHistoryInstrumentedTest.kt | 3 + .../llc/slacker/openime/widget/ImeKeyView.kt | 75 +++++++++++++++--- 5 files changed, 156 insertions(+), 67 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt index eafc0612..f8c3c274 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingTipInstrumentedTest.kt @@ -6,6 +6,7 @@ import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.keyboard.ToolbarTipPolicy +import llc.slacker.openime.theme.ImeReferenceSizing import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -35,7 +36,7 @@ class FloatingTipInstrumentedTest { } try { harness.awaitMain { if (keyboard.width > 0) true else null } - harness.awaitMain { test(harness, keyboard); true } + test(harness, keyboard) } finally { harness.awaitMain { keyboard.shutdown() @@ -57,32 +58,38 @@ class FloatingTipInstrumentedTest { @Test fun theFloatingKeyboardOpensWithTheTipBanner() { withKeyboard { harness, keyboard -> - assertEquals(View.GONE, tip(keyboard)?.visibility) + assertEquals(View.GONE, harness.awaitMain { tip(keyboard)?.visibility }) showFloatingTip(harness, keyboard) - assertEquals(View.VISIBLE, tip(keyboard)?.visibility) - val label = keyboard.findViewWithTag("toolbar-tip-label") + assertEquals(View.VISIBLE, harness.awaitMain { tip(keyboard)?.visibility }) + val label = harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-label") } assertNotNull("The banner says what the handle does", label) assertTrue( "Our tip names our own way back to the docked keyboard", - label.text.toString().contains("把手"), + label!!.text.toString().contains("把手"), + ) + assertNotNull( + "The banner keeps the reference's round close key", + harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-close") }, ) - assertNotNull("The banner keeps the reference's round close key", keyboard.findViewWithTag("toolbar-tip-close")) } } @Test fun theCloseKeySettlesTheTipForTheStayAfloat() { - withKeyboard { _, keyboard -> - keyboard.setFloatingWindowMode(true) - keyboard.findViewWithTag("toolbar-tip-close")!!.performClick() - assertEquals(View.GONE, tip(keyboard)?.visibility) + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + harness.awaitMain { + keyboard.findViewWithTag("toolbar-tip-close")!!.performClick() + true + } + assertEquals(View.GONE, harness.awaitMain { tip(keyboard)?.visibility }) // A later floating update keeps it away: the user has read it. - keyboard.setFloatingWindowMode(true, compact = true) - assertEquals(View.GONE, tip(keyboard)?.visibility) + harness.awaitMain { keyboard.setFloatingWindowMode(true, compact = true); true } + assertEquals(View.GONE, harness.awaitMain { tip(keyboard)?.visibility }) // Docking and floating again shows it once more. - keyboard.setFloatingWindowMode(false) - keyboard.setFloatingWindowMode(true) - assertEquals(View.VISIBLE, tip(keyboard)?.visibility) + harness.awaitMain { keyboard.setFloatingWindowMode(false); true } + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + assertEquals(View.VISIBLE, harness.awaitMain { tip(keyboard)?.visibility }) } } @@ -90,13 +97,13 @@ class FloatingTipInstrumentedTest { fun theTipTakesTheRowButNotTheCollectKey() { withKeyboard { harness, keyboard -> showFloatingTip(harness, keyboard) - assertEquals(View.VISIBLE, tip(keyboard)?.visibility) - val hide = keyboard.findViewWithTag("keyboard-hide") + assertEquals(View.VISIBLE, harness.awaitMain { tip(keyboard)?.visibility }) + val hide = harness.awaitMain { keyboard.findViewWithTag("keyboard-hide") } assertNotNull("The collect-the-keyboard key stays reachable", hide) - assertEquals(View.VISIBLE, hide.visibility) + assertEquals(View.VISIBLE, harness.awaitMain { hide!!.visibility }) assertTrue( "The banner is wider than a toolbar icon: it takes the row", - tip(keyboard)!!.width > hide.width, + harness.awaitMain { (tip(keyboard)?.width ?: 0) > hide.width }, ) } } @@ -105,24 +112,24 @@ class FloatingTipInstrumentedTest { fun theBannerKeepsTheReferencesOwnProportions() { withKeyboard { harness, keyboard -> showFloatingTip(harness, keyboard) - val strip = tip(keyboard)!! - val row = keyboard.findViewWithTag("toolbar-row")!! + val strip = harness.awaitMain { tip(keyboard)!! } + val row = harness.awaitMain { keyboard.findViewWithTag("toolbar-row")!! } val density = keyboard.resources.displayMetrics.density assertEquals( "The container keeps its own 3.333% padding", (row.width * ToolbarTipPolicy.CONTAINER_PAD_FRACTION).toInt().toLong(), strip.paddingLeft.toLong(), ) - val lead = keyboard.findViewWithTag("toolbar-tip-lead")!! + val lead = harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-lead")!! } assertEquals( "`width_weight=\"0.03571\"` before the label", (row.width * ToolbarTipPolicy.LEAD_WEIGHT).toInt().toLong(), lead.width.toLong(), ) - val close = keyboard.findViewWithTag("toolbar-tip-close")!! + val close = harness.awaitMain { keyboard.findViewWithTag("toolbar-tip-close")!! } assertEquals( "The close key keeps the 48dp touch target", - (48 * density).toInt().toLong(), + (48 * density * ImeReferenceSizing.scale(keyboard.context, keyboard.width) + 0.5f).toInt().toLong(), close.width.toLong(), ) } diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt index 2f7ad209..faa2e0c2 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -49,7 +49,7 @@ class KeyMarginsInstrumentedTest { } try { harness.awaitMain { if (keyboard.width > 0) true else null } - harness.awaitMain { test(harness, keyboard); true } + test(harness, keyboard) } finally { harness.awaitMain { keyboard.shutdown() @@ -165,9 +165,15 @@ class KeyMarginsInstrumentedTest { } // A panel's keys are the reference's own 26-key key whatever board // it was opened from (`cls_symbol_keyboard_button`, `style.xml:1332`). - val symbol = allViews(keyboard) - .filterIsInstance() - .firstOrNull { it.isShown && it.width > 0 && capInsets(it).left > 0 } + // The page is laid out on the frame after it opens, so wait for its + // first key to have a size. + val symbol = harness.awaitMain { + allViews(keyboard) + .filterIsInstance() + .firstOrNull { + it.isShown && it.width > 0 && it.background is InsetDrawable && capInsets(it).left > 0 + } + } assertNotNull("the symbol page carries keys", symbol) assertMargins( keyboard, diff --git a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt index 7ffa7bea..fd2f1256 100644 --- a/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/QuickInputRowInstrumentedTest.kt @@ -11,9 +11,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.keyboard.QuickInputRowPolicy import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeReferenceSizing import llc.slacker.openime.theme.ImeTheme import llc.slacker.openime.theme.ImeTypographyTokens 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 @@ -38,7 +40,10 @@ class QuickInputRowInstrumentedTest { if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener - private fun withClipboard(text: String, test: (ImeKeyboardView, (String) -> Unit) -> Unit) { + private fun withClipboard( + text: String, + test: (DirectActivityHarness, ImeKeyboardView, (String) -> Unit) -> Unit, + ) { DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> harness.launch() val keyboard = harness.awaitMain { activity -> @@ -80,7 +85,7 @@ class QuickInputRowInstrumentedTest { val icon = keyboard.findViewWithTag("suggestion-icon") if ((icon?.height ?: 0) > 0) true else null } - harness.awaitMain { test(keyboard, copy); true } + test(harness, keyboard, copy) } finally { harness.awaitMain { keyboard.shutdown() @@ -93,27 +98,33 @@ class QuickInputRowInstrumentedTest { @Test fun theClipboardSuggestionIsTheReferencesOwnRow() { - withClipboard("hello from the clipboard") { keyboard, _ -> - val row = keyboard.findViewWithTag("suggestion-row") + withClipboard("hello from the clipboard") { harness, keyboard, _ -> + val row = harness.awaitMain { keyboard.findViewWithTag("suggestion-row") } assertNotNull("A copied text must offer a row", row) - val label = keyboard.findViewWithTag("suggestion-text") + val label = harness.awaitMain { keyboard.findViewWithTag("suggestion-text") } assertNotNull("The row carries the text itself", label) + // Every text outside a key cap is drawn at the reference scale's + // size, so the row reads at the candidate font as drawn. assertEquals( "The quick-input text reads at the candidate font", - ImeTypographyTokens.CANDIDATE_SP, - label!!.textSize / keyboard.resources.displayMetrics.scaledDensity, + ImeTypographyTokens.CANDIDATE_SP * + ImeReferenceSizing.scale(keyboard.context, keyboard.width), + harness.awaitMain { label!!.textSize / keyboard.resources.displayMetrics.scaledDensity }, 0.5f, ) - assertNotNull("and the glyph that says where it came from", keyboard.findViewWithTag("suggestion-icon")) - val badge = keyboard.findViewWithTag("suggestion-icon")!! + assertNotNull( + "and the glyph that says where it came from", + harness.awaitMain { keyboard.findViewWithTag("suggestion-icon") }, + ) + val badge = harness.awaitMain { keyboard.findViewWithTag("suggestion-icon")!! } assertEquals( "The glyph rides at `fi_toolbar_tip_superscript` against the candidate font", - (label.textSize * QuickInputRowPolicy.BADGE_FONT_RATIO + 0.5f).toInt().toLong(), + (label!!.textSize * QuickInputRowPolicy.BADGE_FONT_RATIO + 0.5f).toInt().toLong(), badge.height.toLong(), ) assertTrue( "as a superscript above the text, not a centred icon", - badge.top - row.top < row.height / 2, + badge.top - row!!.top < row.height / 2, ) val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) assertEquals( @@ -121,44 +132,51 @@ class QuickInputRowInstrumentedTest { tokens.candidateText, label.currentTextColor, ) - val close = keyboard.findViewWithTag("autofill-close") + val close = harness.awaitMain { keyboard.findViewWithTag("autofill-close") } assertNotNull("The row ends with the reference's close key", close) - assertEquals(View.VISIBLE, close.visibility) - assertEquals( + assertEquals(View.VISIBLE, harness.awaitMain { close!!.visibility }) + assertFalse( "The strip's back control belongs to the system's chips", - View.GONE, - keyboard.findViewWithTag("association-back")?.visibility, + harness.awaitMain { + keyboard.findViewWithTag("association-back")?.visibility == View.VISIBLE + }, ) } } @Test fun tappingTheRowTypesTheTextAndTheCloseKeyDropsIt() { - withClipboard("123456", { keyboard, copy -> + withClipboard("123456") { harness, keyboard, copy -> characters.clear() - keyboard.findViewWithTag("suggestion-row")!!.performClick() + harness.awaitMain { + keyboard.findViewWithTag("suggestion-row")!!.performClick() + true + } assertTrue("A tap types the copied text", characters.contains("123456")) - assertNull( + assertTrue( "The row goes away with the tap", - keyboard.findViewWithTag("suggestion-row"), + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") == null }, ) // A later copy is a new suggestion, so the row is back. copy("654321") - keyboard.refreshSuggestions() + harness.awaitMain { keyboard.refreshSuggestions(); true } assertNotNull( "A fresh copy brings the row back", - keyboard.findViewWithTag("suggestion-row"), + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") }, ) - keyboard.findViewWithTag("autofill-close")!!.performClick() - assertNull( + harness.awaitMain { + keyboard.findViewWithTag("autofill-close")!!.performClick() + true + } + assertTrue( "The close key drops the row for this text", - keyboard.findViewWithTag("suggestion-row"), + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") == null }, ) - keyboard.refreshSuggestions() - assertNull( + harness.awaitMain { keyboard.refreshSuggestions(); true } + assertTrue( "and keeps it away until the clipboard changes", - keyboard.findViewWithTag("suggestion-row"), + harness.awaitMain { keyboard.findViewWithTag("suggestion-row") == null }, ) - }) + } } } diff --git a/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt index c93837d2..bc387f6c 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SymbolHistoryInstrumentedTest.kt @@ -66,6 +66,9 @@ class SymbolHistoryInstrumentedTest { @Test fun theSymbolBoardListsTheSymbolsTheUserTyped() { + // The store outlives this class: another test that commits a symbol + // through the service leaves its own entry behind. + SymbolRecentRepository.clear(context) SymbolRecentRepository.record(context, "★") SymbolRecentRepository.record(context, "→") withKeyboard { keyboard -> 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 5f8130b6..3921beba 100644 --- a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -3,6 +3,7 @@ package llc.slacker.openime.widget import android.content.Context import android.content.res.ColorStateList import android.graphics.Color +import android.text.TextPaint import android.text.TextUtils import android.util.TypedValue import android.view.Gravity @@ -108,6 +109,23 @@ class ImeKeyView( private val baseMainTextSize = mainTextSize private var fitMain = fitMainText + /** + * True when the label must be shrunk to the width the cap really has — a key + * whose whole label is one wide glyph (the marks beside the space bar). The + * platform's autosize cannot do this: it rejects a candidate size only when + * the text needs more lines than `maxLines` or more height than the view has + * (`TextView#suggestedSizeFitsInSpace`, + * `frameworks/base/core/java/android/widget/TextView.java:11549-11577`), so a + * single line that is simply too wide keeps the largest size and runs out of + * the key. [fitMainLabelToWidth] shrinks it instead. + */ + private var shrinkToWidth = false + + /** The size the label asks for before any shrinking, and the floor below it. */ + private var wantedMainTextPx = 0f + private var smallestMainTextPx = 0f + private val fitPaint = TextPaint() + /** * The key's own margins in reference units: the cap is painted inside the * cell by this much, as the reference's key classes give every key its own @@ -245,7 +263,8 @@ class ImeKeyView( } }.apply { this.text = text - setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(mainTextSize)) + setWantedMainText(mainTextSize) + setTextSize(TypedValue.COMPLEX_UNIT_PX, wantedMainTextPx) gravity = Gravity.CENTER isAllCaps = false includeFontPadding = false @@ -359,24 +378,62 @@ class ImeKeyView( /** Update key typography without rebuilding the keyboard hierarchy. */ fun applyMainTextScale(scale: Float) { val sp = baseMainTextSize * scale.coerceAtLeast(0.4f) + setWantedMainText(sp) mainTextView?.apply { if (fitMain) { // Function labels ("中/英", "完成") must keep shrinking to fit their key. setAutoSizeTextTypeUniformWithConfiguration( - labelPx((sp * 0.68f).coerceAtLeast(10f)).toInt(), - labelPx(sp.coerceAtLeast(12f)).toInt(), + smallestMainTextPx.toInt(), + wantedMainTextPx.toInt(), 1, TypedValue.COMPLEX_UNIT_PX, ) } else { setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE) - setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(sp)) + setTextSize(TypedValue.COMPLEX_UNIT_PX, wantedMainTextPx) } } secondaryTextView?.setTextSize( TypedValue.COMPLEX_UNIT_PX, labelPx(ImeTypographyTokens.CAPTION_SP) * (toPx(100) / (100f * density)), ) + if (shrinkToWidth) fitMainLabelToWidth() + } + + /** The size the label asks for at [sp], and the smallest it may be shrunk to. */ + private fun setWantedMainText(sp: Float) { + wantedMainTextPx = labelPx(sp) + smallestMainTextPx = labelPx((sp * 0.68f).coerceAtLeast(10f)) + } + + /** + * Shrinks a single-line label until it fits the cap it is drawn in, never + * below [smallestMainTextPx]. Idempotent: a pass that finds the label + * already fitting (or already at the size it computes) changes nothing, so + * the extra layout a size change asks for settles after one round. + */ + private fun fitMainLabelToWidth() { + val view = mainTextView ?: return + val available = view.width - view.paddingLeft - view.paddingRight + if (available <= 0 || wantedMainTextPx <= 0f) return + val label = view.text?.toString().orEmpty() + if (label.isEmpty()) return + fitPaint.set(view.paint) + fitPaint.textSize = wantedMainTextPx + val needed = fitPaint.measureText(label) + // A hair under the width: the glyph then sits inside its cap even after + // the platform's own rounding of a pixel. + val target = if (needed <= available) { + wantedMainTextPx + } else { + (wantedMainTextPx * (available - 0.5f) / needed).coerceAtLeast(smallestMainTextPx) + } + if (view.textSize != target) view.setTextSize(TypedValue.COMPLEX_UNIT_PX, target) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (shrinkToWidth) fitMainLabelToWidth() } /** True when the main label is fully visible (no ellipsis, nothing clipped). Used by tests. */ @@ -419,15 +476,13 @@ class ImeKeyView( * keeps its label either way, as it does for the function keys. */ fun allowLabelShrink() { - fitMain = true - // The same controlled range the function keys keep, so a later - // [applyMainTextScale] leaves the key in the shape it asks for. - val smallest = labelPx((baseMainTextSize * 0.68f).coerceAtLeast(10f)).toInt() - val largest = labelPx(baseMainTextSize.coerceAtLeast(12f)).toInt() + shrinkToWidth = true mainTextView?.apply { ellipsize = null - setAutoSizeTextTypeUniformWithConfiguration(smallest, largest, 1, TypedValue.COMPLEX_UNIT_PX) + setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE) + setTextSize(TypedValue.COMPLEX_UNIT_PX, wantedMainTextPx) } + requestLayout() } fun setIcon(value: Int) { From 110b2c872e237f6b575df6d69077d745e0fef89a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:09:01 +0800 Subject: [PATCH 34/94] Read the rail in the one font and margin the skin gives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's rail item class sets one face for every cell of the list (`cls_vertical_symbol_list_button`, `style.xml:1025-1027`) and that face is 47 units (`fi_vertical_symbol_list_text`, `style.xml:112`) — our sp scale steps down from the reference's numbers by three, as `key_letter` 57 is our 19 and `comp_font` 40 our 13. We drew marks at 20 to 24sp by glyph and readings at 20sp. The list's own panel keeps `margin="2.5,2.75,2.5,2.75"` (`style.xml:1035-1036`, and the stroke sidebar at `:1041-1042`), not a uniform half key gap. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/keyboard/ImeThemeApplier.kt | 14 +++++++++++--- .../keyboard/NineKeySymbolRailController.kt | 4 ++-- .../slacker/openime/keyboard/SymbolRailRenderer.kt | 10 +++++++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 124fe91d..60b9ec31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 九键左侧栏统一为参考皮肤的那一个字号:符号、拼音读法和网址条目都按参考的 47 单位(约 15.5sp,原来符号按字形 20–24sp、读法 20sp);整栏面板也改按参考给这个列表自己的边距绘制(左右 2.5、上下 2.75 单位,原来四边统一 3 单位),与旁边的键位对齐。 - 键帽圆角改为参考皮肤的 6 单位(原来 8):参考皮肤里白键、灰功能键、符号格、历史符号格和三个返回键写的都是 bkround 6。 - 长按气泡改为参考皮肤自己的底色与圆角:浅色是不透明白、深色是实色灰(96,96,96),不再用键盘上那种半透明的白;圆角按参考的 8 单位(原来 12),并且去掉我们自加的描边,只留参考的那层阴影(原来是半透明底加一圈边,深色下能透出底下的键)。 - 键帽边距改为按参考皮肤每一类按键自己的数值绘制:26 键是左右 2.25、上下 4.75 单位(原来所有键统一 6),九键、笔画和数字页是 2.5 与 2.75;26 键那一行的「分词」「退格」两个大键朝字母区一侧各留 8 单位。键位大小和键盘高度都不变,只是键帽在格子里按参考的比例收边。 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 6b4c91f0..2158b791 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -325,11 +325,19 @@ internal class ImeThemeApplier( (view.tag == "nine-punct-stack" || view.tag == "digits-symbol-scroll") && content in setOf("nine-pinyin-panel", "nine-symbol-scroll-content", "digits-symbol-scroll-content") ) { - // Reading or symbol list: the whole rail is one panel and its items are flat. - // Inset by half a key gap so the panel lines up with the key grid. + // Reading or symbol list: the whole rail is one panel and its items are + // flat. The panel is drawn inside the column with the margins the + // reference gives the list itself — 2.5 units sideways, 2.75 up and + // down (`cls_vertical_symbol_list`, `style.xml:1035-1036`) — so it + // lines up with the key grid beside it. + val unit = view.resources.displayMetrics.density * referenceScale() + fun inset(dp: Float): Int = Math.round(dp * unit) view.background = InsetDrawable( ImeDrawableFactory.rounded(t.sideKeyBackground, toPx(ImeGeometryTokens.KEY_RADIUS_DP)), - toPx(ImeGeometryTokens.KEY_GAP_DP) / 2, + inset(ImeGeometryTokens.NINE_KEY_MARGIN_X_DP), + inset(ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP), + inset(ImeGeometryTokens.NINE_KEY_MARGIN_X_DP), + inset(ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP), ) } else if (view.tag == "nine-punct-stack" || view.tag == "digits-symbol-scroll") { view.background = null diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index ec42a98f..63f54ef0 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -293,8 +293,8 @@ internal class NineKeySymbolRailController( const val PINYIN_PANEL_TAG = "nine-pinyin-panel" const val MAX_VISIBLE_PATHS = 16 - /** The symbol cells' own size: one list, one text size for both kinds of item. */ - const val RAIL_ITEM_TEXT_SP = 20f + /** The reference reads and marks the same way: one list, one item font. */ + const val RAIL_ITEM_TEXT_SP = SymbolRailRenderer.ITEM_TEXT_SP const val RAIL_ITEM_TYPEFACE = "sans-serif-medium" } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt index 1b624ac1..ed20ce18 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -22,6 +22,9 @@ internal object SymbolRailRenderer { private const val CELL_HEIGHT_DP = 54 private const val FADING_EDGE_DP = 18 + /** The rail's one item font: the reference's 47-unit face over its own step of 3. */ + const val ITEM_TEXT_SP = 15.5f + fun build( context: Context, railTag: String, @@ -178,7 +181,12 @@ internal object SymbolRailRenderer { onFeedback: () -> Unit, ): TextView = InkCenteredTextView(context).apply { text = symbol - textSize = when (symbol) { "!", "!", "?", "?" -> 24f; ",", "、", "%", "+", "−", "-" -> 22f; else -> 20f } + // One font for the whole list, marks and readings alike: the reference's + // item class gives every rail cell `fi_vertical_symbol_list_text` + // (`style.xml:1025-1027`), a 47-unit face — 47 ÷ 3, the step between the + // reference's own numbers and our sp scale (`key_letter` 57 is our 19, + // `comp_font` 40 our 13). + textSize = ITEM_TEXT_SP gravity = Gravity.CENTER tag = "$tagPrefix$symbol" contentDescription = symbol From ae1e6f23a1ad6cb20e960be5b401609908e54bab Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:19:54 +0800 Subject: [PATCH 35/94] Read every key face at the size the reference gives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skin gives each kind of key its own font, and we were borrowing neighbours' sizes: the nine-key letters wore the candidate size (18) where `key_letter9` is 48 units (16), the English row wore the pinyin letter size (19) although `key_letter_english` is 60 (20) — the shift is what turns an English key back into the pinyin face (`input_kbd_english26.xml:51`) — the stroke cells wore the symbol size (24) where `key_stroke` is 60 (20), and the numeric page wore it too where `key_number9` is 54 (18) (`input_kbd_number.xml:17-34`). The `@#` cells keep their own larger faces: `key_letter9_at_symbol` and `key_stroke_at_symbol` are both 54, and 通配 is `key_stroke_wildcard` at 44 — the same 44 the reference's function keys use. The press tip is not the long-press bubble. It has its own class (`cls_touch_tip_button`, `style.xml:1168-1173`): rounder than the bubble (bkround 12 against 8) and set in `key_touch_tip` at 87 units (29). The bubble button's own face is `fi_fusiyin_bubble_button` at 60 (20) — we listed its variants at body size. Co-Authored-By: Claude Code --- CHANGELOG.md | 4 ++- .../openime/keyboard/KeyPopupController.kt | 14 +++++++--- .../keyboard/NumericKeyboardRenderer.kt | 2 +- .../keyboard/Pinyin26KeyboardRenderer.kt | 9 ++++++- .../keyboard/Pinyin9KeyboardRenderer.kt | 12 ++++++--- .../keyboard/StrokeKeyboardRenderer.kt | 21 +++++++++++++-- .../slacker/openime/theme/ImeDesignTokens.kt | 26 +++++++++++++++++++ 7 files changed, 76 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b9ec31..ea5148ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,11 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 - 九键左侧栏统一为参考皮肤的那一个字号:符号、拼音读法和网址条目都按参考的 47 单位(约 15.5sp,原来符号按字形 20–24sp、读法 20sp);整栏面板也改按参考给这个列表自己的边距绘制(左右 2.5、上下 2.75 单位,原来四边统一 3 单位),与旁边的键位对齐。 - 键帽圆角改为参考皮肤的 6 单位(原来 8):参考皮肤里白键、灰功能键、符号格、历史符号格和三个返回键写的都是 bkround 6。 -- 长按气泡改为参考皮肤自己的底色与圆角:浅色是不透明白、深色是实色灰(96,96,96),不再用键盘上那种半透明的白;圆角按参考的 8 单位(原来 12),并且去掉我们自加的描边,只留参考的那层阴影(原来是半透明底加一圈边,深色下能透出底下的键)。 +- 长按气泡改为参考皮肤自己的底色与圆角:浅色是不透明白、深色是实色灰(96,96,96),不再用键盘上那种半透明的白;圆角按参考的 8 单位(原来 12),并且去掉我们自加的描边,只留参考的那层阴影(原来是半透明底加一圈边,深色下能透出底下的键)。气泡里每个可选符号改用参考皮肤自己的字号(`fi_fusiyin_bubble_button` 60 单位,原为正文 14sp)。 +- 按下时显示的放大键位改为参考皮肤「按下提示」这一类自己的样式:圆角 12 单位(这一类在参考皮肤里是 `cls_touch_tip_button`,比长按气泡圆角更大),字号按参考的 87 单位(29sp,原来 28sp)。「分词」「空格」这类多字功能键在同一宽度里放不下这么大的字,仍用正文号。 - 键帽边距改为按参考皮肤每一类按键自己的数值绘制:26 键是左右 2.25、上下 4.75 单位(原来所有键统一 6),九键、笔画和数字页是 2.5 与 2.75;26 键那一行的「分词」「退格」两个大键朝字母区一侧各留 8 单位。键位大小和键盘高度都不变,只是键帽在格子里按参考的比例收边。 - 大系统字号下,26 键底行的「,。」「,」「。」几个标点键不再被省略号截断:参考皮肤给这几个键单独的字号(72 对 57 单位),我们让这个标记在键内收缩到合适大小。 - 复制文字或收到验证码后的建议条改为参考皮肤的样式:不再是一整块带底色的胶囊,而是按候选字号显示的文字加一个来源小标(消息/剪贴板;参考皮肤里这个标是上标,约候选字号的 53%,贴着这一行的上沿,文字框也按参考的比例上下留白),右端是参考皮肤自己的关闭键;点一下仍然直接上屏,关闭后同一条内容不再出现(换新内容会重新出现)。系统输入法提供的自动填充建议仍用原来的返回键。 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 d155a011..8f9b696a 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -171,15 +171,18 @@ internal class KeyPopupController( val popupHeight = dp(ImeGeometryTokens.KEY_POPUP_HEIGHT_DP) previewPopup.apply { this.text = text + // The reference's tip is one size (`key_touch_tip`); a whole + // function-key label in the same box would run out of the tip, so + // only the character keys take it. textSize = if (text.length > 1) { ImeTypographyTokens.BODY_SP } else { - ImeTypographyTokens.DISPLAY_SP + ImeTypographyTokens.TOUCH_TIP_SP } setTextColor(t.keyText) background = ImeDrawableFactory.rounded( ImeSurfacePolicy.keyPopupBackground(t), - dp(ImeGeometryTokens.KEY_POPUP_RADIUS_DP), + dp(ImeGeometryTokens.TOUCH_TIP_RADIUS_DP), ) } @@ -236,7 +239,7 @@ internal class KeyPopupController( TextView(host.context).apply { choiceCells += this to symbol text = symbol - textSize = ImeTypographyTokens.BODY_SP + textSize = ImeTypographyTokens.POPUP_ITEM_SP includeFontPadding = false gravity = Gravity.CENTER setTextColor(t.keyText) @@ -248,7 +251,10 @@ internal class KeyPopupController( isClickable = true isFocusable = true contentDescription = "输入$symbol" - setPadding(dp(11), 0, dp(11), 0) + // One character per cell (the reference's bubble lists a + // key's variants): the padding leaves room for the + // 60-unit face inside the 40dp cell. + setPadding(dp(9), 0, dp(9), 0) setOnClickListener { hide() feedback() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt index c32a670e..8e4df5ba 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -158,7 +158,7 @@ internal class NumericKeyboardRenderer( /** A digit cell: the reference draws every digit on a white key (`cls_keyboard_number9_button`). */ private fun digitKey(digit: String, value: String = digit, tag: String = "key:$digit"): ImeKeyView = - createKey(digit, false, ImeTypographyTokens.SYMBOL_SP) { onCommitCharacter(value) }.apply { + createKey(digit, false, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter(value) }.apply { this.tag = tag markWhiteKey(this) } 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 98e37ea0..11244b85 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -249,7 +249,14 @@ internal class Pinyin26KeyboardRenderer( } // The corner hint is a digit or symbol; swipe up or long press types it. val secondary = if (hintsEnabled()) LetterHintPolicy.hint(character, english) else null - return createKey(main, false, secondary, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { + // English wears its own face while it is lowercase; the shift turns the + // key into the pinyin one, as the reference's two fonts do. + val textSize = if (english && shiftState == ShiftState.LOWERCASE) { + ImeTypographyTokens.KEY_LETTER_ENGLISH_SP + } else { + ImeTypographyTokens.KEY_LETTER_COMPACT_SP + } + return createKey(main, false, secondary, textSize, 0) { onLetter(base) }.apply { tag = "key:$base" 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 d807843a..da59a820 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -183,7 +183,7 @@ internal class Pinyin9KeyboardRenderer( LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL digits.forEach { digit -> - addView(createKey(digit.toString(), false, null, ImeTypographyTokens.KEY_LETTER_SP) { + addView(createKey(digit.toString(), false, null, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter(digit.toString()) }.apply { tag = "key-split-number:$digit" @@ -201,7 +201,7 @@ internal class Pinyin9KeyboardRenderer( addView(createKey(",", true, null, ImeTypographyTokens.BODY_SP) { onCommitCharacter(",") }.apply { tag = "key-punctuation"; markSideKey(this) }, flexKeyParams()) - addView(createKey("0", false, null, ImeTypographyTokens.KEY_LETTER_SP) { + addView(createKey("0", false, null, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter("0") }.apply { tag = "key-split-number:0"; markWhiteKey(this) }, flexKeyParams()) addView(createKey("互换", true, null, ImeTypographyTokens.BODY_SP, onMirror).apply { @@ -225,7 +225,10 @@ internal class Pinyin9KeyboardRenderer( labels, false, digit, - ImeTypographyTokens.CANDIDATE_SP, + // The reference gives the `@#` key its own, larger face + // (`key_letter9_at_symbol`, 54 units) and the letters + // `key_letter9`, 48 (`input_kbd_pinyin9.xml:23-28`, 32). + if (atSymbol) NINE_KEY_SYMBOL_SP else ImeTypographyTokens.NINE_KEY_LETTER_SP, ) { onNineKey(digit) }.apply { @@ -274,6 +277,9 @@ internal class Pinyin9KeyboardRenderer( ) private companion object { + /** The `@#` key's own face: `key_letter9_at_symbol`, 54 units. */ + const val NINE_KEY_SYMBOL_SP = 18f + val NINE_KEYS = listOf( listOf("1" to "@#", "2" to "ABC", "3" to "DEF"), listOf("4" to "GHI", "5" to "JKL", "6" to "MNO"), diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt index 7faf197b..93aff5ef 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -139,11 +139,12 @@ internal class StrokeKeyboardRenderer( /** One cell of the grid: a stroke, 通配, or a mark the cell commits itself. */ private fun strokeCellKey(cell: StrokeGridPolicy.Cell): ImeKeyView { val stroke = cell.stroke + val textSize = strokeTextSize(cell) val key = if (stroke != null) { - createKey(cell.label, false, cell.corner, ImeTypographyTokens.SYMBOL_SP) { onStroke(stroke) } + createKey(cell.label, false, cell.corner, textSize) { onStroke(stroke) } } else { val commit = cell.commit.orEmpty() - createKey(cell.label, false, cell.corner, ImeTypographyTokens.SYMBOL_SP) { onCommitCharacter(commit) } + createKey(cell.label, false, cell.corner, textSize) { onCommitCharacter(commit) } } return key.apply { tag = cell.tag @@ -168,6 +169,16 @@ internal class StrokeKeyboardRenderer( } } + /** The reference's 笔画 faces: one per kind of cell (`style.xml:9-13`). */ + private fun strokeTextSize(cell: StrokeGridPolicy.Cell): Float = when { + // `key_stroke_wildcard`, 44 units — the same 44 its function keys use. + cell.label == WILDCARD_LABEL -> ImeTypographyTokens.BODY_SP + // `key_stroke_at_symbol` / `key_stroke_punctuation`, both 54. + cell.commit != null -> STROKE_MARK_SP + // The five strokes themselves: `key_stroke`, 60 units. + else -> STROKE_SP + } + private fun keyParams(heightDp: Int, weight: Float = 1f) = LinearLayout.LayoutParams(0, toPx(heightDp), weight) @@ -180,5 +191,11 @@ internal class StrokeKeyboardRenderer( private companion object { const val WILDCARD_LABEL = "通配" const val AT_SYMBOL_TAG = "key-stroke:at" + + /** `key_stroke`, 60 units over the reference's step of 3. */ + const val STROKE_SP = 20f + + /** `key_stroke_at_symbol` and `key_stroke_punctuation`, both 54 units. */ + const val STROKE_MARK_SP = 18f } } 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 7686a836..807b03ac 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -93,6 +93,12 @@ internal object ImeGeometryTokens { * (`style.xml:1128-1133`). */ const val KEY_POPUP_RADIUS_DP = 8 + /** + * The tip that shows the pressed key enlarged is a class of its own, and + * rounder: `cls_touch_tip_button` rounds at 12 with its own small shadow + * (`style.xml:1168-1173`). + */ + const val TOUCH_TIP_RADIUS_DP = 12 const val KEY_POPUP_MIN_WIDTH_DP = 40 const val KEY_POPUP_WIDTH_SCALE = 1.08f const val KEY_POPUP_VERTICAL_GAP_DP = 8 @@ -147,12 +153,32 @@ internal object ImeTypographyTokens { /** The open Pinyin string above the candidates (`comp_font` size 40). */ const val COMPOSITION_SP = 13f + + /** The pressed key shown enlarged: `key_touch_tip`, 87 units (`style.xml:129`). */ + const val TOUCH_TIP_SP = 29f + + /** One choice inside the long-press bubble: `fi_fusiyin_bubble_button`, 60 units. */ + const val POPUP_ITEM_SP = 20f const val TITLE_SP = 16f const val CANDIDATE_SP = 18f const val GLYPH_SP = 20f const val KEY_LETTER_SP = 21f /** Letters on the 26-key surface, one step below [KEY_LETTER_SP]. */ const val KEY_LETTER_COMPACT_SP = 19f + + /** + * English letters on the 26-key surface keep a face of their own: the + * reference sets `key_letter_english` (60 units) on the lowercase rows and + * the pinyin face `key_letter` (57) on the shifted ones + * (`input_kbd_english26.xml:51`). + */ + const val KEY_LETTER_ENGLISH_SP = 20f + + /** Nine-key letters: `key_letter9`, 48 units over the reference's step of 3. */ + const val NINE_KEY_LETTER_SP = 16f + + /** Every digit the reference draws, nine-key or numeric: `key_number9`, 54 units. */ + const val KEY_NUMBER_SP = 18f const val SYMBOL_SP = 24f const val DISPLAY_SP = 28f From a0ccbabe38f045d6bc59c30fcba6248e99c0d4b1 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:32:58 +0800 Subject: [PATCH 36/94] Split the composing strip the way the reference splits its own `input_toolbar_typing.xml:4-30` gives the pinyin line 20 height units and the candidate row 36, with a 2-unit gap under both. Our 60dp strip divided those as 18 and 42, which left the pinyin line cramped and the candidate row roomier than the reference's proportion. 21 and 37 hold the reference's own ratio and keep our zone's 2 units of gap inside the strip, so the keyboard below does not move. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../java/llc/slacker/openime/keyboard/ImeTopZone.kt | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5148ec..f4d92c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 +- 打字时顶部的拼音行与候选行改按参考皮肤的比例分配(参考把这一条切成 20 : 36 并留 2 单位空隙):拼音行 21dp、候选行 37dp(原来 18 与 42)。整个顶栏高度不变,键盘位置不动。 - 九键左侧栏统一为参考皮肤的那一个字号:符号、拼音读法和网址条目都按参考的 47 单位(约 15.5sp,原来符号按字形 20–24sp、读法 20sp);整栏面板也改按参考给这个列表自己的边距绘制(左右 2.5、上下 2.75 单位,原来四边统一 3 单位),与旁边的键位对齐。 - 键帽圆角改为参考皮肤的 6 单位(原来 8):参考皮肤里白键、灰功能键、符号格、历史符号格和三个返回键写的都是 bkround 6。 - 长按气泡改为参考皮肤自己的底色与圆角:浅色是不透明白、深色是实色灰(96,96,96),不再用键盘上那种半透明的白;圆角按参考的 8 单位(原来 12),并且去掉我们自加的描边,只留参考的那层阴影(原来是半透明底加一圈边,深色下能透出底下的键)。气泡里每个可选符号改用参考皮肤自己的字号(`fi_fusiyin_bubble_button` 60 单位,原为正文 14sp)。 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 671aea89..82557658 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -39,9 +39,14 @@ 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 +/** + * Composing strip: a short pinyin line over the candidate row, 60dp in all. + * The reference splits its own strip 20 : 36 and leaves a 2-unit gap under it + * (`input_toolbar_typing.xml:4-30`); our zone keeps that gap inside itself, so + * the pinyin line takes the larger part of it than ours used to: 21 and 37. + */ +private const val COMPOSITION_LINE_DP = 21 +private const val CANDIDATE_ROW_DP = 37 /** * The reference's candidate strip is `[4] 候选列表(308) [1] [3] 更多候选(40) [4]` From fe9c987c97b1d626dca8a3ea02553186ec4d2051 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:39:58 +0800 Subject: [PATCH 37/94] Let the key fills trade places on press, and put the shadow back under them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's two key classes swap their fills when pressed: `keybutton_pushed_bk` is `keybutton_func_bk` — a white key takes the gray — and `keybutton_func_pushed_bk` is `keybutton_bk` — a gray key takes the white (`values/colors.xml:19-23`, and the same pair over the dark skin's translucent fills, `values/dark_colors.xml:18-22`). We shaded each key a little darker instead, so a pressed function key read as "deeper" rather than as the reference's flash of white, and a pressed white key barely moved. The theme-colored key (确定) keeps its own pressed color, as `cls_button_enter` does. The line a cap sits on was drawn in that pressed color, which in the light skin is nearly invisible. Every key class in the reference sets `shadow_y_offset="1"` over `keybutton_white_shadow` / `keybutton_gray_shadow` — black at 30% light, 40% dark (`style.xml:224-227`, `:276-281`) — so the cap now sits on that line. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 ++ .../openime/keyboard/ImeThemeApplier.kt | 18 ++++++++++++++---- .../slacker/openime/theme/ImeDesignTokens.kt | 10 ++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d92c74..00f29f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 +- 按键按下时改为参考皮肤的对调效果:参考皮肤里白键按下的颜色就是灰键的底色(`keybutton_pushed_bk` 等于 `keybutton_func_bk`),灰键按下则变成白键的底色(`keybutton_func_pushed_bk` 等于 `keybutton_bk`),深浅两套皮肤都是这一对颜色对调;原来两种键按下都是各自加深一点。主题色键(确定)仍是自己的按下色。 +- 每个键帽下沿改回参考皮肤的那条 1 单位投影线(浅色 30% 黑、深色 40% 黑,参考所有键类都写了 `shadow_y_offset="1"`);原来这条线取的是按键的按下色,浅色下几乎看不见,深色下会发白。 - 打字时顶部的拼音行与候选行改按参考皮肤的比例分配(参考把这一条切成 20 : 36 并留 2 单位空隙):拼音行 21dp、候选行 37dp(原来 18 与 42)。整个顶栏高度不变,键盘位置不动。 - 九键左侧栏统一为参考皮肤的那一个字号:符号、拼音读法和网址条目都按参考的 47 单位(约 15.5sp,原来符号按字形 20–24sp、读法 20sp);整栏面板也改按参考给这个列表自己的边距绘制(左右 2.5、上下 2.75 单位,原来四边统一 3 单位),与旁边的键位对齐。 - 键帽圆角改为参考皮肤的 6 单位(原来 8):参考皮肤里白键、灰功能键、符号格、历史符号格和三个返回键写的都是 bkround 6。 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 2158b791..ca6e73a7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -105,15 +105,25 @@ internal class ImeThemeApplier( } val pressedColor = when { primary -> ImeSurfacePolicy.primaryPressed(t) - side -> ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t) - function -> ImeSurfacePolicy.pressedSurface(t.functionKeyBackground, t) - else -> ImeSurfacePolicy.pressedSurface(color, t) + // The reference's two key fills trade places on press: a white key + // takes the gray fill (`keybutton_pushed_bk`, `values/colors.xml:21`, + // is `keybutton_func_bk`, `:20`) and a gray key takes the white one + // (`keybutton_func_pushed_bk`, `:22`, is `keybutton_bk`, `:19`). The + // dark skin swaps the same pair over its translucent fills + // (`values/dark_colors.xml:18-22`). + side || function -> t.lightKeyBackground + else -> t.functionKeyBackground } view.applyMainTextScale(keyMainTextScale()) val keyRadius = toPx(ImeGeometryTokens.KEY_RADIUS_DP) val keyFace = statefulRounded(color, pressedColor, keyRadius) - val keyCap = ImeDrawableFactory.keyCap(keyFace, pressedColor, keyRadius, toPx(1)) + val keyCap = ImeDrawableFactory.keyCap( + keyFace, + ImeSurfacePolicy.keyShadow(t), + keyRadius, + toPx(1), + ) // The cap is painted inside the cell by the key's own margins, as the // reference's key classes set them (2.25 × 4.75 on the 26-key board, // 2.5 × 2.75 on the nine-key family, 8 on a big side key's inner side). 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 807b03ac..8fbcfdc5 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -330,6 +330,16 @@ internal object ImeSurfacePolicy { if (isDark(tokens)) 0.12f else 0.08f, ) + /** + * The 1-unit line a key cap sits on: every key class in the reference sets + * `shadow_y_offset="1"` with `keybutton_white_shadow` or + * `keybutton_gray_shadow` — black at 30% on the light skin, black at 40% on + * the dark one (`style.xml:224-227`, `:276-281`; + * `values/colors.xml:27-28`, `values/dark_colors.xml:23-24`). + */ + fun keyShadow(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#66000000") else Color.parseColor("#4D000000") + fun primaryPressed(tokens: ImeTheme.Tokens): Int = adjustHslLightness( tokens.primary, From f9ceba7bda8eee4aa30967f9e60420316c9179e0 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:11:18 +0800 Subject: [PATCH 38/94] Fill each pressed surface with the reference's own colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pressed state in the reference is named per class, not derived from the surrounding neutral, and three of them read the other way round from ours: - A candidate cell brightens to `canditem_pushedbk` (white, white 8% in the dark skin) instead of darkening, and the first candidate presses the same way as the rest. The expanded page keeps its own shade (`morecands_button_pushedbk`, black 6% / black 4%). - The accent key — 确定/发送/搜索 — turns into a white key under the finger (`action_button_pushedbk`) and its label goes dark (`action_button_pushedtext`), so its text and icon now swap with the cap. - A rail row (symbols, readings, URLs) fills with the white key's colour (`cls_vertical_symbol_list_button` → `keybutton_bk`). A symbol-category tab is neutral in the reference (`cls_input_kbd_symbol_option`): one pill colour for pressed and chosen, labels at 60%/85% of the key text colour. A bubble cell fills with the accent and whitens its glyph (`cls_fusiyin_bubble_button`). Co-Authored-By: Claude Code --- CHANGELOG.md | 8 +- .../keyboard/CandidateBarController.kt | 12 ++- .../openime/keyboard/ImeThemeApplier.kt | 82 +++++++++++++------ .../openime/keyboard/KeyPopupController.kt | 17 +++- .../slacker/openime/theme/ImeDesignTokens.kt | 70 ++++++++++++++++ .../llc/slacker/openime/widget/ImeKeyView.kt | 16 ++++ 6 files changed, 170 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f29f53..76b0a70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 - 按键按下时改为参考皮肤的对调效果:参考皮肤里白键按下的颜色就是灰键的底色(`keybutton_pushed_bk` 等于 `keybutton_func_bk`),灰键按下则变成白键的底色(`keybutton_func_pushed_bk` 等于 `keybutton_bk`),深浅两套皮肤都是这一对颜色对调;原来两种键按下都是各自加深一点。主题色键(确定)仍是自己的按下色。 +- 候选项按下时改为参考皮肤的候选底色(浅色是不透明白、深色是 8% 白),所有候选一格一样,第一个候选不再单独按主题色按压;候选栏右端的表情与展开键、符号页工具栏里的最近符号格也是同一个底色。原来是在各自的底色上加深一点,浅色下是按下去变暗。 +- 展开候选页的候选格按下改为参考皮肤自己给这一类的底色(浅色 6% 黑、深色 4% 黑,深浅都是压暗一点);原来用的是白色半透明,深色下会发亮。 +- 九键、笔画和数字页左侧栏的条目(符号、拼音读法、网址)按下改为白键的底色(参考这一类写的按下色就是 `keybutton_bk`,浅色白、深色 28% 白),与同一列旁边的键一致;原来是在灰色面板上加深一点。 +- 符号页底部的分类标签改为参考皮肤自己的样式:选中和按下是同一层浅灰胶囊(浅色 6% 黑、深色 9% 白,圆角胶囊),文字未选中 60%、选中 85%,都不再用主题色;原来选中项是主题色底的胶囊、未选中是次要文字色。 +- 主题色键(确定、发送、搜索)按下时改为白键的样子:参考皮肤里这个键按下换成「动作键按下底色」(浅色不透明白、深色 12% 白),键面文字同时转成深色(浅色 85% 黑);原来是把主题色压暗一点。工具页与切换键盘页里带主题色的磁贴仍是各自轻微的按下色。 +- 长按气泡里的符号按下时改为主题色底、字变白(参考这类的按下底就是主题色、按下文字为白);原来按下只是加深一点灰。 - 每个键帽下沿改回参考皮肤的那条 1 单位投影线(浅色 30% 黑、深色 40% 黑,参考所有键类都写了 `shadow_y_offset="1"`);原来这条线取的是按键的按下色,浅色下几乎看不见,深色下会发白。 - 打字时顶部的拼音行与候选行改按参考皮肤的比例分配(参考把这一条切成 20 : 36 并留 2 单位空隙):拼音行 21dp、候选行 37dp(原来 18 与 42)。整个顶栏高度不变,键盘位置不动。 - 九键左侧栏统一为参考皮肤的那一个字号:符号、拼音读法和网址条目都按参考的 47 单位(约 15.5sp,原来符号按字形 20–24sp、读法 20sp);整栏面板也改按参考给这个列表自己的边距绘制(左右 2.5、上下 2.75 单位,原来四边统一 3 单位),与旁边的键位对齐。 @@ -35,7 +41,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 九键左侧栏的拼音索引对齐参考产品:与常用符号共用同一个列表,每个读法一行、同一字号(不再逐条缩放),条目间是细线,当前读法不再高亮填色(读数由拼音串本身显示);只有换了数字才回到列表顶部,其余刷新保持滚动位置。 - 26 键拼音候选排序:读音正好覆盖已输入全部字母的词,现在排在只匹配了前半段、尾部还差几个字母的候选前面(输入 dang 先出「当、党、档」,输入 dangdi 先出「当地」),不再被「切了一半」的高频词挤下去。 - 九键左侧栏在网址、邮箱输入框里改为参考皮肤的网址栏(`.` `/` `www.` `.com` `.cn` `http` `wap.` `ftp:`),普通输入框仍是用自定义的标点。 -- 候选栏第一个候选改为主题色文字(中等字重、无底色),点按仍是主题色按压效果,不再是一个带底色的方块。 +- 候选栏第一个候选改为主题色文字(中等字重、无底色),不再是一个带底色的方块。 - 展开候选页对齐参考皮肤:顶部一排「全部 / 单字」筛选(「单字」只留单个汉字,默认「全部」);候选列表收进一张圆角卡片(圆角 11、描边 2);退格键浮在卡片右下角(占列表宽高各 25.2% / 20.2%,只圆右下角),点它删掉一个拼音字母、页面留在更短的候选列表上;网格里每个候选统一为平铺样式,不再把第一个填成强调色块。 - 26 键底行补齐参考产品的两个符号键:空格键左右各加一键(中文是「,」和「。」,英文是「,」和「.」,网址与邮箱输入框里换成「/」和「.」),底行各键宽度改按参考皮肤的实际权重;英文下「句号逗号」合并键改为以「.」为主标签、角标「,」(原来主副颠倒),上滑或长按输入角标。 - 九键左上角一格改为参考产品的「@#」键:主标签「@#」、角标仍是 1,点按与上滑都输入 1,长按弹出参考皮肤自带的符号列表「/ - _ 1 @ # .」;九键不再有「分词」键(参考产品只在 26 键那一行有分词键)。 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 0a4ba671..7849edf9 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -516,12 +516,6 @@ internal class CandidateBarController( word.typeface = if (index == 0) MEDIUM_WEIGHT else Typeface.DEFAULT val palette = tokens() - val pressedSurface = - if (index == 0) { - ImeSurfacePolicy.selectedSurface(palette) - } else { - palette.candidateBackground - } word.setTextColor( if (index == 0) { ImeSurfacePolicy.selectedText(palette) @@ -529,9 +523,13 @@ internal class CandidateBarController( palette.candidateText }, ) + // Every candidate cell, first or not, presses to the reference's one + // candidate fill: `canditem_pushedbk`, white (`style.xml:1076-1080`, + // `values/colors.xml:81`) or white at 8% on the dark skin + // (`values/dark_colors.xml:79`). item.background = statefulBackground( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(pressedSurface, palette), + ImeSurfacePolicy.candidatePressed(palette), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) item.setOnTouchListener { _, event -> 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 ca6e73a7..542c6493 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -81,7 +81,7 @@ internal class ImeThemeApplier( toPx(1).coerceAtLeast(1), ) view.background = LayerDrawable( - arrayOf(edge, statefulRounded(Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), 0)), + arrayOf(edge, statefulRounded(Color.TRANSPARENT, ImeSurfacePolicy.moreCandsPressed(t), 0)), ) view.setColors(t.keyText) return @@ -104,7 +104,10 @@ internal class ImeThemeApplier( else -> t.keyBackground } val pressedColor = when { - primary -> ImeSurfacePolicy.primaryPressed(t) + // The accent key turns into a white key under the finger + // (`action_button_pushedbk`); the tiles that also carry the accent + // keep the plain pressed tint of their own class. + primary -> ImeSurfacePolicy.actionKeyPressed(t) // The reference's two key fills trade places on press: a white key // takes the gray fill (`keybutton_pushed_bk`, `values/colors.xml:21`, // is `keybutton_func_bk`, `:20`) and a gray key takes the white one @@ -142,6 +145,9 @@ internal class ImeThemeApplier( primary -> { val onPrimary = ImeDrawableFactory.contrastText(t.primary) view.setColors(onPrimary, onPrimary, onPrimary) + // Under the finger the accent key wears the white face, so its + // label goes dark for as long as it is held. + view.setLabelColors(onPrimary, ImeDrawableFactory.contrastText(pressedColor)) } white -> view.setColors(t.lightKeyText, t.keySecondaryText, t.lightKeyText) @@ -173,18 +179,13 @@ internal class ImeThemeApplier( ) } "app-setting-group" -> view.background = null - "candidate-first-row" -> { - val selected = t.keyBackground - view.background = statefulRounded( - selected, - ImeSurfacePolicy.pressedSurface(selected, t), - toPx(ImeGeometryTokens.KEY_RADIUS_DP), - ) - } - "candidate-row" -> { + // A candidate cell, first or not, has no tile of its own and presses + // to the reference's one candidate fill (`canditem_pushedbk`: + // style.xml:1076-1080, `values/colors.xml:81`). + "candidate-first-row", "candidate-row" -> { view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + ImeSurfacePolicy.candidatePressed(t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -194,7 +195,7 @@ internal class ImeThemeApplier( "recent-symbol-cell" -> { view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + ImeSurfacePolicy.candidatePressed(t), toPx(RecentSymbolStripPolicy.CELL_RADIUS_DP), ) } @@ -392,7 +393,7 @@ internal class ImeThemeApplier( view.imageTintList = ColorStateList.valueOf(t.sideKeyText) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), + ImeSurfacePolicy.railItemPressed(t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -406,7 +407,7 @@ internal class ImeThemeApplier( view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + ImeSurfacePolicy.candidatePressed(t), toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } @@ -578,21 +579,52 @@ internal class ImeThemeApplier( } tag == "tab-active" -> { + // The symbol page's category tabs are the reference's own tab + // class: a neutral pill filled with `symbol_option_pushedbk`, + // never an accent tint (`cls_input_kbd_symbol_option`, + // style.xml:715-724). + val symbolTab = hasAncestorTag(view, "symbols-panel") val selected = ImeSurfacePolicy.selectedSurface(t) - view.setTextColor(ImeSurfacePolicy.selectedText(t)) + val fill = if (symbolTab) ImeSurfacePolicy.symbolTabFill(t) else selected + view.setTextColor( + if (symbolTab) { + ImeSurfacePolicy.symbolTabText(t, selected = true) + } else { + ImeSurfacePolicy.selectedText(t) + }, + ) view.background = statefulRounded( - selected, - ImeSurfacePolicy.pressedSurface(selected, t), - toPx(if (hasAncestorTag(view, "emoji-panel")) 99 else ImeGeometryTokens.CONTROL_RADIUS_DP), + fill, + if (symbolTab) fill else ImeSurfacePolicy.pressedSurface(selected, t), + toPx( + if (symbolTab || hasAncestorTag(view, "emoji-panel")) { + 99 + } else { + ImeGeometryTokens.CONTROL_RADIUS_DP + }, + ), ) } tag == "panel-tab" -> { - view.setTextColor(t.keySecondaryText) + val symbolTab = hasAncestorTag(view, "symbols-panel") + view.setTextColor( + if (symbolTab) { + ImeSurfacePolicy.symbolTabText(t, selected = false) + } else { + t.keySecondaryText + }, + ) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + if (symbolTab) { + ImeSurfacePolicy.symbolTabFill(t) + } else { + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t) + }, + toPx( + if (symbolTab) 99 else ImeGeometryTokens.CONTROL_RADIUS_DP, + ), ) } @@ -690,7 +722,7 @@ internal class ImeThemeApplier( view.setTextColor(t.keyText) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), + ImeSurfacePolicy.railItemPressed(t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -703,7 +735,7 @@ internal class ImeThemeApplier( view.setTextColor(t.keyText) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), + ImeSurfacePolicy.railItemPressed(t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } @@ -724,7 +756,7 @@ internal class ImeThemeApplier( view.setTextColor(t.keySecondaryText) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + ImeSurfacePolicy.candidatePressed(t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } 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 8f9b696a..71c26b84 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -1,5 +1,6 @@ package llc.slacker.openime.keyboard +import android.content.res.ColorStateList import android.graphics.Color import android.graphics.drawable.Drawable import android.text.TextUtils @@ -242,10 +243,22 @@ internal class KeyPopupController( textSize = ImeTypographyTokens.POPUP_ITEM_SP includeFontPadding = false gravity = Gravity.CENTER - setTextColor(t.keyText) + // A bubble cell fills with the accent under the finger and + // its glyph turns white (`cls_fusiyin_bubble_button`: + // `pushedbkcolor="@color/theme_color"`, `pushedtextcolor` + // white, style.xml:1135-1139, `values/colors.xml:177`). + setTextColor( + ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_pressed), intArrayOf()), + intArrayOf( + ImeDrawableFactory.contrastText(t.primary), + t.keyText, + ), + ), + ) background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.keyBackground, t), + t.primary, dp(ImeGeometryTokens.KEY_RADIUS_DP), ) isClickable = 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 8fbcfdc5..c0d1f16a 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -337,6 +337,76 @@ internal object ImeSurfacePolicy { * the dark one (`style.xml:224-227`, `:276-281`; * `values/colors.xml:27-28`, `values/dark_colors.xml:23-24`). */ + /** + * A candidate cell fills with white when it is pressed: `canditem_pushedbk` + * is opaque white on the light skin and white at 8% on the dark one + * (`values/colors.xml:81`, `values/dark_colors.xml:79`). The cell under the + * finger brightens, the way the key classes brighten a pressed key. + */ + fun candidatePressed(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) { + ImeDrawableFactory.blend(tokens.keyText, tokens.candidateBackground, 0.08f) + } else { + tokens.lightKeyBackground + } + + /** + * An expanded-candidate cell presses the other way: `morecands_button_pushedbk` + * is black at 6% on the light skin and black at 4% on the dark one — a shade + * both ways, never a lift (`values/colors.xml:117`, + * `values/dark_colors.xml:111`). + */ + fun moreCandsPressed(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + Color.BLACK, + tokens.candidateBackground, + if (isDark(tokens)) 0.04f else 0.06f, + ) + + /** + * A symbol-category tab is a neutral pill, never an accent-tinted one: the + * reference's own tab class wears no fill at rest and takes + * `symbol_option_pushedbk` when it is pressed or chosen — black at 6% on the + * light skin, white at 9% on the dark one (`cls_input_kbd_symbol_option`, + * `style.xml:715-724`, `values/colors.xml:105`, + * `values/dark_colors.xml:100`). The fill is translucent in the reference, + * so it is handed over translucent here and lies on whatever the tab row + * sits on. + */ + fun symbolTabFill(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#17FFFFFF") else Color.parseColor("#0F000000") + + /** The tab's label: 60% at rest, 85% when chosen (`symbol_option_text` / + * `symbol_option_selectedtext`, `values/colors.xml:16-17`). */ + fun symbolTabText(tokens: ImeTheme.Tokens, selected: Boolean): Int = + ImeDrawableFactory.withAlpha(tokens.keyText, if (selected) 217 else 153) + + /** + * A row of the reading or symbol rail sits flat on the rail's gray panel and + * fills with the white key's own colour when pressed: the reference's + * `cls_vertical_symbol_list_button` asks for `keybutton_bk` + * (`style.xml:1025-1030`, `values/colors.xml:19`, + * `values/dark_colors.xml:18`). + */ + fun railItemPressed(tokens: ImeTheme.Tokens): Int = tokens.lightKeyBackground + + /** + * The accent key — 确定/发送/搜索 — presses the way a white key looks: the + * reference fills its action key with `theme_color` and swaps that for + * `action_button_pushedbk` under the finger, white on the light skin and + * white at 12% on the dark one, with the label moving to + * `action_button_pushedtext` (black at 85%) on the light skin + * (`cls_button_enter`, `style.xml:810-818`, `values/colors.xml:35-38`, + * `values/dark_colors.xml:34-36`). Its rounded back key presses the same + * way through `cls_bk_theme_circle` (`style.xml:282-287`). + */ + fun actionKeyPressed(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) { + ImeDrawableFactory.blend(tokens.keyText, tokens.keyboardBackground, 0.12f) + } else { + tokens.lightKeyBackground + } + fun keyShadow(tokens: ImeTheme.Tokens): Int = if (isDark(tokens)) Color.parseColor("#66000000") else Color.parseColor("#4D000000") 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 3921beba..47c2c00d 100644 --- a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -357,6 +357,22 @@ class ImeKeyView( returnIcon?.imageTintList = ColorStateList.valueOf(mainColor) } + /** + * The key's own face in two states. The reference's action key is the one + * that changes its label when the finger is down — the white press fill + * takes a dark label (`action_button_pushedtext`, `values/colors.xml:38`) — + * so its text and icon swap together with the cap. + */ + fun setLabelColors(normalColor: Int, pressedColor: Int) { + val colors = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_pressed), intArrayOf()), + intArrayOf(pressedColor, normalColor), + ) + mainTextView?.setTextColor(colors) + iconView?.imageTintList = colors + returnIcon?.imageTintList = colors + } + val currentMainText: String get() = mainTextView?.text?.toString().orEmpty() From b7a15464f85693118114d49205d733e2c11c3aa3 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:24:50 +0800 Subject: [PATCH 39/94] Give the corner hints, idle toolbar and strip split their own colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference names a colour for each of these surfaces, and we were borrowing a neighbour's instead: - Key corner hints (`keybutton_text_2`) are black 40% on the light skin and white 40% on the dark one (`values/colors.xml:14`, `values/dark_colors.xml:13`), not the main label's colour. - Idle-toolbar glyphs (`toolbar_idle_button_text`) are mid gray (127,127,127) with a 6% black press on the light skin and half-white with a 4% white press on the dark one (`values/colors.xml:30,43-45`, `values/dark_colors.xml:28,41-43`). The pill that colour sits in was lost when the icon strip became scrollable — the icons' parent is `toolbar-icon-strip`, not `toolbar-row` — so the branch now matches both. - The association bar's back key and the autofill chip's close key are `cls_bk_toolbar_button`: no fill of their own, pressing to the candidate fill (`style.xml:231-236`, `:1076-1080`). - The line between the candidate list and the expand key is `canditem_split`, black 12% light and white 16% dark (`cls_candidate_split`, `style.xml:504-506`, `values/colors.xml:83`, `values/dark_colors.xml:81`), not a faded key-cap stroke. Co-Authored-By: Claude Code --- CHANGELOG.md | 5 ++- .../openime/keyboard/ImeThemeApplier.kt | 42 +++++++++++++++---- .../slacker/openime/theme/ImeDesignTokens.kt | 25 +++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b0a70d..c3bdba42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,10 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 26 键「分词」键去掉自造的「@#/」角标与长按弹窗:参考皮肤的这个键只有一个文字标签。 - 拼音预编辑串按参考皮肤分两色:用户已经选定的音节(九键左侧点选的读法)用主题色,剩下未转换的部分用普通键字色;字号同时从 14sp 调到参考的 13sp(comp_font 40)。 - 候选条按参考皮肤排版:候选列表左右各留 4 单位内缩,列表与展开键之间加一条细分隔线(中间再隔 3 单位),展开键的按压底色改成参考的整圆胶囊形。 -- 空闲工具栏按键改为参考皮肤的样子:每个图标 40 单位宽、自带白色胶囊底与按压色(收起键同样),多余宽度留在图标与收起键之间,不再把图标拉伸铺满一行。 +- 空闲工具栏按键改为参考皮肤的样子:每个图标 40 单位宽、自带白色胶囊底与按压色(收起键同样),多余宽度留在图标与收起键之间,不再把图标拉伸铺满一行。工具栏改成可横向滑动之后,这一排图标一度丢掉了这层胶囊底和灰色字色(图标变成纯黑、没有底),现在都按参考皮肤恢复:图标是中灰(参考 `toolbar_idle_button_text`,深色下为半透明白)。 +- 键位上的角标改为参考皮肤自己的浅色(`keybutton_text_2`,浅色 40% 黑、深色 40% 白),不再用与主标签同一个字色;深色下角标原来偏亮,比主标签还显眼。 +- 候选栏与快捷输入条两端的小按钮(联想条的返回键、建议条的关闭键)改为参考皮肤的透明胶囊:只在按下时出现候选底色,图标用键字色;原来是常驻一层实心胶囊底。 +- 候选列表与展开键之间那条分隔线改用参考皮肤自己的颜色(`canditem_split`:浅色 12% 黑、深色 16% 白,原来是拿键帽描边色淡化出来的灰线,深浅两套下都比参考更实)。 ## [0.0.10-beta.1] - 2026-10-14 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 542c6493..41564847 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -149,8 +149,10 @@ internal class ImeThemeApplier( // label goes dark for as long as it is held. view.setLabelColors(onPrimary, ImeDrawableFactory.contrastText(pressedColor)) } + // The corner glyph on any of these keys is the key hint colour + // (`keybutton_text_2`), never the key text colour. white -> - view.setColors(t.lightKeyText, t.keySecondaryText, t.lightKeyText) + view.setColors(t.lightKeyText, ImeSurfacePolicy.keyHint(t), t.lightKeyText) side -> view.setColors(t.sideKeyText, t.sideKeyText, t.sideKeyText) function -> @@ -160,7 +162,7 @@ internal class ImeThemeApplier( t.functionKeyText, ) else -> - view.setColors(t.keyText, t.keySecondaryText, t.keyText) + view.setColors(t.keyText, ImeSurfacePolicy.keyHint(t), t.keyText) } } @@ -446,7 +448,8 @@ internal class ImeThemeApplier( view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.iconTint(t)) view.background = null } - view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + view.tag == "undo-toolbar" -> view.imageTintList = + ColorStateList.valueOf(ImeSurfacePolicy.toolbarIcon(t)) // The quick-input row's glyph takes `keybutton_text_2` // (`fi_toolbar_tip_superscript` glyphs, `input_toolbar_quickinput.xml:44-46`). view.tag == "suggestion-icon" -> { @@ -461,11 +464,33 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } + // The strips' own controls — the association back control and the + // quick-input close key — are the reference's transparent toolbar + // pills: `cls_btn_morecand_back` and `cls_history_close_button` both + // extend `cls_bk_toolbar_button`, whose only face is the press tint + // (`canditem_pushedbk`) under a `keybutton_text` glyph + // (style.xml:231-235, `:519-525`, `:1055-1059`). + view.tag == "association-back" || view.tag == "autofill-close" -> { + view.imageTintList = ColorStateList.valueOf(t.keyText) + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.candidatePressed(t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ) + } + // The idle toolbar's own keys, the icon strip included: each is the + // reference's 40-unit pill with the toolbar's icon colour. ( - view.parent is LinearLayout && - (view.parent as LinearLayout).tag == "toolbar-row" + (view.parent as? View)?.tag == "toolbar-row" || + (view.parent as? View)?.tag == "toolbar-icon-strip" ) || hasAncestorTag(view, "tools-panel") -> { - view.imageTintList = ColorStateList.valueOf(if (hasAncestorTag(view, "tools-panel")) ImeSurfacePolicy.selectedText(t) else t.keyText) + view.imageTintList = ColorStateList.valueOf( + if (hasAncestorTag(view, "tools-panel")) { + ImeSurfacePolicy.selectedText(t) + } else { + ImeSurfacePolicy.toolbarIcon(t) + }, + ) if (view.isClickable) { // Every idle-toolbar key wears its own pill face at rest // (`cls_toolbar_idle_button`: `bkcolor="@color/toolbar_idle_button_bk"`, @@ -873,9 +898,10 @@ internal class ImeThemeApplier( view.setBackgroundColor(ImeDrawableFactory.withAlpha(t.border, if (ImeSurfacePolicy.isDark(t)) 150 else 48)) "row-hairline" -> view.setBackgroundColor(ImeSurfacePolicy.hairline(t)) // `cls_candidate_split` is filled with @color/canditem_split - // (style.xml:504-506), the colour the candidate row's hairlines use. + // (style.xml:504-506), a black 12% / white 16% line rather than a + // key-coloured stroke. "candidate-strip-split", "recent-symbol-split" -> - view.setBackgroundColor(ImeSurfacePolicy.divider(t)) + view.setBackgroundColor(ImeSurfacePolicy.candidateSplit(t)) } } 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 c0d1f16a..e7479509 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -407,6 +407,21 @@ internal object ImeSurfacePolicy { tokens.lightKeyBackground } + /** + * The corner glyph on a key — `keybutton_text_2`, black at 40% on the light + * skin and white at 40% on the dark one (`cls_keybutton_fusiyin`, + * `style.xml:207-212`; `values/colors.xml:14`, `values/dark_colors.xml:13`). + */ + fun keyHint(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.withAlpha(tokens.keyText, 102) + + /** + * An idle-toolbar icon is half of the key text colour, never the key text + * itself: `toolbar_idle_button_text` is a mid gray on the light skin and + * half white on the dark one (`cls_toolbar_idle_button`, + * `style.xml:237-248`, `values/colors.xml:30`, `values/dark_colors.xml:28`). + */ + fun toolbarIcon(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.withAlpha(tokens.keyText, 128) + fun keyShadow(tokens: ImeTheme.Tokens): Int = if (isDark(tokens)) Color.parseColor("#66000000") else Color.parseColor("#4D000000") @@ -557,6 +572,16 @@ internal object ImeSurfacePolicy { tokens.border, if (isDark(tokens)) 150 else 180, ) + + /** + * The line between the composing strip and the keys. The reference does not + * stroke it with a key colour: `cls_candidate_split` is filled with + * `@color/canditem_split` (`style.xml:504-506`), which is black at 12% on the + * light skin (`values/colors.xml:83`) and white at 16% on the dark one + * (`values/dark_colors.xml:81`), so it reads as a gap in the surface. + */ + fun candidateSplit(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#29FFFFFF") else Color.parseColor("#1F000000") } /** Shared WCAG contrast decisions for every native surface. */ From 7097ccff2890b9b4cdcda97ce747dc7ff99d87ea Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:32:59 +0800 Subject: [PATCH 40/94] Let the dark skin's first candidate be plain, and log the day's ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `first_cand_text_color` is an accent blue on the light skin but white at 95% on the dark one — the same value as `canditem_text` (`values/colors.xml:4`, `values/dark_colors.xml:4`, `:75`) — so on that skin the reference sets its first candidate apart by weight alone. Our candidate bar tinted it in both skins. `docs/DECISIONS.md` gains the day's reference ports: the per-class key margins, radius and faces; the two key fills that trade places on press with the shadow line under every cap; the pressed colour each surface names for itself, and the label colours that go with them; and the composing strip's 20:36 split. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/CandidateBarController.kt | 2 +- .../slacker/openime/keyboard/ImeThemeApplier.kt | 8 +++++--- .../llc/slacker/openime/theme/ImeDesignTokens.kt | 10 ++++++++++ docs/DECISIONS.md | 15 +++++++++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3bdba42..761c13eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 键位上的角标改为参考皮肤自己的浅色(`keybutton_text_2`,浅色 40% 黑、深色 40% 白),不再用与主标签同一个字色;深色下角标原来偏亮,比主标签还显眼。 - 候选栏与快捷输入条两端的小按钮(联想条的返回键、建议条的关闭键)改为参考皮肤的透明胶囊:只在按下时出现候选底色,图标用键字色;原来是常驻一层实心胶囊底。 - 候选列表与展开键之间那条分隔线改用参考皮肤自己的颜色(`canditem_split`:浅色 12% 黑、深色 16% 白,原来是拿键帽描边色淡化出来的灰线,深浅两套下都比参考更实)。 +- 深色下第一个候选不再用主题色文字:参考皮肤这个颜色在深色皮肤里就是普通候选文字色(`first_cand_text_color` 深色为 95% 白,与 `canditem_text` 相同),第一个候选只靠字重区分;浅色下仍是主题色。 ## [0.0.10-beta.1] - 2026-10-14 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 7849edf9..2533c0fd 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -518,7 +518,7 @@ internal class CandidateBarController( val palette = tokens() word.setTextColor( if (index == 0) { - ImeSurfacePolicy.selectedText(palette) + ImeSurfacePolicy.firstCandidateText(palette) } else { palette.candidateText }, 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 41564847..6deab389 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -726,9 +726,11 @@ internal class ImeThemeApplier( view.typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) } tag == "segment-option-tall" -> { view.setTextColor(t.keySecondaryText); view.background = null } - // The tile's name takes `keybutton_text_3` (50% black, or 50% white - // in the dark skin, values/colors.xml:15) and white on the chosen - // tile (`setting_button_selectedtext`). + // The tile's name is the reference's dimmed one — `keybutton_text_3`, + // 50% black or 50% white (`values/colors.xml:15`, + // `values/dark_colors.xml:14`) — drawn in our palette's own secondary + // role, which keeps the 4.5:1 our text roles are held to, and white + // on the chosen tile (`setting_button_selectedtext`). tag == "tool-label" -> view.setTextColor(t.keySecondaryText) tag == "tool-label-selected" -> view.setTextColor(ImeDrawableFactory.contrastText(t.primary)) tag == "fuzzy-rules" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16)) 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 e7479509..b56aa494 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -573,6 +573,16 @@ internal object ImeSurfacePolicy { if (isDark(tokens)) 150 else 180, ) + /** + * The first candidate's own colour. The light skin gives it an accent blue + * (`first_cand_text_color`, `values/colors.xml:4`), but the dark skin's + * entry for the same name is white at 95% — exactly `canditem_text` + * (`values/dark_colors.xml:4`, `:75`) — so there the first candidate is set + * apart by its weight alone, not by a tint. + */ + fun firstCandidateText(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) tokens.candidateText else selectedText(tokens) + /** * The line between the composing strip and the keys. The reference does not * stroke it with a key colour: `cls_candidate_split` is filled with diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c01e18e2..3a5267a0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -229,3 +229,18 @@ Add new entries at the end. - **The quick-input row's origin glyph rides above the text as a superscript.** The reference's own suggestion row measures both of its parts against the row: the text box carries `margin_decimal="0,0.055,0,0.111"` on `btn_quickinput_clipboard` (`input_toolbar_quickinput.xml:42`), and the glyph that follows it takes `margin_decimal="0,0.08,0,0.111"` with `align="top"` under `fi_toolbar_tip_superscript` (`input_toolbar_quickinput.xml:44-45`) — a font of 30 units where `cand_font` is 56 (`style.xml:138`, `:83`), so the badge is a little over half the line it marks and keeps the row's top edge instead of its centre. *Reason:* our first pass drew the glyph as a fixed 18dp icon centred on the row, which reads as a second key beside the text rather than as the mark of where the text came from; the reference's own proportion ties it to the candidate font, so the size is a share of the text rather than a number of its own. The reference's 1% side padding on the glyph is not ported: it is a share of the row's own width in the skin's decimal mode, and a badge sized against the text has no such box to pad. + +## 2026-10-15 + +- **Every key cap takes its own class's margin, radius and face.** + The skin gives each kind of key its own numbers instead of one shared key style: the 26-key rows keep `margin="2.25,4.75,2.25,4.75"` against the nine-key, stroke and number boards' `2.5,2.75` (`cls_keyboard_button_margin`, `style.xml:289-290`; `cls_keyboard9_button_margin`, `style.xml:373-374`), every cap rounds to `bkround="6"` (`cls_bk_white_circle`, `style.xml:224-227`; `cls_bk_gray_circle`, `style.xml:276-281`), and each family names its own face — `key_letter` 57, `key_letter_english` 60, `key_letter9` 48, `key_letter9_at_symbol` 54, `key_stroke` 60, `key_stroke_wildcard` 44, `key_number9` 54 (`style.xml:4-13`), the stroke grid taking `key_stroke` where we had read it as a symbol cell. The rail list keeps one face for all its cells, `fi_vertical_symbol_list_text` 47 (`style.xml:112`, `:1025-1030`), on a panel with `margin="2.5,2.75,2.5,2.75"` (`style.xml:1035-1036`). + *Reason:* our caps used 6, 8 and 12dp radii, one uniform margin per board, and sizes borrowed from a neighbouring family — the nine-key letters wore the candidate size and the numeric page wore the symbol size. The reference's own step maps to ours as three units to the sp, the rule `key_letter` 57 → 19 and `comp_font` 40 → 13 already follow. +- **A pressed key takes the other key's fill, and the cap sits on the reference's shadow line.** + The reference's two key classes swap their fills when pressed: `keybutton_pushed_bk` is `keybutton_func_bk` — a white key turns gray — and `keybutton_func_pushed_bk` is `keybutton_bk` — a gray key turns white (`values/colors.xml:19-23`, and the same pair over the dark skin's translucent fills, `values/dark_colors.xml:18-22`). Every key class also sets `shadow_y_offset="1"` over `keybutton_white_shadow` / `keybutton_gray_shadow` — black at 30% on the light skin and 40% on the dark one (`style.xml:224-227`, `:276-281`). + *Reason:* we shaded each key a little darker instead, so a pressed gray key read as deeper rather than as the reference's flash of white, and the line under a cap was drawn in that same pressed colour, which the light skin makes nearly invisible. The theme-coloured key keeps its own pressed colour, as `cls_button_enter` does (`style.xml:810-818`). +- **Each pressed surface carries the colour its own class names.** + The skin names a press colour per surface, and several read the other way round from a derived shade: a candidate cell brightens to `canditem_pushedbk` — white on the light skin, white at 8% on the dark one (`values/colors.xml:81`, `values/dark_colors.xml:79`) — and the first candidate presses like the rest, the expanded page keeps its own `morecands_button_pushedbk` (black 6% / black 4%, `values/colors.xml:117`, `values/dark_colors.xml:111`), a rail row fills with the white key (`cls_vertical_symbol_list_button` → `keybutton_bk`, `style.xml:1025-1030`), a symbol-category tab uses one neutral pill for pressed and chosen with labels at 60% and 85% of the key text colour (`cls_input_kbd_symbol_option`, `style.xml:715-724`, `values/colors.xml:16-17`), a bubble cell fills with the accent and whitens its glyph (`cls_fusiyin_bubble_button`, `style.xml:1135-1139`), and the action key turns white with a dark label (`action_button_pushedbk` / `action_button_pushedtext`, `values/colors.xml:35,38`, `values/dark_colors.xml:34,36`). The corner marks take `keybutton_text_2` (black 40% / white 40%, `values/colors.xml:14`, `values/dark_colors.xml:13`), the idle toolbar's glyphs `toolbar_idle_button_text` (rgb 127,127,127 / white 50%, `values/colors.xml:30`, `values/dark_colors.xml:28`), and the line between the candidate list and the expand key `canditem_split` (black 12% / white 16%, `values/colors.xml:83`, `values/dark_colors.xml:81`). The first candidate's own colour is the accent on the light skin and the plain candidate colour on the dark one, where `first_cand_text_color` is white at 95% (`values/colors.xml:4`, `values/dark_colors.xml:4`). + *Reason:* we derived each pressed state by shading whatever neutral surrounded it, so a candidate dimmed where the reference brightens it and a function key pressed to a white key in the reference pressed to a deeper gray here. The reference's translucent pairs also scale with the theme, which a single derived shade cannot. +- **The composing strip splits 20 : 36, the reference's own two rows.** + `input_toolbar_typing.xml:2-34` gives the pinyin line 20 height units (line 5) and the candidate row 36 (line 23), with a 2-unit gap under both (``, line 33). + *Reason:* our 60dp strip divided those rows as 18 and 42, which cramped the pinyin line and gave the candidate row more room than the reference's proportion. 21 and 37 hold the reference's ratio and keep our zone's 2 units of gap inside the strip, so the keyboard below does not move. From 693a0197368b9a5a1a1fb7ffc2db3be5c699d6ee Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:42:28 +0800 Subject: [PATCH 41/94] Press tiles and toolbar keys onto their own page, not into the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every press colour the reference skin names for a tile, a toolbar key or a settings row is one recipe: black at 6% on the light skin and white at 4% on the dark one, laid over the ground the face sits on (toolbar_idle_button_ pushedbk values/colors.xml:45, dark_colors.xml:43; setting_button_pushedbk :89, dark :87). Our pressed states instead blended the key text colour into the resting face, which brightens a surface on the dark skin where the reference dims it — a pressed tile glowed. ImeSurfacePolicy gains pressedOnGround and cardPressed; the idle toolbar's keys, the quick tiles, the settings rows and the tool tiles now press with the page's own recipe, and the association bar's cells press with the candidate fill the bar declares (style.xml:562-566). Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeThemeApplier.kt | 21 ++++++++++++------- .../slacker/openime/theme/ImeDesignTokens.kt | 21 +++++++++++++++++++ docs/DECISIONS.md | 3 +++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 761c13eb..94d1cb72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 候选栏与快捷输入条两端的小按钮(联想条的返回键、建议条的关闭键)改为参考皮肤的透明胶囊:只在按下时出现候选底色,图标用键字色;原来是常驻一层实心胶囊底。 - 候选列表与展开键之间那条分隔线改用参考皮肤自己的颜色(`canditem_split`:浅色 12% 黑、深色 16% 白,原来是拿键帽描边色淡化出来的灰线,深浅两套下都比参考更实)。 - 深色下第一个候选不再用主题色文字:参考皮肤这个颜色在深色皮肤里就是普通候选文字色(`first_cand_text_color` 深色为 95% 白,与 `canditem_text` 相同),第一个候选只靠字重区分;浅色下仍是主题色。 +- 空闲工具栏按键、工具页与切换键盘页的磁贴、设置页的行,按下时改用参考皮肤自己的按下色(浅色 6% 黑、深色 4% 白,都是压在所在页面底色上),深浅两套下按下都是「按暗」;原来按下色是在各自的底色上算出来的,深色下按下去反而变亮。联想条的候选格按下也改为候选底色(与候选栏一致)。 ## [0.0.10-beta.1] - 2026-10-14 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 6deab389..ed6f00a7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -244,7 +244,7 @@ internal class ImeThemeApplier( "setting-row" -> if (view.isClickable) { view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + ImeSurfacePolicy.cardPressed(t), 0, ) } @@ -256,7 +256,7 @@ internal class ImeThemeApplier( val fill = if (view.isSelected) t.primary else t.toolCardBackground view.background = statefulRounded( fill, - if (view.isSelected) ImeSurfacePolicy.primaryPressed(t) else ImeSurfacePolicy.pressedSurface(fill, t), + if (view.isSelected) ImeSurfacePolicy.primaryPressed(t) else ImeSurfacePolicy.cardPressed(t), toPx(14), ) } @@ -267,7 +267,7 @@ internal class ImeThemeApplier( // when it is the chosen one (`cls_settings_button`, style.xml:626-647). "keyboard-choice" -> view.background = statefulRounded( t.toolCardBackground, - ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + ImeSurfacePolicy.cardPressed(t), toPx(TileGridPolicy.TILE_RADIUS_DP), ) "keyboard-choice-selected" -> view.background = statefulRounded( @@ -312,7 +312,7 @@ internal class ImeThemeApplier( if ((view.tag as? String)?.startsWith("tool:") == true) { view.background = statefulRounded( t.toolCardBackground, - ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + ImeSurfacePolicy.cardPressed(t), toPx(TileGridPolicy.TILE_RADIUS_DP), ) } @@ -495,10 +495,11 @@ internal class ImeThemeApplier( // Every idle-toolbar key wears its own pill face at rest // (`cls_toolbar_idle_button`: `bkcolor="@color/toolbar_idle_button_bk"`, // `bkround="999"`, `pushedbkcolor`, style.xml:237-248), which on the - // toolbar surface is the key cap colour. + // toolbar surface is the key cap colour, and presses to the + // reference's own shade instead of a derived one. view.background = statefulRounded( t.keyBackground, - ImeSurfacePolicy.pressedSurface(t.keyBackground, t), + ImeSurfacePolicy.pressedOnGround(t.toolbarBackground, t), toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } @@ -800,10 +801,16 @@ internal class ImeThemeApplier( view.setTextColor(t.keySecondaryText) tag == "association-candidate" -> { + // The association bar's items are candidate items — the + // reference's own class for them carries the candidate font and + // the candidate text colour (`cls_option_associate_first_canditem`, + // style.xml:562-566) — and none of the bar's classes names a + // press colour, so they press to the candidate fill the strip's + // cells use. view.setTextColor(t.candidateText) view.background = statefulRounded( Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + ImeSurfacePolicy.candidatePressed(t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } 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 b56aa494..4d4d4871 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -414,6 +414,27 @@ internal object ImeSurfacePolicy { */ fun keyHint(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.withAlpha(tokens.keyText, 102) + /** + * A face that presses the way every surface in the reference does: the skin + * writes each press colour as black at 6% on the light skin and white at 4% + * on the dark one, laid over the ground the face sits on + * (`toolbar_idle_button_pushedbk`, `values/colors.xml:45`, + * `values/dark_colors.xml:43`; `setting_button_pushedbk`, + * `values/colors.xml:89`, `values/dark_colors.xml:87`). A pressed tile or + * toolbar key therefore sinks toward its page on both skins, where a shade + * derived from the resting colour brightens it on the dark one. + */ + fun pressedOnGround(ground: Int, tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + if (isDark(tokens)) Color.WHITE else Color.BLACK, + ground, + if (isDark(tokens)) 0.04f else 0.06f, + ) + + /** A card or tile that presses onto the page it lies on. */ + fun cardPressed(tokens: ImeTheme.Tokens): Int = + pressedOnGround(tokens.keyboardBackground, tokens) + /** * An idle-toolbar icon is half of the key text colour, never the key text * itself: `toolbar_idle_button_text` is a mid gray on the light skin and diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3a5267a0..3eca9534 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -241,6 +241,9 @@ Add new entries at the end. - **Each pressed surface carries the colour its own class names.** The skin names a press colour per surface, and several read the other way round from a derived shade: a candidate cell brightens to `canditem_pushedbk` — white on the light skin, white at 8% on the dark one (`values/colors.xml:81`, `values/dark_colors.xml:79`) — and the first candidate presses like the rest, the expanded page keeps its own `morecands_button_pushedbk` (black 6% / black 4%, `values/colors.xml:117`, `values/dark_colors.xml:111`), a rail row fills with the white key (`cls_vertical_symbol_list_button` → `keybutton_bk`, `style.xml:1025-1030`), a symbol-category tab uses one neutral pill for pressed and chosen with labels at 60% and 85% of the key text colour (`cls_input_kbd_symbol_option`, `style.xml:715-724`, `values/colors.xml:16-17`), a bubble cell fills with the accent and whitens its glyph (`cls_fusiyin_bubble_button`, `style.xml:1135-1139`), and the action key turns white with a dark label (`action_button_pushedbk` / `action_button_pushedtext`, `values/colors.xml:35,38`, `values/dark_colors.xml:34,36`). The corner marks take `keybutton_text_2` (black 40% / white 40%, `values/colors.xml:14`, `values/dark_colors.xml:13`), the idle toolbar's glyphs `toolbar_idle_button_text` (rgb 127,127,127 / white 50%, `values/colors.xml:30`, `values/dark_colors.xml:28`), and the line between the candidate list and the expand key `canditem_split` (black 12% / white 16%, `values/colors.xml:83`, `values/dark_colors.xml:81`). The first candidate's own colour is the accent on the light skin and the plain candidate colour on the dark one, where `first_cand_text_color` is white at 95% (`values/colors.xml:4`, `values/dark_colors.xml:4`). *Reason:* we derived each pressed state by shading whatever neutral surrounded it, so a candidate dimmed where the reference brightens it and a function key pressed to a white key in the reference pressed to a deeper gray here. The reference's translucent pairs also scale with the theme, which a single derived shade cannot. +- **A surface presses onto the page it lies on.** + Every press colour the skin names for a tile or a toolbar key is the same recipe: black at 6% on the light skin and white at 4% on the dark one, laid over the ground the face sits on (`toolbar_idle_button_pushedbk`, `values/colors.xml:45`, `values/dark_colors.xml:43`; `setting_button_pushedbk`, `values/colors.xml:89`, `values/dark_colors.xml:87`). The tool and switcher tiles, the settings rows and the idle-toolbar keys use that recipe over their own page ground, and the association bar's cells press to the candidate fill, the family their class belongs to (`cls_option_associate_first_canditem`, `style.xml:562-566`). + *Reason:* our pressed states were derived by blending the key text colour into the resting face, which brightens a surface on the dark skin where the reference dims it — a pressed tile glowed. The reference's own recipe sinks the face toward the page on both skins, so it carries over unchanged. - **The composing strip splits 20 : 36, the reference's own two rows.** `input_toolbar_typing.xml:2-34` gives the pinyin line 20 height units (line 5) and the candidate row 36 (line 23), with a 2-unit gap under both (``, line 33). *Reason:* our 60dp strip divided those rows as 18 and 42, which cramped the pinyin line and gave the candidate row more room than the reference's proportion. 21 and 37 hold the reference's ratio and keep our zone's 2 units of gap inside the strip, so the keyboard below does not move. From 0711907d1f5f03581b83fe0da44897ae4aace82b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:54:55 +0800 Subject: [PATCH 42/94] Give the number page's return key the skin's theme colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key that leaves the number page for the letter board is cls_button_back_26key (input_kbd_number_from26.xml:13), which takes cls_button_back: an icon key whose cls_bk_theme_circle fills with the theme colour and presses to the white key's own fill (style.xml:857-858, :838-843, :282-286). The number-page entry in DECISIONS.md recorded this key as grey -- read from the class name rather than from what it inherits -- so that entry is corrected here too. The skin's glyph is a private-use character of its icon font, so the label stays our own 返回. An accent marker joins the white/side markers the theme applier already reads, and the key takes the accent treatment the send key wears when the field asks for an action. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../java/llc/slacker/openime/keyboard/ImeKeyboardView.kt | 6 ++++++ .../java/llc/slacker/openime/keyboard/ImeThemeApplier.kt | 7 +++++++ .../slacker/openime/keyboard/NumericKeyboardRenderer.kt | 8 +++++++- docs/DECISIONS.md | 5 ++++- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d1cb72..76ce4648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 +- 数字页左下角的「返回」键改为参考皮肤自己的颜色:参考皮肤里这个键用主题色底(`cls_bk_theme_circle` 填 `theme_color`),按下换成白键的底色;原来我们把它画成了灰色键。(键上的字仍是我们的「返回」:参考皮肤用的是它自带的图标字体,这个字我们无法随包分发。) - 按键按下时改为参考皮肤的对调效果:参考皮肤里白键按下的颜色就是灰键的底色(`keybutton_pushed_bk` 等于 `keybutton_func_bk`),灰键按下则变成白键的底色(`keybutton_func_pushed_bk` 等于 `keybutton_bk`),深浅两套皮肤都是这一对颜色对调;原来两种键按下都是各自加深一点。主题色键(确定)仍是自己的按下色。 - 候选项按下时改为参考皮肤的候选底色(浅色是不透明白、深色是 8% 白),所有候选一格一样,第一个候选不再单独按主题色按压;候选栏右端的表情与展开键、符号页工具栏里的最近符号格也是同一个底色。原来是在各自的底色上加深一点,浅色下是按下去变暗。 - 展开候选页的候选格按下改为参考皮肤自己给这一类的底色(浅色 6% 黑、深色 4% 黑,深浅都是压暗一点);原来用的是白色半透明,深色下会发亮。 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 b0edeb8b..4f74a544 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -154,6 +154,8 @@ open class ImeKeyboardView( /** Visual class marker for the gray side/action column in nine-key layouts. */ private val MARK_SIDE_KEY = 0x1F000002 private val MARK_FUNCTION_KEY = 0x1F000003 + /** Visual class marker for a key the reference draws on its theme colour. */ + private val MARK_ACCENT_KEY = 0x1F000004 companion object { /** Scale differences below this are rounding noise, not a new geometry. */ @@ -563,6 +565,9 @@ open class ImeKeyboardView( isWhiteKey = { key -> key.getTag(MARK_WHITE_KEY) == true }, + isAccentKey = { key -> + key.getTag(MARK_ACCENT_KEY) == true + }, ) } private val keyPopupController = KeyPopupController( @@ -689,6 +694,7 @@ open class ImeKeyboardView( }, markSideKey = { key -> key.setTag(MARK_SIDE_KEY, true) }, markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) }, + markAccentKey = { key -> key.setTag(MARK_ACCENT_KEY, true) }, onCommitCharacter = ::commitKeyboardCharacter, onFeedback = ::feedback, onShowSymbols = { showPanel(Panel.SYMBOLS) }, 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 ed6f00a7..86442446 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -36,6 +36,7 @@ internal class ImeThemeApplier( private val isSideKey: (ImeKeyView) -> Boolean, private val isFunctionKey: (ImeKeyView) -> Boolean, private val isWhiteKey: (ImeKeyView) -> Boolean, + private val isAccentKey: (ImeKeyView) -> Boolean, ) { fun apply(view: View, tokens: ImeTheme.Tokens) { when (view) { @@ -93,7 +94,13 @@ internal class ImeThemeApplier( isWhiteKey(view) || (!function && DIGITS_ONLY.matches(label)) ) + // A key the reference draws on its theme colour: the send key once the + // field asks for an action (`cls_button_enter`'s `actionbkcolor`, + // style.xml:810-815) and the number page's return key + // (`cls_button_back_26key` over `cls_button_back`, style.xml:857-858, + // :838-843, whose `cls_bk_theme_circle` is the theme colour, :282-286). val primary = view.tag == "tab-active" || + isAccentKey(view) || (view.tag == "key-enter" && view.currentMainText != "↵") val color = when { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt index 8e4df5ba..ca8c44af 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -38,6 +38,7 @@ internal class NumericKeyboardRenderer( private val createSpaceVoiceKey: (label: String, onTap: () -> Unit) -> ImeKeyView, private val markSideKey: (ImeKeyView) -> Unit, private val markWhiteKey: (ImeKeyView) -> Unit, + private val markAccentKey: (ImeKeyView) -> Unit, private val onCommitCharacter: (String) -> Unit, private val onFeedback: () -> Unit, private val onShowSymbols: () -> Unit, @@ -87,10 +88,15 @@ internal class NumericKeyboardRenderer( toPx(keyRowHeightDp() * NumberPadPolicy.RAIL_ROWS), ), ) + // The reference draws this key on its theme colour, not on gray + // (`cls_button_back_26key` over `cls_button_back`, style.xml:857-858, + // :838-843, and `cls_bk_theme_circle` there is `theme_color`, + // :282-286). Its label is our own text: the skin's key carries a glyph + // from its icon font (a private-use glyph), which this build has no face for. left.addView( createKey("返回", true, ImeTypographyTokens.BODY_SP, onReturnToText).apply { tag = "key:mode" - markSideKey(this) + markAccentKey(this) }, columnCellParams(keyRowHeightDp() * NumberPadPolicy.BACK_ROWS), ) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3eca9534..a79437ff 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -187,7 +187,7 @@ Add new entries at the end. *Reason:* our keys were bare icons stretched by weight to fill the row, so they had no face at all and 收起 absorbed the leftover width. The keys now keep the reference's width and press shape, and the slack of the strip sits in a spacer before 收起; a strip or the autofill chips take that slack when they are open. Height stays our 48dp touch target rather than the reference's row height, for the same accessibility reason as the expand key. - **The number page is the reference's own number layout.** - The page is `input_kbd_number_from26.xml:5-42`, the layout the reference's 26-key `123` key opens (`input_kbd_pinyin26.xml:134`, `cls_button_123` with `target="9"`): a symbol rail over the return key in the left column, three digit columns each closed by a function cell, and a delete / space / send column on the right. The column weights are the reference's own 66 / 75 / 75 / 75 / 66 (`input_kbd_number_from26.xml:8,14-38`); the left column is the rail's 156 units against the return key's 52 (`input_kbd_number_from26.xml:10-15`), three key rows against one; send is `height_weight="2"` (`input_kbd_number_from26.xml:39`), twice the delete and space keys above it; and the digits read down the columns 1/4/7, 2/5/8, 3/6/9 with `符号`, `0` and `.` as the last cell of each (`input_kbd_number_from26.xml:17-34`). Every digit sits on `cls_keyboard_number9_button`, a white key with `key_number9` (`style.xml:394-395`), and the two function cells follow their own class: the return key is grey (`cls_button_back_26key` over `cls_button_back`, `style.xml:857-858,838-842`) and so is send (`cls_button_enter9` over `cls_keyboard9_button_func`, `style.xml:824-826,398-401`), which is why this page's space key is grey where the letter pages draw theirs white. + The page is `input_kbd_number_from26.xml:5-42`, the layout the reference's 26-key `123` key opens (`input_kbd_pinyin26.xml:134`, `cls_button_123` with `target="9"`): a symbol rail over the return key in the left column, three digit columns each closed by a function cell, and a delete / space / send column on the right. The column weights are the reference's own 66 / 75 / 75 / 75 / 66 (`input_kbd_number_from26.xml:8,14-38`); the left column is the rail's 156 units against the return key's 52 (`input_kbd_number_from26.xml:10-15`), three key rows against one; send is `height_weight="2"` (`input_kbd_number_from26.xml:39`), twice the delete and space keys above it; and the digits read down the columns 1/4/7, 2/5/8, 3/6/9 with `符号`, `0` and `.` as the last cell of each (`input_kbd_number_from26.xml:17-34`). Every digit sits on `cls_keyboard_number9_button`, a white key with `key_number9` (`style.xml:394-395`), and the two function cells follow their own class: the return key is the theme colour (`cls_button_back_26key` over `cls_button_back`, `style.xml:857-858`, `:838-843`, whose `cls_bk_theme_circle` carries `theme_color`, `:282-286`) and send is grey (`cls_button_enter9` over `cls_keyboard9_button_func`, `style.xml:824-826,398-401`), which is why this page's space key is grey where the letter pages draw theirs white. *Reason:* our page was a 3x3 grid over a `返回 / 空格 / 0` row with `退格 / . / @ / 发送` down the right — an arrangement the reference does not ship, with the zero parked beside the space bar and an `@` key no number page declares. A phone field in the reference dials from a page our build does not draw, so there the symbol cell carries the dialer's `#` and the period cell its `+` (our own rule, unchanged in effect: `*`, `+` and `#` stay reachable); the reference's other number page keeps the same rail and swaps only those two cells, 符号 at the foot of the left column and the return key in the digit grid (`input_kbd_number.xml:19-20,24-25`), so the slot is the page's, not the key's. - **The stroke grid is the reference's own nine cells, and the nine-key row its own weights.** @@ -247,3 +247,6 @@ Add new entries at the end. - **The composing strip splits 20 : 36, the reference's own two rows.** `input_toolbar_typing.xml:2-34` gives the pinyin line 20 height units (line 5) and the candidate row 36 (line 23), with a 2-unit gap under both (``, line 33). *Reason:* our 60dp strip divided those rows as 18 and 42, which cramped the pinyin line and gave the candidate row more room than the reference's proportion. 21 and 37 hold the reference's ratio and keep our zone's 2 units of gap inside the strip, so the keyboard below does not move. +- **The number page's return key wears the theme colour.** + The key that leaves the number page for the letter board is `cls_button_back_26key` (`input_kbd_number_from26.xml:13`), which takes `cls_button_back`: an icon key whose `cls_bk_theme_circle` fills with `theme_color` and presses to the white key's own fill (`style.xml:857-858`, `:838-843`, `:282-286`). Its glyph is a private-use character of the skin's icon font, so the label stays our own `返回`. + *Reason:* the entry above recorded this key as grey, read from the class name `cls_button_back_26key` rather than from what it inherits — the skin's "back" class is a theme-colour circle, not a neutral one. Our key now matches the skin's colour; the letter on it is our substitution, as the icon font is not ours to ship. From 1ad342724021d3b1cab697296d20c2a6fa75f25a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:22:23 +0800 Subject: [PATCH 43/94] Read the switch page and the English board from their own classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surfaces were wearing a neighbouring class's colours. The keyboard-switch page builds its tiles from cls_settings_option_mode_switch_button (input_switch_keyboard.xml:16-24), which declares an accent selectedtextcolor and ficon_textcolor_selected and no selectedbkcolor at all, against the settings page's cls_settings_button, which fills the chosen tile with theme_color and whitens its label (style.xml:649-668 against :626-647). We had read only the settings class, so the keyboard in use glowed as a solid accent card; it now keeps the card its neighbours wear and answers in the accent, shaded to the text floor. The English board's 中/英 key is cls_button_en2cn, whose own wordmark takes keyboard_current_english -- the accent at 95% on both skins (style.xml: 897-904, values/colors.xml:18, values/dark_colors.xml:17) -- where the Chinese boards' cls_button_cn2en draws its wordmark in the plain key text and dims only the corner mark (style.xml:882-890). The renderer now tags the key by the board it serves and the applier colours the English one's label; the accessibility lookup reads either tag. Also records, on the nine-key rail, that the skin's first-item class (cls_pinyin9_symbol_first_item, style.xml:567-573) has no shipped file naming its trigger, so its highlight stays unported rather than guessed at. The instrumented tile test moves with the first change: it now asserts the chosen tile's card and its accent name. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 + .../openime/LauncherTileInstrumentedTest.kt | 36 +++++++++++--- .../openime/keyboard/ImeKeyboardView.kt | 4 +- .../openime/keyboard/ImeThemeApplier.kt | 49 +++++++++++++------ .../keyboard/NineKeySymbolRailController.kt | 6 ++- .../keyboard/Pinyin26KeyboardRenderer.kt | 14 +++++- docs/DECISIONS.md | 9 ++++ 7 files changed, 94 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ce4648..ce44d4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 +- 英文 26 键上的「中/英」键文字改为主题色:参考皮肤里这个键在英文键盘上指向中文,主字用主题色画(`keyboard_current_english`,深浅两套都是 95% 的主题色);中文键盘上的同一个键仍用普通功能键文字色,只有右上角小字变淡。 +- 切换键盘页里当前键盘那一格改为参考皮肤自己的选中样式:卡片底色和旁边几格一样,只有图标与文字变成主题色(参考皮肤给这一类只写了选中文字与选中图标为主题色,没有写选中底色;工具页的磁贴才是整格填主题色、文字变白)。原来是整格填主题色、图标与文字变白。 - 数字页左下角的「返回」键改为参考皮肤自己的颜色:参考皮肤里这个键用主题色底(`cls_bk_theme_circle` 填 `theme_color`),按下换成白键的底色;原来我们把它画成了灰色键。(键上的字仍是我们的「返回」:参考皮肤用的是它自带的图标字体,这个字我们无法随包分发。) - 按键按下时改为参考皮肤的对调效果:参考皮肤里白键按下的颜色就是灰键的底色(`keybutton_pushed_bk` 等于 `keybutton_func_bk`),灰键按下则变成白键的底色(`keybutton_func_pushed_bk` 等于 `keybutton_bk`),深浅两套皮肤都是这一对颜色对调;原来两种键按下都是各自加深一点。主题色键(确定)仍是自己的按下色。 - 候选项按下时改为参考皮肤的候选底色(浅色是不透明白、深色是 8% 白),所有候选一格一样,第一个候选不再单独按主题色按压;候选栏右端的表情与展开键、符号页工具栏里的最近符号格也是同一个底色。原来是在各自的底色上加深一点,浅色下是按下去变暗。 diff --git a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt index cd22ab6f..745348e2 100644 --- a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt @@ -13,6 +13,7 @@ import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.panel.TileGridPolicy import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeReferenceSizing +import llc.slacker.openime.theme.ImeSurfacePolicy import llc.slacker.openime.theme.ImeTheme import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -136,20 +137,43 @@ class LauncherTileInstrumentedTest { } @Test - fun theChosenKeyboardWearsTheAccentTile() { + fun theChosenKeyboardKeepsTheCardAndAnswersInTheAccent() { withKeyboard { keyboard -> keyboard.showPanel(Panel.KEYBOARD_SELECT) val tokens = ImeTheme.IOS.tokens(ImeAppearance.LIGHT) val selected = keyboard.findViewWithTag("keyboard-choice-selected") assertNotNull("The open keyboard's tile must be drawn selected", selected) + // `cls_settings_option_mode_switch_button` declares no selected + // fill: the chosen tile wears the same card as its neighbours + // (`setting_button_bk`, `style.xml:649-668`), where the settings + // page's `cls_settings_button` fills with the accent. + val others = keyboard.findViewWithTag("keyboard-choice") + assertNotNull("Other keyboards keep the plain tile", others) assertEquals( - "`selectedbkcolor=\"@color/theme_color\"` on cls_settings_button", - tokens.primary, + "The chosen tile keeps the card", + tokens.toolCardBackground, faceColor(selected!!), ) - val others = keyboard.findViewWithTag("keyboard-choice") - assertNotNull("Other keyboards keep the plain tile", others) - assertEquals(tokens.toolCardBackground, faceColor(others!!)) + assertEquals( + "Every tile of the page shares that face", + faceColor(others!!), + faceColor(selected), + ) + // The choice shows in the name: `selectedtextcolor` is + // `theme_color`, read here as the accent the palette derives on + // the card. + val selectedLabel = (selected as ViewGroup).getChildAt(1) as TextView + val plainLabel = (others as ViewGroup).getChildAt(1) as TextView + assertEquals( + "`selectedtextcolor=\"@color/theme_color\"` on cls_settings_option_mode_switch_button", + ImeSurfacePolicy.accentTextOn(tokens.primary, tokens.toolCardBackground), + selectedLabel.currentTextColor, + ) + assertEquals( + "The other names keep the tile's neutral label", + tokens.keySecondaryText, + plainLabel.currentTextColor, + ) } } 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 4f74a544..19f54980 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -2319,7 +2319,9 @@ open class ImeKeyboardView( * the visual key wider or adding another persistent label. */ private fun syncModeAccessibility() { - val modeKey = findViewWithTag("key:mode") ?: return + val modeKey = findViewWithTag("key:mode") + ?: findViewWithTag("key:mode-en") + ?: return val target = when (mode) { KeyboardMode.PINYIN_26, KeyboardMode.PINYIN_9, KeyboardMode.STROKE, KeyboardMode.WUBI, KeyboardMode.DOUBLE_PINYIN, 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 86442446..f571e77b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -162,6 +162,16 @@ internal class ImeThemeApplier( view.setColors(t.lightKeyText, ImeSurfacePolicy.keyHint(t), t.lightKeyText) side -> view.setColors(t.sideKeyText, t.sideKeyText, t.sideKeyText) + // The English board's 中/英 key leads back to Chinese, and the + // reference tints its wordmark with `keyboard_current_english` — + // the accent at 95% on both skins (`cls_button_en2cn`, + // `style.xml:897-904`; `values/colors.xml:18`, + // `values/dark_colors.xml:17`). + view.tag == "key:mode-en" -> view.setColors( + ImeSurfacePolicy.accentTextOn(t.primary, t.functionKeyBackground), + t.functionKeyText, + t.functionKeyText, + ) function -> view.setColors( t.functionKeyText, @@ -268,20 +278,18 @@ internal class ImeThemeApplier( ) } "nine-symbol-scroll-content", "digits-symbol-scroll-content" -> view.background = null - // Tool-style tiles: the icon tile carries the colour, not the card. - // A launcher tile is the reference's own rounded card: the whole - // cell takes `setting_button_bk` with `bkround="14"`, the accent - // when it is the chosen one (`cls_settings_button`, style.xml:626-647). - "keyboard-choice" -> view.background = statefulRounded( + // The keyboard-switch page's tiles are their own class: the whole + // cell is the reference's rounded card (`setting_button_bk` with + // `bkround="14"`) and the chosen one keeps that face — its class + // declares no selected fill, only an accent label and glyph + // (`cls_settings_option_mode_switch_button`, style.xml:649-668), + // where the settings page's tiles fill with the accent + // (`cls_settings_button`, `:626-647`). + "keyboard-choice", "keyboard-choice-selected" -> view.background = statefulRounded( t.toolCardBackground, ImeSurfacePolicy.cardPressed(t), toPx(TileGridPolicy.TILE_RADIUS_DP), ) - "keyboard-choice-selected" -> view.background = statefulRounded( - t.primary, - ImeSurfacePolicy.primaryPressed(t), - toPx(TileGridPolicy.TILE_RADIUS_DP), - ) "segmented-track" -> view.background = ImeDrawableFactory.rounded( if (hasAncestorTag(view, "settings-panel")) ImeSurfacePolicy.controlTrack(t) else t.functionKeyBackground, toPx(12), @@ -446,9 +454,13 @@ internal class ImeThemeApplier( ) } view.tag == "tool-icon-selected" -> { - // The chosen tile is the accent, so its glyph reads on it - // (`setting_button_icon_selected` over a `theme_color` face). - view.imageTintList = ColorStateList.valueOf(ImeDrawableFactory.contrastText(t.primary)) + // The chosen tile's glyph answers in the accent on the plain + // card (`ficon_textcolor_selected`, `theme_color`, + // `cls_settings_option_mode_switch_button`, style.xml:649-668), + // in the same shade as the name beside it. + view.imageTintList = ColorStateList.valueOf( + ImeSurfacePolicy.accentTextOn(t.primary, t.toolCardBackground), + ) view.background = null } view.tag == "tool-icon" -> { @@ -737,10 +749,15 @@ internal class ImeThemeApplier( // The tile's name is the reference's dimmed one — `keybutton_text_3`, // 50% black or 50% white (`values/colors.xml:15`, // `values/dark_colors.xml:14`) — drawn in our palette's own secondary - // role, which keeps the 4.5:1 our text roles are held to, and white - // on the chosen tile (`setting_button_selectedtext`). + // role, which keeps the 4.5:1 our text roles are held to. The chosen + // tile's name and glyph answer in the accent (`selectedtextcolor` / + // `ficon_textcolor_selected` are `theme_color`, + // `cls_settings_option_mode_switch_button`, style.xml:649-668), + // shaded to that same floor over the card. tag == "tool-label" -> view.setTextColor(t.keySecondaryText) - tag == "tool-label-selected" -> view.setTextColor(ImeDrawableFactory.contrastText(t.primary)) + tag == "tool-label-selected" -> view.setTextColor( + ImeSurfacePolicy.accentTextOn(t.primary, t.toolCardBackground), + ) tag == "fuzzy-rules" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16)) tag == "textedit-spacer" -> view.background = null tag == "textedit-center" -> { diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index 63f54ef0..32cf24f7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -140,7 +140,11 @@ internal class NineKeySymbolRailController( // The reading the pre-edit is showing: the list leads with it and a tap // on it cannot change anything. The pre-edit is where the reference - // shows which reading is current, so nothing is highlighted here. + // shows which reading is current, so nothing is highlighted here. The + // skin does carry a first-item class for this list + // (`cls_pinyin9_symbol_first_item`, `style.xml:567-573` — accent text + // on the candidate fill, face 54 against the items' 46) but no shipped + // file names its trigger, so it stays unported rather than guessed at. val shown = tail.filter { it in 'a'..'z' } val selected = choices.firstOrNull { it.coversAll && it.syllables.joinToString("") == shown } ?: choices.firstOrNull { shown.startsWith(it.syllables.joinToString("")) } 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 11244b85..2884af5e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -118,7 +118,7 @@ internal class Pinyin26KeyboardRenderer( null, ImeTypographyTokens.BODY_SP, 0, - ) { onModeSwitch() }.apply { tag = "key:mode" }, + ) { onModeSwitch() }.apply { tag = modeKeyTag(english) }, flexKeyParams(weights.rightInner), ) bottom.addView( @@ -161,7 +161,7 @@ internal class Pinyin26KeyboardRenderer( gap() addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(2f)) addView(fn("。") { onCommitCharacter(if (english) "." else "。") }, flexKeyParams(1f)) - addView(fn("中/英") { onModeSwitch() }.apply { tag = "key:mode" }, flexKeyParams(1f)) + addView(fn("中/英") { onModeSwitch() }.apply { tag = modeKeyTag(english) }, flexKeyParams(1f)) addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" }, flexKeyParams(1f), @@ -297,6 +297,16 @@ internal class Pinyin26KeyboardRenderer( private companion object { const val SPLIT_GAP = 3.5f + /** + * The 中/英 key names which way it points. The reference draws its own + * wordmark in the accent on the English board, where the key leads back + * to Chinese (`cls_button_en2cn`'s `keyboard_current_english`, + * `style.xml:897-904`), and in the plain key text on the Chinese boards, + * where only the corner mark is dimmed (`cls_button_cn2en`, + * `style.xml:882-890`). The host's accessibility lookup reads both tags. + */ + fun modeKeyTag(english: Boolean): String = if (english) "key:mode-en" else "key:mode" + /** The reference skin's weight 15 against a letter's 10. */ const val EDGE_KEY_WEIGHT = 1.5f val ROWS = listOf("qwertyuiop", "asdfghjkl", "zxcvbnm") diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a79437ff..ea77d757 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -250,3 +250,12 @@ Add new entries at the end. - **The number page's return key wears the theme colour.** The key that leaves the number page for the letter board is `cls_button_back_26key` (`input_kbd_number_from26.xml:13`), which takes `cls_button_back`: an icon key whose `cls_bk_theme_circle` fills with `theme_color` and presses to the white key's own fill (`style.xml:857-858`, `:838-843`, `:282-286`). Its glyph is a private-use character of the skin's icon font, so the label stays our own `返回`. *Reason:* the entry above recorded this key as grey, read from the class name `cls_button_back_26key` rather than from what it inherits — the skin's "back" class is a theme-colour circle, not a neutral one. Our key now matches the skin's colour; the letter on it is our substitution, as the icon font is not ours to ship. +- **The keyboard-switch page's chosen tile keeps its card and answers in the accent.** + `input_switch_keyboard.xml:16-24` builds its four tiles from `cls_settings_option_mode_switch_button` (`style.xml:649-668`), which names `selectedtextcolor` and `ficon_textcolor_selected` as `theme_color` (`values/colors.xml:3`) and declares no `selectedbkcolor` at all — against the settings page's `cls_settings_button`, which fills the chosen tile with `theme_color` and whitens its label (`style.xml:626-647`, `setting_button_selectedtext`, `values/colors.xml:93`). + *Reason:* our switch page borrowed the settings page's selected treatment, so the keyboard in use glowed as a solid accent card. The two pages name different classes for the same tile shape and we had read only one of them; the chosen tile now keeps the card its neighbours wear and colours its name and glyph with the accent, shaded to the 4.5:1 our text roles are held to. +- **The space bar's name stays full-contrast.** + The reference's 26-key space is a wordmark glyph (`cls_button_space`, whose own text is a wordmark glyph in `fi_space_text1`) painted in `keybutton_text_2` — black 40% / white 40% (`style.xml:860-868`, `values/colors.xml:14`, `values/dark_colors.xml:13`) — while the nine-key and number pages paint their own 空格 in the full key text (`cls_keyboard9_button_space_func`, `style.xml:876-879`, and the white-key `cls_button_space_number9`, `:871-874`, both `keybutton_text`). + *Reason:* we draw the word 空格 where the reference draws an icon-font wordmark. 40% is the weight of a drawing there; as text it falls under the 4.5:1 our labels are held to, so our 26-key space keeps the key text colour, and our number page's space already wears the grey key the class names for it. +- **The English board's 中/英 key answers in the accent.** + The key carries the direction it points in the reference: on the English board it is `cls_button_en2cn`, whose own wordmark takes `keyboard_current_english` — the theme colour at 95%, declared the same on both skins (`style.xml:897-904`, `values/colors.xml:18`, `values/dark_colors.xml:17`) — while the Chinese boards' `cls_button_cn2en` draws its wordmark in the plain key text and dims only the corner mark to `cnen_button_light` (`style.xml:882-890`, `values/colors.xml:41`). Our English 26-key board's 中/英 key now takes the accent (shaded to the 4.5:1 floor over its grey face) and keeps the tag that says which way it points. + *Reason:* one key wore one colour on both boards, where the reference uses the colour to say that this key is the way back to Chinese. The label stays our own text: the reference's is an icon-font wordmark. From 6d2aa7d8d4f45b52c1fbab67eca2b4ce2b4a3de4 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:29:33 +0800 Subject: [PATCH 44/94] Draw the nine-key and stroke bottom rows on white keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both layouts put white circles either side of the space bar: the 123 key is cls_keyboard9_button_func_white_circle (input_kbd_pinyin9.xml:88, input_kbd_stroke.xml:70) and the 中/英 key the same white circle under cls_button_cn2en's and cls_button_stroke2en's own colours (input_kbd_pinyin9.xml:98, input_kbd_stroke.xml:82). The space between them is cls_button_space, itself a white-key subclass (style.xml:860-868), and only the 符号 key above the row keeps the grey function face. We had drawn both outer keys with the 26-key board's grey 123 face, so the row read as three key kinds where the reference draws one. The labels follow: the reference keeps both in the full key text, which is what the white role paints. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt | 9 +++++++-- .../slacker/openime/keyboard/StrokeKeyboardRenderer.kt | 8 ++++++-- docs/DECISIONS.md | 3 +++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce44d4a1..d00940da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 +- 九键和笔画键盘底行的「123」与「中/英」两个键改为白色键:参考皮肤给这两个键写的是白键底(`cls_keyboard9_button_func_white_circle` 与 `cls_bk_white_circle`),与中间的「空格」和上面的字母键一致;原来画成了灰色功能键。左列的「符号」键仍是灰键,与参考一致。 - 英文 26 键上的「中/英」键文字改为主题色:参考皮肤里这个键在英文键盘上指向中文,主字用主题色画(`keyboard_current_english`,深浅两套都是 95% 的主题色);中文键盘上的同一个键仍用普通功能键文字色,只有右上角小字变淡。 - 切换键盘页里当前键盘那一格改为参考皮肤自己的选中样式:卡片底色和旁边几格一样,只有图标与文字变成主题色(参考皮肤给这一类只写了选中文字与选中图标为主题色,没有写选中底色;工具页的磁贴才是整格填主题色、文字变白)。原来是整格填主题色、图标与文字变白。 - 数字页左下角的「返回」键改为参考皮肤自己的颜色:参考皮肤里这个键用主题色底(`cls_bk_theme_circle` 填 `theme_color`),按下换成白键的底色;原来我们把它画成了灰色键。(键上的字仍是我们的「返回」:参考皮肤用的是它自带的图标字体,这个字我们无法随包分发。) 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 da59a820..b53462c6 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -84,15 +84,20 @@ internal class Pinyin9KeyboardRenderer( ) val centerBottom = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() + // This row's two outer keys are white circles in the reference, not the + // grey function keys: 123 takes `cls_keyboard9_button_func_white_circle` + // (`input_kbd_pinyin9.xml:88`) and 中/英 the `cls_bk_white_circle` beside + // its `cls_button_cn2en` colours (`:98`), so they match the space bar + // between them and the letter keys above. centerBottom.addView( - createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markSideKey(this) }, + createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markWhiteKey(this) }, flexKeyParams(weights.side), ) centerBottom.addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(weights.space)) centerBottom.addView( createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, onModeSwitch).apply { tag = "key:mode" - markSideKey(this) + markWhiteKey(this) }, flexKeyParams(weights.side), ) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt index 93aff5ef..020c93bc 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -76,15 +76,19 @@ internal class StrokeKeyboardRenderer( ) val bottom = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() + // White circles either side of the space bar, as the nine-key row draws + // them: `cls_keyboard9_button_func_white_circle` for 123 + // (`input_kbd_stroke.xml:70`) and `cls_bk_white_circle` under + // `cls_button_stroke2en` for 中/英 (`:82`). bottom.addView( - createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markSideKey(this) }, + createKey("123", true, null, ImeTypographyTokens.BODY_SP, onDigits).apply { markWhiteKey(this) }, keyParams(keyRowHeightDp(), weights.side), ) bottom.addView(createSpaceVoiceKey("空格", onSpace), keyParams(keyRowHeightDp(), weights.space)) bottom.addView( createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, onModeSwitch).apply { tag = "key:mode" - markSideKey(this) + markWhiteKey(this) }, keyParams(keyRowHeightDp(), weights.side), ) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ea77d757..55db00a7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -259,3 +259,6 @@ Add new entries at the end. - **The English board's 中/英 key answers in the accent.** The key carries the direction it points in the reference: on the English board it is `cls_button_en2cn`, whose own wordmark takes `keyboard_current_english` — the theme colour at 95%, declared the same on both skins (`style.xml:897-904`, `values/colors.xml:18`, `values/dark_colors.xml:17`) — while the Chinese boards' `cls_button_cn2en` draws its wordmark in the plain key text and dims only the corner mark to `cnen_button_light` (`style.xml:882-890`, `values/colors.xml:41`). Our English 26-key board's 中/英 key now takes the accent (shaded to the 4.5:1 floor over its grey face) and keeps the tag that says which way it points. *Reason:* one key wore one colour on both boards, where the reference uses the colour to say that this key is the way back to Chinese. The label stays our own text: the reference's is an icon-font wordmark. +- **The nine-key and stroke boards' bottom row is white, not grey, either side of the space bar.** + In both layouts the 123 key is `cls_keyboard9_button_func_white_circle` (`input_kbd_pinyin9.xml:88`, `input_kbd_stroke.xml:70`) — the white circle with the full key text (`style.xml:404-407`) — and the 中/英 key is the same white circle under `cls_button_cn2en`'s or `cls_button_stroke2en`'s own colours (`input_kbd_pinyin9.xml:98`, `input_kbd_stroke.xml:82`). The space bar between them is `cls_button_space`, itself a white-key subclass (`style.xml:860-868`), while the 符号 key above the row keeps the grey function face. + *Reason:* we drew both outer keys with the grey function face, the one the 26-key board's 123 wears, so the row read as three different key kinds where the reference draws one white row. Their labels are full-contrast in the reference too (unlike the space's own wordmark), which is what our white key role already paints. From a3bc8c11d89eb46991046535103a4654f95d2ea9 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:44:44 +0800 Subject: [PATCH 45/94] Draw the panel header's close key as the reference's circle Both pages the reference gives a header open with cld_toolbar_close_button: a circle filled toolbar_idle_button_bk (white at 80% on the light skin, white at 12% on the dark one), pressing to toolbar_idle_button_pushedbk (black at 6% / white at 4%), its glyph in action_button_pushedtext. Our headers drew a bare arrow on the page ground, and inside the tools page it also fell into the tile-icon rule and took the accent, where the reference keeps this key out of the tiles' colour family. The circle paints 40dp inside the 48dp touch target; the glyph stays our own arrow, as the reference's is a private-use character of its icon font. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeThemeApplier.kt | 25 +++++++++++++---- .../slacker/openime/theme/ImeDesignTokens.kt | 28 +++++++++++++++++++ docs/DECISIONS.md | 5 ++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00940da..4ee1fd86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 - 九键和笔画键盘底行的「123」与「中/英」两个键改为白色键:参考皮肤给这两个键写的是白键底(`cls_keyboard9_button_func_white_circle` 与 `cls_bk_white_circle`),与中间的「空格」和上面的字母键一致;原来画成了灰色功能键。左列的「符号」键仍是灰键,与参考一致。 - 英文 26 键上的「中/英」键文字改为主题色:参考皮肤里这个键在英文键盘上指向中文,主字用主题色画(`keyboard_current_english`,深浅两套都是 95% 的主题色);中文键盘上的同一个键仍用普通功能键文字色,只有右上角小字变淡。 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 f571e77b..d513cbfd 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -475,12 +475,20 @@ internal class ImeThemeApplier( view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.background = null } + // The panel header's close key: the reference draws it as a circle + // on the header's own ground, in the shade it uses for button glyphs + // rather than the idle toolbar's icon grey — and never in the + // page's accent, which its tiles carry (`cld_toolbar_close_button`, + // style.xml:266-273). view.tag == "key-panel-back" -> { - view.imageTintList = ColorStateList.valueOf(t.keyText) - view.background = statefulRounded( - Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.keyboardBackground, t), - toPx(ImeGeometryTokens.PILL_RADIUS_DP), + view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.headerCloseIcon(t)) + view.background = paintedSquareWithinTarget( + statefulRounded( + ImeSurfacePolicy.headerCloseKey(t), + ImeSurfacePolicy.cardPressed(t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ), + HEADER_CLOSE_PAINTED_DP, ) } // The strips' own controls — the association back control and the @@ -991,6 +999,13 @@ internal class ImeThemeApplier( /** Painted heights of controls whose touch target is a full 48dp. */ const val SEGMENT_PAINTED_DP = 34 const val RETENTION_PAINTED_DP = 36 + + /** + * The header's close key is the reference's 40-unit circle + * (`cls_toolbar_idle_button` is 40 wide, `cld_toolbar_close_button` as + * wide as its row is high, `style.xml:237-248`, `:266-273`). + */ + const val HEADER_CLOSE_PAINTED_DP = 40 const val REFERENCE_ICON_SIZE = 0x1F000011 const val REFERENCE_TEXT_SIZE = 0x1F000010 val DIGITS_ONLY = Regex("[0-9]+") 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 4d4d4871..b0fe6c67 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -443,6 +443,34 @@ internal object ImeSurfacePolicy { */ fun toolbarIcon(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.withAlpha(tokens.keyText, 128) + /** + * The panel header's close key. Both pages the reference gives a header — + * the tools page and the keyboard switcher — open with one, drawn as a + * circle filled `toolbar_idle_button_bk`: white at 80% on the light skin + * and white at 12% on the dark one (`cld_toolbar_close_button`, + * `style.xml:266-273`; `input_settings.xml:6`, `input_switch_keyboard.xml:6`; + * `values/colors.xml:43`, `values/dark_colors.xml:41`). Its press is the + * skin's usual one — `toolbar_idle_button_pushedbk`, black at 6% on the + * light skin and white at 4% on the dark one (`values/colors.xml:45`, + * `values/dark_colors.xml:43`) — the same shade [cardPressed] lays on the + * header ground. + */ + fun headerCloseKey(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + Color.WHITE, + tokens.keyboardBackground, + if (isDark(tokens)) 0.12f else 0.8f, + ) + + /** + * The close key's glyph is `action_button_pushedtext`: black at 85% on the + * light skin and full white on the dark one, darker than the idle toolbar's + * own icon grey (`cld_toolbar_close_button`, `style.xml:266-273`; + * `values/colors.xml:38`, `values/dark_colors.xml:36`). + */ + fun headerCloseIcon(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.withAlpha(tokens.keyText, 217) + fun keyShadow(tokens: ImeTheme.Tokens): Int = if (isDark(tokens)) Color.parseColor("#66000000") else Color.parseColor("#4D000000") diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 55db00a7..f61d61cd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -262,3 +262,8 @@ Add new entries at the end. - **The nine-key and stroke boards' bottom row is white, not grey, either side of the space bar.** In both layouts the 123 key is `cls_keyboard9_button_func_white_circle` (`input_kbd_pinyin9.xml:88`, `input_kbd_stroke.xml:70`) — the white circle with the full key text (`style.xml:404-407`) — and the 中/英 key is the same white circle under `cls_button_cn2en`'s or `cls_button_stroke2en`'s own colours (`input_kbd_pinyin9.xml:98`, `input_kbd_stroke.xml:82`). The space bar between them is `cls_button_space`, itself a white-key subclass (`style.xml:860-868`), while the 符号 key above the row keeps the grey function face. *Reason:* we drew both outer keys with the grey function face, the one the 26-key board's 123 wears, so the row read as three different key kinds where the reference draws one white row. Their labels are full-contrast in the reference too (unlike the space's own wordmark), which is what our white key role already paints. +- **The panel header's close key is the reference's own circle.** + Both pages the reference gives a header open with `cld_toolbar_close_button` (`input_settings.xml:5-8`, `input_switch_keyboard.xml:5-8`): a circle whose only face is the idle fill, `bkcolor="@color/toolbar_idle_button_bk"` — white at 80% on the light skin and white at 12% on the dark one — pressing to `toolbar_idle_button_pushedbk` (black 6% / white 4%), with its glyph in `action_button_pushedtext`, the button-glyph colour rather than the idle toolbar's own icon grey (`style.xml:266-273`, `:237-248`, `values/colors.xml:38,43,45`, `values/dark_colors.xml:36,41,43`). Its width is `match_height`, the circle as wide as its row is high, and the toolbar's own circular keys are 40 units wide (`cls_toolbar_idle_button`, `style.xml:240-243`). + *Reason:* our panel headers drew a bare arrow on the page ground, which is neither the shape nor the colour the two pages name; the arrow also fell into the tools page's tile-icon rule and took the accent, where the reference keeps this key out of the tiles' colour family. The circle paints 40dp inside the 48dp touch target our controls hold to, and the glyph stays our own arrow: the reference's is a private-use character of its icon font. +- **Three reference classes stay unported, with the evidence.** `cls_quickinput_button` declares a press fill (`quickinput_button_pushedbk`, black 8% / `#FFe8ebf0`) for "the tap-to-commit button of quick input" (`style.xml:1254-1262`, `values/colors.xml:149`, `values/dark_colors.xml:143`), but no layout in the skin tree instantiates it — the quick-input row itself is built from `cls_toolbar_idle_button` keys and `canditem_text` labels (`input_toolbar_quickinput.xml`), which we ported. `cls_morecands_scroll` (`style.xml:1153`) is likewise declared and referenced nowhere in the shipped layouts. `cls_period_comma_fusiyin` does reach the layouts — it is composed into the two ,。 key classes (`style.xml:304`, `:312`) — but it only gives that key's corner mark its geometry and the 40% text colour (`ficon_textcolor="@color/keybutton_text_2"`, `style.xml:215-221`, `values/colors.xml:14`), which our corner hint already carries through `ImeSurfacePolicy.keyHint`. + *Reason:* a class the layouts never instantiate names no behaviour to port, and guessing at its trigger would invent one; the corner mark's colour was already the reference's. From 20078dd4e43cf7d1097d0989ad0b21195415ea3e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:46:55 +0800 Subject: [PATCH 46/94] Record the reference surfaces checked and left unported cls_quickinput_button, cls_morecands_scroll and the number keyboard's own symbol board name behaviour no shipped layout carries out; the period/comma corner class only repeats a colour our corner hint already wears. Written down so the next sweep does not re-read them as misses. Co-Authored-By: Claude Code --- docs/DECISIONS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f61d61cd..b13247bb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -267,3 +267,6 @@ Add new entries at the end. *Reason:* our panel headers drew a bare arrow on the page ground, which is neither the shape nor the colour the two pages name; the arrow also fell into the tools page's tile-icon rule and took the accent, where the reference keeps this key out of the tiles' colour family. The circle paints 40dp inside the 48dp touch target our controls hold to, and the glyph stays our own arrow: the reference's is a private-use character of its icon font. - **Three reference classes stay unported, with the evidence.** `cls_quickinput_button` declares a press fill (`quickinput_button_pushedbk`, black 8% / `#FFe8ebf0`) for "the tap-to-commit button of quick input" (`style.xml:1254-1262`, `values/colors.xml:149`, `values/dark_colors.xml:143`), but no layout in the skin tree instantiates it — the quick-input row itself is built from `cls_toolbar_idle_button` keys and `canditem_text` labels (`input_toolbar_quickinput.xml`), which we ported. `cls_morecands_scroll` (`style.xml:1153`) is likewise declared and referenced nowhere in the shipped layouts. `cls_period_comma_fusiyin` does reach the layouts — it is composed into the two ,。 key classes (`style.xml:304`, `:312`) — but it only gives that key's corner mark its geometry and the 40% text colour (`ficon_textcolor="@color/keybutton_text_2"`, `style.xml:215-221`, `values/colors.xml:14`), which our corner hint already carries through `ImeSurfacePolicy.keyHint`. *Reason:* a class the layouts never instantiate names no behaviour to port, and guessing at its trigger would invent one; the corner mark's colour was already the reference's. +- **The number keyboard's own symbol board keeps our shared symbol panel.** + `input_kbd_number_symbol_cn.xml` gives that board a flat ten-column grid with the digits 1-0 across its first row (`:5-16`), where the letter keyboards' symbol board is `input_kbd_symbol.xml`. We open the same category panel from every mode and point it at the 数学 category when it comes from the number page (`ImePanelRenderer.syncSymbolCategoryForMode`). + *Reason:* the ten-column grid is half the pitch our 48dp touch floor allows, and a digits row belongs to a flat numeric board rather than to a category list whose own categories carry the digits' work. Recorded so the next pass does not read the missing row as an oversight. From f717f6f752010754a04fea270247873f57abb0eb Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:58:33 +0800 Subject: [PATCH 47/94] Paint the expanded candidates page from its own classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference names every surface of input_more_cand.xml: the list sheet is morecands_list_bk (white at 40% light / 6% dark) edged at 2 units with morecands_list_border (black 6% / white 10%) and rounded 11, each cell repeating that border at 1 unit; the 全部/单字 group is morecands_filter_container_bk (black 4% / white 6%) with a white chosen chip on the light skin and black at 24% over the pill on the dark one, its label filter_button_selecttext and the other left at keybutton_text_2; the corner backspace takes morecands_backspace_bk and its pressed shade, this page's own opaque greys. We had drawn the sheet opaque white with the shared divider, the option group in the candidate-strip colour our page ground already matches (so the pill vanished), and the backspace as a grey function key. The chosen chip is one more face that sinks on the dark skin where ours brightened. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/ImeThemeApplier.kt | 49 +++++++++++---- .../slacker/openime/theme/ImeDesignTokens.kt | 59 +++++++++++++++++++ docs/DECISIONS.md | 3 + 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee1fd86..7cd67140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 - 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 - 九键和笔画键盘底行的「123」与「中/英」两个键改为白色键:参考皮肤给这两个键写的是白键底(`cls_keyboard9_button_func_white_circle` 与 `cls_bk_white_circle`),与中间的「空格」和上面的字母键一致;原来画成了灰色功能键。左列的「符号」键仍是灰键,与参考一致。 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 d513cbfd..09c6abdf 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -74,11 +74,14 @@ internal class ImeThemeApplier( // One item class in the reference (`cls_morecands_button`, // style.xml:1141-1149): every cell is a flat label with a hairline // edge on the list's own background, so no cell carries the accent - // tile here. + // tile here. The hairline is the sheet's own border colour at 1 + // unit, the same `morecands_list_border` the sheet's 2-unit edge + // takes (`values/colors.xml:117,123`). + val sheet = ImeSurfacePolicy.moreCandsSheet(t.candidateBackground, t) val edge = ImeDrawableFactory.rounded( Color.TRANSPARENT, 0, - ImeSurfacePolicy.divider(t), + ImeSurfacePolicy.moreCandsSheetEdge(sheet, t), toPx(1).coerceAtLeast(1), ) view.background = LayerDrawable( @@ -188,7 +191,10 @@ internal class ImeThemeApplier( // The option group 全部 / 单字 sits on (`cls_morecand_filter_containder`, // style.xml:1157-1159: a 20-unit pill in the darker neutral). "candidate-filter-container" -> - view.background = ImeDrawableFactory.rounded(t.candidateBackground, toPx(20)) + view.background = ImeDrawableFactory.rounded( + ImeSurfacePolicy.filterTrack(t.candidateBackground, t), + toPx(20), + ) "app-setting-key" -> { val radius = toPx(ImeGeometryTokens.KEY_RADIUS_DP) @@ -434,8 +440,8 @@ internal class ImeThemeApplier( // key that rounds only the list's bottom-right corner. view.imageTintList = ColorStateList.valueOf(t.functionKeyText) view.background = ImeDrawableFactory.statefulCorners( - normal = t.functionKeyBackground, - pressed = ImeSurfacePolicy.pressedSurface(t.functionKeyBackground, t), + normal = ImeSurfacePolicy.moreCandsBackspace(t), + pressed = ImeSurfacePolicy.moreCandsBackspacePressed(t), bottomRightPx = toPx(11).toFloat(), ) } @@ -588,17 +594,31 @@ internal class ImeThemeApplier( tag == "candidate-filter-chip" -> { // 全部 / 单字: the chosen one is the raised surface with plain // text, the other a dimmed label on the group's own background - // (`cls_filter_button`, style.xml:773-780). + // (`cls_filter_button`, style.xml:773-780). Its own text is + // `keybutton_text_2` — black or white at 40%, the corner-mark + // colour — and the chosen one's is `filter_button_selecttext`. val selected = view.isSelected - view.setTextColor(if (selected) t.keyText else t.textSecondary) + val track = ImeSurfacePolicy.filterTrack(t.candidateBackground, t) + view.setTextColor( + if (selected) { + ImeSurfacePolicy.filterChipText(t) + } else { + ImeSurfacePolicy.keyHint(t) + }, + ) view.typeface = if (selected) { android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) } else { android.graphics.Typeface.DEFAULT } + val chip = if (selected) ImeSurfacePolicy.filterChip(track, t) else Color.TRANSPARENT view.background = statefulRounded( - if (selected) t.keyBackground else Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.candidateBackground, t), + chip, + if (selected) { + ImeSurfacePolicy.pressedOnGround(chip, t) + } else { + ImeSurfacePolicy.pressedSurface(t.candidateBackground, t) + }, toPx(20), ) } @@ -876,14 +896,17 @@ internal class ImeThemeApplier( private fun applyFrameLayout(view: FrameLayout, t: ImeTheme.Tokens) { when (view.tag) { // The expanded page's list card (`cls_morecand_bk`, style.xml:1162-1166): - // one rounded surface for the whole grid, an 11 radius and a 2 edge. - "candidate-expanded-card" -> + // one rounded surface for the whole grid, an 11 radius and a 2 edge + // in the sheet's own border colour. + "candidate-expanded-card" -> { + val sheet = ImeSurfacePolicy.moreCandsSheet(t.candidateBackground, t) view.background = ImeDrawableFactory.rounded( - t.keyBackground, + sheet, toPx(11), - ImeSurfacePolicy.divider(t), + ImeSurfacePolicy.moreCandsSheetEdge(sheet, t), toPx(2).coerceAtLeast(1), ) + } "emoji-cell" -> { view.background = statefulRounded( 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 b0fe6c67..65a6d1db 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -607,6 +607,65 @@ internal object ImeSurfacePolicy { if (isDark(tokens)) ImeDrawableFactory.blend(tokens.keyText, controlTrack(tokens), 0.18f) else tokens.toolCardBackground + /** + * The expanded page's list sheet, laid over the page it opens on: the + * reference names it `morecands_list_bk` — white at 40% on the light skin + * and white at 6% on the dark one — with its 2-unit edge + * `morecands_list_border` (black 6% / white 10%) (`cls_morecand_bk`, + * `style.xml:1162-1166`, `values/colors.xml:121-123`, + * `values/dark_colors.xml:115-117`). Each candidate cell's hairline is the + * same border colour at 1 unit (`cls_morecands_button`, `style.xml:1141-1149`). + */ + fun moreCandsSheet(ground: Int, tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend(Color.WHITE, ground, if (isDark(tokens)) 0.06f else 0.4f) + + fun moreCandsSheetEdge(sheet: Int, tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + if (isDark(tokens)) Color.WHITE else Color.BLACK, + sheet, + if (isDark(tokens)) 0.10f else 0.06f, + ) + + /** + * The 全部 / 单字 group's own pill, `morecands_filter_container_bk`: black + * at 4% on the light skin and white at 6% on the dark one + * (`cls_morecand_filter_containder`, `style.xml:1157-1159`, + * `values/colors.xml:124`, `values/dark_colors.xml:118`). + */ + fun filterTrack(ground: Int, tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend( + if (isDark(tokens)) Color.WHITE else Color.BLACK, + ground, + if (isDark(tokens)) 0.06f else 0.04f, + ) + + /** + * The chosen option inside that group: opaque white on the light skin, and + * black at 24% over the group's pill on the dark one, where the reference's + * chosen chip sinks into its track (`filter_button_selectedbk`, + * `values/colors.xml:125`, `values/dark_colors.xml:119`). + */ + fun filterChip(track: Int, tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) ImeDrawableFactory.blend(Color.BLACK, track, 0.24f) + else Color.WHITE + + /** The chosen option's label: near-black on the light skin, white on the dark one. */ + fun filterChipText(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.WHITE else Color.parseColor("#141414") + + /** + * The expanded page's corner backspace: the reference gives that key its own + * opaque greys, `morecands_backspace_bk` — rgb(233,233,233) on the light + * skin, rgb(59,59,59) on the dark one — pressing to rgb(193,193,193) / + * rgb(42,42,42) (`input_more_cand.xml:22-26`, `values/colors.xml:127-128`, + * `values/dark_colors.xml:121-122`). + */ + fun moreCandsBackspace(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#3B3B3B") else Color.parseColor("#E9E9E9") + + fun moreCandsBackspacePressed(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#2A2A2A") else Color.parseColor("#C1C1C1") + fun destructiveText(tokens: ImeTheme.Tokens): Int { val surface = destructiveSurface(tokens) return if (ImeContrastPolicy.contrastRatio(tokens.destructive, surface) >= 4.5) { diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b13247bb..5882693e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -270,3 +270,6 @@ Add new entries at the end. - **The number keyboard's own symbol board keeps our shared symbol panel.** `input_kbd_number_symbol_cn.xml` gives that board a flat ten-column grid with the digits 1-0 across its first row (`:5-16`), where the letter keyboards' symbol board is `input_kbd_symbol.xml`. We open the same category panel from every mode and point it at the 数学 category when it comes from the number page (`ImePanelRenderer.syncSymbolCategoryForMode`). *Reason:* the ten-column grid is half the pitch our 48dp touch floor allows, and a digits row belongs to a flat numeric board rather than to a category list whose own categories carry the digits' work. Recorded so the next pass does not read the missing row as an oversight. +- **The expanded page wears the colours its own classes name.** + `cls_morecand_bk` fills the list sheet with `morecands_list_bk` — white at 40% on the light skin, white at 6% on the dark one — and edges it with `morecands_list_border`, black at 6% / white at 10%, at `bordersize="2"` and `bkround="11"`; each candidate cell repeats that border at 1 unit (`cls_morecands_button`, `style.xml:1141-1149`, `:1162-1166`, `values/colors.xml:121-123`, `values/dark_colors.xml:115-117`). The 全部 / 单字 group is `morecands_filter_container_bk`, black at 4% / white at 6% (`cls_morecand_filter_containder`, `style.xml:1157-1159`, `values/colors.xml:124`, `values/dark_colors.xml:118`), its chosen chip opaque white on the light skin and black at 24% over that pill on the dark one, labelled `filter_button_selecttext` with the other chip left in `keybutton_text_2` (`cls_filter_button`, `style.xml:773-780`, `values/colors.xml:125-126,14`). The corner backspace takes `morecands_backspace_bk` / `..._pushedbk`, this page's own opaque greys — rgb(233,233,233) and rgb(193,193,193) on the light skin, rgb(59,59,59) and rgb(42,42,42) on the dark one (`input_more_cand.xml:22-26`, `values/colors.xml:127-128`, `values/dark_colors.xml:121-122`). + *Reason:* we had drawn this page from general roles — an opaque white sheet with the shared divider for its grid, the candidate-strip colour for the option group (which our page ground already matches, so the pill vanished), and a grey function key for the corner backspace. The reference names all four surfaces itself, and the chosen chip is one more case of a selected face that sinks on the dark skin where ours brightened. From 0bb3ce0a34eab5cc172f011f2c5524c675d93a5e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:00:08 +0800 Subject: [PATCH 48/94] Note the rail's custom-symbol class among the unported ones Co-Authored-By: Claude Code --- docs/DECISIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5882693e..924dc8a5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -265,7 +265,7 @@ Add new entries at the end. - **The panel header's close key is the reference's own circle.** Both pages the reference gives a header open with `cld_toolbar_close_button` (`input_settings.xml:5-8`, `input_switch_keyboard.xml:5-8`): a circle whose only face is the idle fill, `bkcolor="@color/toolbar_idle_button_bk"` — white at 80% on the light skin and white at 12% on the dark one — pressing to `toolbar_idle_button_pushedbk` (black 6% / white 4%), with its glyph in `action_button_pushedtext`, the button-glyph colour rather than the idle toolbar's own icon grey (`style.xml:266-273`, `:237-248`, `values/colors.xml:38,43,45`, `values/dark_colors.xml:36,41,43`). Its width is `match_height`, the circle as wide as its row is high, and the toolbar's own circular keys are 40 units wide (`cls_toolbar_idle_button`, `style.xml:240-243`). *Reason:* our panel headers drew a bare arrow on the page ground, which is neither the shape nor the colour the two pages name; the arrow also fell into the tools page's tile-icon rule and took the accent, where the reference keeps this key out of the tiles' colour family. The circle paints 40dp inside the 48dp touch target our controls hold to, and the glyph stays our own arrow: the reference's is a private-use character of its icon font. -- **Three reference classes stay unported, with the evidence.** `cls_quickinput_button` declares a press fill (`quickinput_button_pushedbk`, black 8% / `#FFe8ebf0`) for "the tap-to-commit button of quick input" (`style.xml:1254-1262`, `values/colors.xml:149`, `values/dark_colors.xml:143`), but no layout in the skin tree instantiates it — the quick-input row itself is built from `cls_toolbar_idle_button` keys and `canditem_text` labels (`input_toolbar_quickinput.xml`), which we ported. `cls_morecands_scroll` (`style.xml:1153`) is likewise declared and referenced nowhere in the shipped layouts. `cls_period_comma_fusiyin` does reach the layouts — it is composed into the two ,。 key classes (`style.xml:304`, `:312`) — but it only gives that key's corner mark its geometry and the 40% text colour (`ficon_textcolor="@color/keybutton_text_2"`, `style.xml:215-221`, `values/colors.xml:14`), which our corner hint already carries through `ImeSurfacePolicy.keyHint`. +- **Four reference classes stay unported, with the evidence.** `cls_quickinput_button` declares a press fill (`quickinput_button_pushedbk`, black 8% / `#FFe8ebf0`) for "the tap-to-commit button of quick input" (`style.xml:1254-1262`, `values/colors.xml:149`, `values/dark_colors.xml:143`), but no layout in the skin tree instantiates it — the quick-input row itself is built from `cls_toolbar_idle_button` keys and `canditem_text` labels (`input_toolbar_quickinput.xml`), which we ported. `cls_morecands_scroll` (`style.xml:1153`) is likewise declared and referenced nowhere in the shipped layouts. `cls_keyboard_button_add_custom_symbol` — the rail's "自定义" entry with its plus glyph (`style.xml:1334-1344`) — has no layout user either, so the rail keeps our own circled plus. `cls_period_comma_fusiyin` does reach the layouts — it is composed into the two ,。 key classes (`style.xml:304`, `:312`) — but it only gives that key's corner mark its geometry and the 40% text colour (`ficon_textcolor="@color/keybutton_text_2"`, `style.xml:215-221`, `values/colors.xml:14`), which our corner hint already carries through `ImeSurfacePolicy.keyHint`. *Reason:* a class the layouts never instantiate names no behaviour to port, and guessing at its trigger would invent one; the corner mark's colour was already the reference's. - **The number keyboard's own symbol board keeps our shared symbol panel.** `input_kbd_number_symbol_cn.xml` gives that board a flat ten-column grid with the digits 1-0 across its first row (`:5-16`), where the letter keyboards' symbol board is `input_kbd_symbol.xml`. We open the same category panel from every mode and point it at the 数学 category when it comes from the number page (`ImePanelRenderer.syncSymbolCategoryForMode`). From 9311ff7d8883f2afa4a9734d9414387f4bdb1009 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:28:25 +0800 Subject: [PATCH 49/94] Keep the board area's reference side inset The area that holds the keys carries an inset of its own on top of the key margins: 2.5 units a side on the boards under `cls_input_kbd` and 1.5 on those under `cls_input_kbd9` (`input_kbd_inset_adaptive`, `style.xml:727-729` and `:736-738`, the area classes the layouts name at `input_kbd_pinyin26.xml:3`, `input_kbd_pinyin9.xml:3`). Without it the outer columns sat half as far from the edge as the columns beside them. The vertical half of those classes stays unported: it cancels the key margin so the first cap row starts flush under the toolbar, and our own top and bottom pads are what hold the keyboard height. Landscape, the floating window and the one-hand and split layouts keep their own side figures. The body padding is now set in one place, so the top-zone refresh no longer drops the inset. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/KeyMarginsInstrumentedTest.kt | 42 ++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 48 ++++++++++++++++--- .../slacker/openime/theme/ImeDesignTokens.kt | 21 ++++++++ docs/DECISIONS.md | 3 ++ 5 files changed, 108 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd67140..dd4cdf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 键区加上参考皮肤给键盘区域自己的侧边留白:26 键(拼音、英文)每侧 2.5 单位,九键、笔画和数字页每侧 1.5 单位(参考皮肤的 `cls_input_kbd_inset_adaptive` 与 `cls_input_kbd9_inset_adaptive`),最外一列两侧的空隙因此和键与键之间的空隙一样宽;原来只有键帽自己的边距,贴边的两列离屏幕边缘只有中间空隙的一半。键盘宽度、键位大小和键盘高度都不变(键帽比原来窄约 1 单位)。参考皮肤给这段留白写的上边距是负值(用来抵消键帽的上下边距,让第一排键帽贴着工具栏),我们没有照搬:顶部与底部的留白是我们自己调过的,键盘高度由它们决定。横屏、悬浮窗口和单手/分屏布局各自的侧边留白不变。 - 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 - 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt index faa2e0c2..299e6322 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -155,6 +155,48 @@ class KeyMarginsInstrumentedTest { } } + @Test + fun eachBoardAreaKeepsTheReferenceSideInset() { + withKeyboard { harness, keyboard -> + // The view takes its scale and its centering inset from the + // configuration's screen width, not from its own measured width + // (`ImeKeyboardView.referenceScale`). + val density = keyboard.resources.displayMetrics.density + val scale = ImeReferenceSizing.scale(keyboard.context) + val unit = density * scale + // A wide display pads the board inwards on top of the area's own + // inset (`ImeKeyboardView.updateResponsiveGeometry`). + val centering = maxOf(0, (keyboard.width - (minOf(600f, 390f * scale) * density).toInt()) / 2) + // The area class the reference gives each board: `cls_input_kbd` on + // the pinyin and English boards (`input_kbd_pinyin26.xml:3`, + // `input_kbd_english26.xml:3`), `cls_input_kbd9` on the nine-key, + // stroke and number ones (`input_kbd_pinyin9.xml:3`, + // `input_kbd_stroke.xml:3`, `input_kbd_number.xml:3`), 2.5 against + // 1.5 units (`style.xml:727-729`, `:736-738`). + val areas = listOf( + KeyboardMode.PINYIN_26 to ImeGeometryTokens.BOARD_SIDE_INSET_DP, + KeyboardMode.ENGLISH_26 to ImeGeometryTokens.BOARD_SIDE_INSET_DP, + KeyboardMode.PINYIN_9 to ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP, + KeyboardMode.STROKE to ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP, + KeyboardMode.DIGITS to ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP, + ) + for ((mode, inset) in areas) { + harness.awaitMain { keyboard.setMode(mode); true } + val expected = centering + Math.round(inset * unit) + val body = harness.awaitMain { + keyboard.findViewWithTag("keyboard-body")?.takeIf { it.paddingLeft > 0 } + } + assertNotNull("$mode lays out a keyboard body", body) + assertEquals( + "$mode keeps the reference's board side inset", + expected.toLong(), + body!!.paddingLeft.toLong(), + ) + assertEquals("$mode insets both sides equally", body.paddingLeft, body.paddingRight) + } + } + } + @Test fun aPanelKeepsTheKeyMarginWhateverBoardOpenedIt() { withKeyboard { harness, keyboard -> 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 19f54980..07c73e7d 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1338,8 +1338,9 @@ open class ImeKeyboardView( } // 单手模式把键面收窄贴向一侧;左右分离布局本身就是两侧的安排,不再叠加。 val oneHand = if (splitKeyboard()) null else OneHandPolicy.insets(measuredWidthPx, oneHandMode, contentInsetPx) - val bodyLeft = oneHand?.first ?: contentInsetPx - val bodyRight = oneHand?.second ?: contentInsetPx + val boardInsetPx = boardSideInsetPx() + val bodyLeft = (oneHand?.first ?: contentInsetPx) + boardInsetPx + val bodyRight = (oneHand?.second ?: contentInsetPx) + boardInsetPx val panelLeft = oneHand?.first ?: moreCandidatesInsetPx val panelRight = oneHand?.second ?: moreCandidatesInsetPx keyboardBody.setPadding( @@ -1380,6 +1381,37 @@ open class ImeKeyboardView( * and its ancestors. So once the pass is over, flag the containers that * were changed. */ + /** + * The boards the reference gives the nine-key area class `cls_input_kbd9` — + * nine-key, stroke and the number page — as against the 26-key area class + * `cls_input_kbd` on the pinyin and English boards + * (`layout/input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, + * `input_kbd_number.xml:3`; `input_kbd_pinyin26.xml:3`, + * `input_kbd_english26.xml:3`). The same split sets their key margins and + * their board-side inset. + */ + private fun usesNineKeyBoardArea(): Boolean = + mode == KeyboardMode.PINYIN_9 || + mode == KeyboardMode.STROKE || + mode == KeyboardMode.DIGITS + + /** + * The board area keeps a side inset of its own, on top of the key margins, + * so the outer columns breathe by the same amount as the gaps between keys: + * 2.5 units on the 26-key boards, 1.5 on the nine-key ones + * (`cls_input_kbd_inset_adaptive`, `style.xml:727-729` and + * `cls_input_kbd9_inset_adaptive`, `:736-738`, the classes + * [usesNineKeyBoardArea] tells apart). Landscape and the floating window + * carry their own side figures, and the one-hand and split layouts arrange + * the two sides themselves. + */ + private fun boardSideInsetPx(): Int = when { + floatingKeyboardController.enabled -> dp(0) + appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE -> dp(0) + usesNineKeyBoardArea() -> dp(ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP) + else -> dp(ImeGeometryTokens.BOARD_SIDE_INSET_DP) + } + private fun scheduleRelayout() { if (relayoutPosted) return relayoutPosted = true @@ -2242,10 +2274,11 @@ open class ImeKeyboardView( keyboardHost.layoutParams = it } } + val boardInsetPx = boardSideInsetPx() keyboardBody.setPadding( - contentInsetPx, + contentInsetPx + boardInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), - contentInsetPx, + contentInsetPx + boardInsetPx, dp(layoutMetrics.bottomPadDp), ) val state = when { @@ -2286,9 +2319,7 @@ open class ImeKeyboardView( // tighter 2.5 × 2.75 (`cls_keyboard9_button_margin`, `style.xml:373-374`), // the 26-key boards 2.25 × 4.75 (`cls_keyboard_button_margin`, // `style.xml:289-290`, see ImeGeometryTokens). - val nineKeyFamily = mode == KeyboardMode.PINYIN_9 || - mode == KeyboardMode.STROKE || - mode == KeyboardMode.DIGITS + val nineKeyFamily = usesNineKeyBoardArea() keyMarginX = if (nineKeyFamily) ImeGeometryTokens.NINE_KEY_MARGIN_X_DP else ImeGeometryTokens.KEY_MARGIN_X_DP keyMarginY = if (nineKeyFamily) ImeGeometryTokens.NINE_KEY_MARGIN_Y_DP else ImeGeometryTokens.KEY_MARGIN_Y_DP when (mode) { @@ -3728,6 +3759,9 @@ open class ImeKeyboardView( } private fun dp(value: Int): Int = kotlin.math.round(value * resources.displayMetrics.density * referenceScale).toInt() + + /** The reference states a few figures as fractions of a unit (2.25, 4.75). */ + private fun dp(value: Float): Int = kotlin.math.round(value * resources.displayMetrics.density * referenceScale).toInt() private fun gridCellParams( heightDp: Int, columns: Int, 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 65a6d1db..e2cbc305 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -86,6 +86,27 @@ internal object ImeGeometryTokens { const val NINE_KEY_MARGIN_X_DP = 2.5f const val NINE_KEY_MARGIN_Y_DP = 2.75f const val KEY_INNER_MARGIN_DP = 8f + + /** + * The board area keeps a side inset of its own on top of the key margins, + * so the outer columns breathe by the same amount as the gaps between + * keys: the 26-key area 2.5 units each side (`cls_input_kbd_inset_adaptive` + * `inset="2.5,-4.75,2.5,0"`, `style.xml:727-729`, named `cls_input_kbd` at + * `:764-766`), the nine-key area 1.5 (`cls_input_kbd9_inset_adaptive` + * `inset="1.5,-2.75,1.5,0"`, `style.xml:736-738`, `cls_input_kbd9` at + * `:768-770`) — the class the nine-key, stroke and number layouts carry, + * against the 26-key one on the pinyin and English boards + * (`layout/input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, + * `input_kbd_number.xml:3` and `input_kbd_pinyin26.xml:3`, + * `input_kbd_english26.xml:3`). + * + * The vertical half of those insets stays unported: it cancels the key + * margin so the first cap row starts flush under the toolbar (`style.xml:726` + * — "57 都给了 padding"), and our keyboard keeps its own top and bottom + * pads, which are what hold the height that the emulator gates measure. + */ + const val BOARD_SIDE_INSET_DP = 2.5f + const val NINE_KEY_SIDE_INSET_DP = 1.5f const val KEY_POPUP_HEIGHT_DP = 52 /** * The long-press bubble is rounder than a key and carries no edge of its diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 924dc8a5..fc3f2a1c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -273,3 +273,6 @@ Add new entries at the end. - **The expanded page wears the colours its own classes name.** `cls_morecand_bk` fills the list sheet with `morecands_list_bk` — white at 40% on the light skin, white at 6% on the dark one — and edges it with `morecands_list_border`, black at 6% / white at 10%, at `bordersize="2"` and `bkround="11"`; each candidate cell repeats that border at 1 unit (`cls_morecands_button`, `style.xml:1141-1149`, `:1162-1166`, `values/colors.xml:121-123`, `values/dark_colors.xml:115-117`). The 全部 / 单字 group is `morecands_filter_container_bk`, black at 4% / white at 6% (`cls_morecand_filter_containder`, `style.xml:1157-1159`, `values/colors.xml:124`, `values/dark_colors.xml:118`), its chosen chip opaque white on the light skin and black at 24% over that pill on the dark one, labelled `filter_button_selecttext` with the other chip left in `keybutton_text_2` (`cls_filter_button`, `style.xml:773-780`, `values/colors.xml:125-126,14`). The corner backspace takes `morecands_backspace_bk` / `..._pushedbk`, this page's own opaque greys — rgb(233,233,233) and rgb(193,193,193) on the light skin, rgb(59,59,59) and rgb(42,42,42) on the dark one (`input_more_cand.xml:22-26`, `values/colors.xml:127-128`, `values/dark_colors.xml:121-122`). *Reason:* we had drawn this page from general roles — an opaque white sheet with the shared divider for its grid, the candidate-strip colour for the option group (which our page ground already matches, so the pill vanished), and a grey function key for the corner backspace. The reference names all four surfaces itself, and the chosen chip is one more case of a selected face that sinks on the dark skin where ours brightened. +- **The board area keeps the reference's own side inset, not its top one.** + The area that holds the keys carries an inset of its own, on top of each key class's margins: `cls_input_kbd_inset_adaptive` `inset="2.5,-4.75,2.5,0"` on the boards that wear `cls_input_kbd` — pinyin and English (`input_kbd_pinyin26.xml:3`, `input_kbd_english26.xml:3`, `style.xml:727-729`, `:764-766`) — and `cls_input_kbd9_inset_adaptive` `inset="1.5,-2.75,1.5,0"` on the nine-key, stroke and number boards under `cls_input_kbd9` (`input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, `input_kbd_number.xml:3`, `style.xml:736-738`, `:768-770`). So the outer columns stand 4.75 units off the edge on the 26-key board, where two neighbouring caps stand 4.5 apart, and 4.0 on the nine-key one. We took the sideways figure only. + *Reason:* our boards drew the edge gap from the key margin alone, so the outermost column sat half as far from the edge as the columns beside it, which the reference does not do. Its vertical half cancels the key margin deliberately — the whole 57 units above the board are padding, so the first cap row starts flush under the toolbar (`style.xml:726`) — and porting that would move every key up and take the height out of our own top and bottom pads, which are what hold the keyboard height the emulator gates measure. Landscape and the floating window keep their own side figures, and the one-hand and split layouts arrange both sides themselves. From 993f240ad2e633d5bc40dffd538d1d733b91ccc6 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:34:56 +0800 Subject: [PATCH 50/94] Give the expanded candidates page its own side inset The page carries the reference's `cls_input_kbd_morecand_inset_adaptive` side figure of 2.5 units whatever board opened it (`style.xml:731-733`, `input_more_cand.xml:3`), so its card stands off the screen edges where we let it run edge to edge. The landscape fraction already sits in the page's own inset. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- .../openime/KeyMarginsInstrumentedTest.kt | 30 +++++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 8 +++-- .../slacker/openime/theme/ImeDesignTokens.kt | 7 +++++ docs/DECISIONS.md | 4 +-- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4cdf94..8691c09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 键区加上参考皮肤给键盘区域自己的侧边留白:26 键(拼音、英文)每侧 2.5 单位,九键、笔画和数字页每侧 1.5 单位(参考皮肤的 `cls_input_kbd_inset_adaptive` 与 `cls_input_kbd9_inset_adaptive`),最外一列两侧的空隙因此和键与键之间的空隙一样宽;原来只有键帽自己的边距,贴边的两列离屏幕边缘只有中间空隙的一半。键盘宽度、键位大小和键盘高度都不变(键帽比原来窄约 1 单位)。参考皮肤给这段留白写的上边距是负值(用来抵消键帽的上下边距,让第一排键帽贴着工具栏),我们没有照搬:顶部与底部的留白是我们自己调过的,键盘高度由它们决定。横屏、悬浮窗口和单手/分屏布局各自的侧边留白不变。 +- 键区加上参考皮肤给键盘区域自己的侧边留白:26 键(拼音、英文)每侧 2.5 单位,九键、笔画和数字页每侧 1.5 单位(参考皮肤的 `cls_input_kbd_inset_adaptive` 与 `cls_input_kbd9_inset_adaptive`),最外一列两侧的空隙因此和键与键之间的空隙一样宽;原来只有键帽自己的边距,贴边的两列离屏幕边缘只有中间空隙的一半。键盘宽度、键位大小和键盘高度都不变(键帽比原来窄约 1 单位)。参考皮肤给这段留白写的上边距是负值(用来抵消键帽的上下边距,让第一排键帽贴着工具栏),我们没有照搬:顶部与底部的留白是我们自己调过的,键盘高度由它们决定。横屏、悬浮窗口和单手/分屏布局各自的侧边留白不变。展开候选页同时补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。 - 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 - 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt index 299e6322..38d1d631 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -5,6 +5,7 @@ import android.graphics.drawable.InsetDrawable import android.view.View import android.view.ViewGroup 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.keyboard.ImeKeyboardView @@ -197,6 +198,35 @@ class KeyMarginsInstrumentedTest { } } + @Test + fun theExpandedCandidatesPageKeepsTheReferenceSideInset() { + withKeyboard { harness, keyboard -> + val density = keyboard.resources.displayMetrics.density + val scale = ImeReferenceSizing.scale(keyboard.context) + val centering = maxOf(0, (keyboard.width - (minOf(600f, 390f * scale) * density).toInt()) / 2) + // The page carries its own side figure whatever board opened it + // (`cls_input_kbd_morecand_inset_adaptive`, `style.xml:731-733`). + val expected = centering + Math.round(ImeGeometryTokens.MORE_CAND_SIDE_INSET_DP * density * scale) + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + harness.awaitMain { + keyboard.renderState(ImeState(composition = "nihao", candidates = listOf("你好", "您好"))) + true + } + harness.awaitMain { keyboard.findViewWithTag("candidate-expand").performClick(); true } + val overlay = harness.awaitMain { + keyboard.findViewWithTag("candidate-overlay") + ?.takeIf { it.visibility == View.VISIBLE && it.paddingLeft > 0 } + } + assertNotNull("the expanded page opens", overlay) + assertEquals( + "the expanded page keeps the reference's side inset", + expected.toLong(), + overlay!!.paddingLeft.toLong(), + ) + assertEquals("the expanded page insets both sides equally", overlay.paddingLeft, overlay.paddingRight) + } + } + @Test fun aPanelKeepsTheKeyMarginWhateverBoardOpenedIt() { withKeyboard { harness, keyboard -> 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 07c73e7d..b89de314 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1341,8 +1341,12 @@ open class ImeKeyboardView( val boardInsetPx = boardSideInsetPx() val bodyLeft = (oneHand?.first ?: contentInsetPx) + boardInsetPx val bodyRight = (oneHand?.second ?: contentInsetPx) + boardInsetPx - val panelLeft = oneHand?.first ?: moreCandidatesInsetPx - val panelRight = oneHand?.second ?: moreCandidatesInsetPx + // 展开候选页自己也留一道侧边留白(2.5 单位,参考皮肤给这一页写的 + // `cls_input_kbd_morecand_inset_adaptive`,style.xml:731-733);横屏的 + // 那一道按页宽比例算,已经在上面的 moreCandidatesInsetPx 里。 + val panelInsetPx = if (landscape) dp(0) else dp(ImeGeometryTokens.MORE_CAND_SIDE_INSET_DP) + val panelLeft = (oneHand?.first ?: moreCandidatesInsetPx) + panelInsetPx + val panelRight = (oneHand?.second ?: moreCandidatesInsetPx) + panelInsetPx keyboardBody.setPadding( bodyLeft, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), 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 e2cbc305..89d00c81 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -105,6 +105,13 @@ internal object ImeGeometryTokens { * — "57 都给了 padding"), and our keyboard keeps its own top and bottom * pads, which are what hold the height that the emulator gates measure. */ + /** + * The expanded candidates page insets its content 2.5 units a side as well, + * whatever board opened it (`cls_input_kbd_morecand_inset_adaptive` + * `inset="2.5,0,2.5,0"`, `style.xml:731-733`, on the page itself at + * `layout/input_more_cand.xml:3`), and draws no top or bottom inset at all. + */ + const val MORE_CAND_SIDE_INSET_DP = 2.5f const val BOARD_SIDE_INSET_DP = 2.5f const val NINE_KEY_SIDE_INSET_DP = 1.5f const val KEY_POPUP_HEIGHT_DP = 52 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fc3f2a1c..2227e987 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -273,6 +273,6 @@ Add new entries at the end. - **The expanded page wears the colours its own classes name.** `cls_morecand_bk` fills the list sheet with `morecands_list_bk` — white at 40% on the light skin, white at 6% on the dark one — and edges it with `morecands_list_border`, black at 6% / white at 10%, at `bordersize="2"` and `bkround="11"`; each candidate cell repeats that border at 1 unit (`cls_morecands_button`, `style.xml:1141-1149`, `:1162-1166`, `values/colors.xml:121-123`, `values/dark_colors.xml:115-117`). The 全部 / 单字 group is `morecands_filter_container_bk`, black at 4% / white at 6% (`cls_morecand_filter_containder`, `style.xml:1157-1159`, `values/colors.xml:124`, `values/dark_colors.xml:118`), its chosen chip opaque white on the light skin and black at 24% over that pill on the dark one, labelled `filter_button_selecttext` with the other chip left in `keybutton_text_2` (`cls_filter_button`, `style.xml:773-780`, `values/colors.xml:125-126,14`). The corner backspace takes `morecands_backspace_bk` / `..._pushedbk`, this page's own opaque greys — rgb(233,233,233) and rgb(193,193,193) on the light skin, rgb(59,59,59) and rgb(42,42,42) on the dark one (`input_more_cand.xml:22-26`, `values/colors.xml:127-128`, `values/dark_colors.xml:121-122`). *Reason:* we had drawn this page from general roles — an opaque white sheet with the shared divider for its grid, the candidate-strip colour for the option group (which our page ground already matches, so the pill vanished), and a grey function key for the corner backspace. The reference names all four surfaces itself, and the chosen chip is one more case of a selected face that sinks on the dark skin where ours brightened. -- **The board area keeps the reference's own side inset, not its top one.** +- **The board takes the reference's own side inset, but not its top one.** The area that holds the keys carries an inset of its own, on top of each key class's margins: `cls_input_kbd_inset_adaptive` `inset="2.5,-4.75,2.5,0"` on the boards that wear `cls_input_kbd` — pinyin and English (`input_kbd_pinyin26.xml:3`, `input_kbd_english26.xml:3`, `style.xml:727-729`, `:764-766`) — and `cls_input_kbd9_inset_adaptive` `inset="1.5,-2.75,1.5,0"` on the nine-key, stroke and number boards under `cls_input_kbd9` (`input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, `input_kbd_number.xml:3`, `style.xml:736-738`, `:768-770`). So the outer columns stand 4.75 units off the edge on the 26-key board, where two neighbouring caps stand 4.5 apart, and 4.0 on the nine-key one. We took the sideways figure only. - *Reason:* our boards drew the edge gap from the key margin alone, so the outermost column sat half as far from the edge as the columns beside it, which the reference does not do. Its vertical half cancels the key margin deliberately — the whole 57 units above the board are padding, so the first cap row starts flush under the toolbar (`style.xml:726`) — and porting that would move every key up and take the height out of our own top and bottom pads, which are what hold the keyboard height the emulator gates measure. Landscape and the floating window keep their own side figures, and the one-hand and split layouts arrange both sides themselves. + *Reason:* our boards drew the edge gap from the key margin alone, so the outermost column sat half as far from the edge as the columns beside it, which the reference does not do. Its vertical half cancels the key margin deliberately — the whole 57 units above the board are padding, so the first cap row starts flush under the toolbar (`style.xml:726`) — and porting that would move every key up and take the height out of our own top and bottom pads, which are what hold the keyboard height the emulator gates measure. Landscape and the floating window keep their own side figures, and the one-hand and split layouts arrange both sides themselves. The expanded candidates page takes its own 2.5-unit side inset in the same pass (`cls_input_kbd_morecand_inset_adaptive`, `style.xml:731-733`, on the page at `input_more_cand.xml:3`), so its card no longer touches the screen edges. From daf9556c5d8983c2a5acbdc6c8fdd9a6b151930f Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:39:38 +0800 Subject: [PATCH 51/94] Record why the landscape faces keep one compactness factor The reference's landscape font table shrinks each family by its own ratio, and `land/style.xml` carries no row height to scale those faces against: every row in the portrait layouts is `height="match_parent"` with no weight, so the landscape geometry stays inside the reference's own engine. The figures are written down for a later pass. Co-Authored-By: Claude Code --- docs/DECISIONS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2227e987..b7bfdae5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -276,3 +276,6 @@ Add new entries at the end. - **The board takes the reference's own side inset, but not its top one.** The area that holds the keys carries an inset of its own, on top of each key class's margins: `cls_input_kbd_inset_adaptive` `inset="2.5,-4.75,2.5,0"` on the boards that wear `cls_input_kbd` — pinyin and English (`input_kbd_pinyin26.xml:3`, `input_kbd_english26.xml:3`, `style.xml:727-729`, `:764-766`) — and `cls_input_kbd9_inset_adaptive` `inset="1.5,-2.75,1.5,0"` on the nine-key, stroke and number boards under `cls_input_kbd9` (`input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, `input_kbd_number.xml:3`, `style.xml:736-738`, `:768-770`). So the outer columns stand 4.75 units off the edge on the 26-key board, where two neighbouring caps stand 4.5 apart, and 4.0 on the nine-key one. We took the sideways figure only. *Reason:* our boards drew the edge gap from the key margin alone, so the outermost column sat half as far from the edge as the columns beside it, which the reference does not do. Its vertical half cancels the key margin deliberately — the whole 57 units above the board are padding, so the first cap row starts flush under the toolbar (`style.xml:726`) — and porting that would move every key up and take the height out of our own top and bottom pads, which are what hold the keyboard height the emulator gates measure. Landscape and the floating window keep their own side figures, and the one-hand and split layouts arrange both sides themselves. The expanded candidates page takes its own 2.5-unit side inset in the same pass (`cls_input_kbd_morecand_inset_adaptive`, `style.xml:731-733`, on the page at `input_more_cand.xml:3`), so its card no longer touches the screen edges. +- **The landscape key faces keep our one compactness factor, not the reference's per-family figures.** + The reference states a landscape face for every family beside its portrait one — `key_26_land_letter` 51 against `key_letter` 57, `key_26_land_letter_english` 54 against 60, `key_stroke_land` 45 against 60, `key_pinyin9_land_letter` 39 against `key_letter9` 48, `key_pinyin9_land_number` 36 against `key_number9` 54, `key_number_land_key` 39, `key_symbol_land_key` 48 against 57, `key_26_land_punctuation` 54 against 72 (`style.xml:4-9`, `:20-26`, `:36-43`, `:50`, `:53`) — so its landscape shrinks each family by its own ratio, from 0.90 (English letters) down to 0.67 (nine-key digits). Our landscape shrinks the whole surface by one factor, `ImeReferenceSizing.scale`'s height term, which lands on the reference's own 26-key letter ratio (0.895 against our 0.884 on a 411dp-high display) and leaves the nine-key, stroke and number faces about 2-3 units larger than the reference's landscape figures. + *Reason:* the landscape half of the skin declares only weights, fractions and paddings — `land/style.xml` overrides `cls_input_kbd_adaptive` to `padding1x="0,50,0,0"` and the area insets to `0.0648508` / `0.095` of the window, and its font table is the only place a landscape *size* appears. The row heights those faces sit in come from the reference's own layout engine, and every row in the portrait layouts is `height="match_parent"` with no weight (`input_kbd_pinyin26.xml:4,47,91,132`), so there is no figure to scale a per-family face against: taking 15 units for the stroke keys would put them on rows we would have to invent. Recorded with the figures so a later pass that finds the landscape geometry can finish the job. From 997ffda83f7956a65721f7c3941b25e982a9ed25 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:44:24 +0800 Subject: [PATCH 52/94] Record the colour sweep and the accent-key label rule All 86 of the reference's 170 colour names that nothing outside the colour tables mentions are written down, with the ones that belong to surfaces we ship and the colour those classes actually name. The action key's resting label keeps our own contrast rule over the reference's white, with the ratios for both palettes. Co-Authored-By: Claude Code --- docs/DECISIONS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b7bfdae5..38322a57 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -267,6 +267,7 @@ Add new entries at the end. *Reason:* our panel headers drew a bare arrow on the page ground, which is neither the shape nor the colour the two pages name; the arrow also fell into the tools page's tile-icon rule and took the accent, where the reference keeps this key out of the tiles' colour family. The circle paints 40dp inside the 48dp touch target our controls hold to, and the glyph stays our own arrow: the reference's is a private-use character of its icon font. - **Four reference classes stay unported, with the evidence.** `cls_quickinput_button` declares a press fill (`quickinput_button_pushedbk`, black 8% / `#FFe8ebf0`) for "the tap-to-commit button of quick input" (`style.xml:1254-1262`, `values/colors.xml:149`, `values/dark_colors.xml:143`), but no layout in the skin tree instantiates it — the quick-input row itself is built from `cls_toolbar_idle_button` keys and `canditem_text` labels (`input_toolbar_quickinput.xml`), which we ported. `cls_morecands_scroll` (`style.xml:1153`) is likewise declared and referenced nowhere in the shipped layouts. `cls_keyboard_button_add_custom_symbol` — the rail's "自定义" entry with its plus glyph (`style.xml:1334-1344`) — has no layout user either, so the rail keeps our own circled plus. `cls_period_comma_fusiyin` does reach the layouts — it is composed into the two ,。 key classes (`style.xml:304`, `:312`) — but it only gives that key's corner mark its geometry and the 40% text colour (`ficon_textcolor="@color/keybutton_text_2"`, `style.xml:215-221`, `values/colors.xml:14`), which our corner hint already carries through `ImeSurfacePolicy.keyHint`. *Reason:* a class the layouts never instantiate names no behaviour to port, and guessing at its trigger would invent one; the corner mark's colour was already the reference's. + A sweep over the colour tables says the same about 86 of their 170 names (`values/colors.xml`, `values/dark_colors.xml`, `values/transparent_colors.xml`): nothing outside those files mentions them. Some are key faces we do have — `backspace_button_selectedbk` rgb(207,207,207) / rgb(43,43,43), `history_symbollist_pushedbk`, `history_symbollist_ficon_text`, `number_symbollist_bk`, `number_symbollist_pushedbk`, `keybutton_ficon_disabledtext`, `morecands_list_pushedbk` — and their classes name a different colour instead (`cls_history_symbollist_button` presses to `canditem_pushedbk`, `style.xml:1061-1071`, the one we already paint). The rest belongs to surfaces we do not ship: the `entrance_*` and `recommend_*` blocks, `correct_morecheck_*`, `chat_assistant_*`, `ai_common_tip_text`, and the 助聊 chip's own `canditem_selectedbk` / `canditem_pinyin_text` (`style.xml:541`, `:1076-1085`), which is the only user of the selected-candidate fill. Every colour a shipped layout does instantiate is ported. - **The number keyboard's own symbol board keeps our shared symbol panel.** `input_kbd_number_symbol_cn.xml` gives that board a flat ten-column grid with the digits 1-0 across its first row (`:5-16`), where the letter keyboards' symbol board is `input_kbd_symbol.xml`. We open the same category panel from every mode and point it at the 数学 category when it comes from the number page (`ImePanelRenderer.syncSymbolCategoryForMode`). *Reason:* the ten-column grid is half the pitch our 48dp touch floor allows, and a digits row belongs to a flat numeric board rather than to a category list whose own categories carry the digits' work. Recorded so the next pass does not read the missing row as an oversight. @@ -279,3 +280,6 @@ Add new entries at the end. - **The landscape key faces keep our one compactness factor, not the reference's per-family figures.** The reference states a landscape face for every family beside its portrait one — `key_26_land_letter` 51 against `key_letter` 57, `key_26_land_letter_english` 54 against 60, `key_stroke_land` 45 against 60, `key_pinyin9_land_letter` 39 against `key_letter9` 48, `key_pinyin9_land_number` 36 against `key_number9` 54, `key_number_land_key` 39, `key_symbol_land_key` 48 against 57, `key_26_land_punctuation` 54 against 72 (`style.xml:4-9`, `:20-26`, `:36-43`, `:50`, `:53`) — so its landscape shrinks each family by its own ratio, from 0.90 (English letters) down to 0.67 (nine-key digits). Our landscape shrinks the whole surface by one factor, `ImeReferenceSizing.scale`'s height term, which lands on the reference's own 26-key letter ratio (0.895 against our 0.884 on a 411dp-high display) and leaves the nine-key, stroke and number faces about 2-3 units larger than the reference's landscape figures. *Reason:* the landscape half of the skin declares only weights, fractions and paddings — `land/style.xml` overrides `cls_input_kbd_adaptive` to `padding1x="0,50,0,0"` and the area insets to `0.0648508` / `0.095` of the window, and its font table is the only place a landscape *size* appears. The row heights those faces sit in come from the reference's own layout engine, and every row in the portrait layouts is `height="match_parent"` with no weight (`input_kbd_pinyin26.xml:4,47,91,132`), so there is no figure to scale a per-family face against: taking 15 units for the stroke keys would put them on rows we would have to invent. Recorded with the figures so a later pass that finds the landscape geometry can finish the job. +- **The accent key's label keeps our contrast rule, not the reference's white.** + `cls_button_enter` names `action_button_text` for the label of every action key — white on all three palettes, since only the light `values/colors.xml:36` defines it and the dark and translucent files do not override it — over `theme_color` rgb(79,132,255) (`style.xml:809-816`, `values/colors.xml:3`), a pairing of 3.46:1. Our action label is `contrastText(primary)` (`ImeThemeApplier`, `ImeDrawableFactory.contrastText` → `ImeContrastPolicy.contrastText`), which takes the darker of the two: 5.67:1 against our light `#1D9BF0` and 8.76:1 against our dark `#6EC3F7`, where white would give 3.00:1 and 1.94:1. + *Reason:* the second entry of this log sets a 4.5:1 floor for text and our palette's dark skin is a deliberately lighter blue than the light skin's, so the reference's white label would sit at 1.94:1 there. The key face, its pressed face and the pressed label are the reference's own; only the resting label follows the contrast rule. From 6a39de18c8bbb6c2a576c68fc65a32bdd50a7158 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:05:22 +0800 Subject: [PATCH 53/94] Revert the portrait board's reference side inset The board area's `cls_input_kbd_inset_adaptive` and `cls_input_kbd9_inset_adaptive` side insets narrow every key on the portrait 26-key and nine-key boards. The owner wants those two layouts left as they are, so the boards keep only the edge gap their own key margins give them, and only the expanded candidates page keeps a side inset of its own (2.5 units: it moves a card, not keys). Measured on the portrait 26-key board at 420dpi: first key cap left edge 14px -> 7px and cell pitch back to 107.8px (106.3px with the inset); the nine-key rail edge 12px -> 8px. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- .../openime/KeyMarginsInstrumentedTest.kt | 42 ------------------- .../openime/keyboard/ImeKeyboardView.kt | 30 ++++--------- .../slacker/openime/theme/ImeDesignTokens.kt | 25 ++--------- docs/DECISIONS.md | 8 ++-- 5 files changed, 16 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8691c09b..c4b3c083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 键区加上参考皮肤给键盘区域自己的侧边留白:26 键(拼音、英文)每侧 2.5 单位,九键、笔画和数字页每侧 1.5 单位(参考皮肤的 `cls_input_kbd_inset_adaptive` 与 `cls_input_kbd9_inset_adaptive`),最外一列两侧的空隙因此和键与键之间的空隙一样宽;原来只有键帽自己的边距,贴边的两列离屏幕边缘只有中间空隙的一半。键盘宽度、键位大小和键盘高度都不变(键帽比原来窄约 1 单位)。参考皮肤给这段留白写的上边距是负值(用来抵消键帽的上下边距,让第一排键帽贴着工具栏),我们没有照搬:顶部与底部的留白是我们自己调过的,键盘高度由它们决定。横屏、悬浮窗口和单手/分屏布局各自的侧边留白不变。展开候选页同时补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。 +- 展开候选页补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。竖屏键区不加参考皮肤给键区的那道侧边留白——它会改动九键与 26 键的键位大小,竖屏布局保持原样。 - 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 - 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 - 键盘字面按参考皮肤每一类键自己的字号绘制:26 键英文字母小写 20sp(参考的 `key_letter_english` 60 单位,大写仍是拼音的 19sp)、九键字母 16sp(`key_letter9` 48,`@#` 键仍是 18sp)、笔画键 20sp(`key_stroke` 60,「通配」14sp、「:」「;」「@#」18sp)、数字页与分屏数字格 18sp(`key_number9` 54)。原来这些键分别借用候选字号 18sp、符号字号 24sp 和字母字号 21sp。 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt index 38d1d631..e9de3998 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -156,48 +156,6 @@ class KeyMarginsInstrumentedTest { } } - @Test - fun eachBoardAreaKeepsTheReferenceSideInset() { - withKeyboard { harness, keyboard -> - // The view takes its scale and its centering inset from the - // configuration's screen width, not from its own measured width - // (`ImeKeyboardView.referenceScale`). - val density = keyboard.resources.displayMetrics.density - val scale = ImeReferenceSizing.scale(keyboard.context) - val unit = density * scale - // A wide display pads the board inwards on top of the area's own - // inset (`ImeKeyboardView.updateResponsiveGeometry`). - val centering = maxOf(0, (keyboard.width - (minOf(600f, 390f * scale) * density).toInt()) / 2) - // The area class the reference gives each board: `cls_input_kbd` on - // the pinyin and English boards (`input_kbd_pinyin26.xml:3`, - // `input_kbd_english26.xml:3`), `cls_input_kbd9` on the nine-key, - // stroke and number ones (`input_kbd_pinyin9.xml:3`, - // `input_kbd_stroke.xml:3`, `input_kbd_number.xml:3`), 2.5 against - // 1.5 units (`style.xml:727-729`, `:736-738`). - val areas = listOf( - KeyboardMode.PINYIN_26 to ImeGeometryTokens.BOARD_SIDE_INSET_DP, - KeyboardMode.ENGLISH_26 to ImeGeometryTokens.BOARD_SIDE_INSET_DP, - KeyboardMode.PINYIN_9 to ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP, - KeyboardMode.STROKE to ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP, - KeyboardMode.DIGITS to ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP, - ) - for ((mode, inset) in areas) { - harness.awaitMain { keyboard.setMode(mode); true } - val expected = centering + Math.round(inset * unit) - val body = harness.awaitMain { - keyboard.findViewWithTag("keyboard-body")?.takeIf { it.paddingLeft > 0 } - } - assertNotNull("$mode lays out a keyboard body", body) - assertEquals( - "$mode keeps the reference's board side inset", - expected.toLong(), - body!!.paddingLeft.toLong(), - ) - assertEquals("$mode insets both sides equally", body.paddingLeft, body.paddingRight) - } - } - } - @Test fun theExpandedCandidatesPageKeepsTheReferenceSideInset() { withKeyboard { harness, keyboard -> 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 b89de314..d2f216e0 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1338,9 +1338,11 @@ open class ImeKeyboardView( } // 单手模式把键面收窄贴向一侧;左右分离布局本身就是两侧的安排,不再叠加。 val oneHand = if (splitKeyboard()) null else OneHandPolicy.insets(measuredWidthPx, oneHandMode, contentInsetPx) - val boardInsetPx = boardSideInsetPx() - val bodyLeft = (oneHand?.first ?: contentInsetPx) + boardInsetPx - val bodyRight = (oneHand?.second ?: contentInsetPx) + boardInsetPx + // 键区不再另加侧边留白:参考皮肤给键区写的那一道(`cls_input_kbd_inset_adaptive` + // 2.5 单位、`cls_input_kbd9_inset_adaptive` 1.5 单位,style.xml:727-729、:736-738) + // 会改动竖屏键位大小,owner 要求竖屏九键与 26 键布局保持原样,见 docs/DECISIONS.md。 + val bodyLeft = oneHand?.first ?: contentInsetPx + val bodyRight = oneHand?.second ?: contentInsetPx // 展开候选页自己也留一道侧边留白(2.5 单位,参考皮肤给这一页写的 // `cls_input_kbd_morecand_inset_adaptive`,style.xml:731-733);横屏的 // 那一道按页宽比例算,已经在上面的 moreCandidatesInsetPx 里。 @@ -1399,23 +1401,6 @@ open class ImeKeyboardView( mode == KeyboardMode.STROKE || mode == KeyboardMode.DIGITS - /** - * The board area keeps a side inset of its own, on top of the key margins, - * so the outer columns breathe by the same amount as the gaps between keys: - * 2.5 units on the 26-key boards, 1.5 on the nine-key ones - * (`cls_input_kbd_inset_adaptive`, `style.xml:727-729` and - * `cls_input_kbd9_inset_adaptive`, `:736-738`, the classes - * [usesNineKeyBoardArea] tells apart). Landscape and the floating window - * carry their own side figures, and the one-hand and split layouts arrange - * the two sides themselves. - */ - private fun boardSideInsetPx(): Int = when { - floatingKeyboardController.enabled -> dp(0) - appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE -> dp(0) - usesNineKeyBoardArea() -> dp(ImeGeometryTokens.NINE_KEY_SIDE_INSET_DP) - else -> dp(ImeGeometryTokens.BOARD_SIDE_INSET_DP) - } - private fun scheduleRelayout() { if (relayoutPosted) return relayoutPosted = true @@ -2278,11 +2263,10 @@ open class ImeKeyboardView( keyboardHost.layoutParams = it } } - val boardInsetPx = boardSideInsetPx() keyboardBody.setPadding( - contentInsetPx + boardInsetPx, + contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), - contentInsetPx + boardInsetPx, + contentInsetPx, dp(layoutMetrics.bottomPadDp), ) val state = when { 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 89d00c81..3af67791 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -88,32 +88,15 @@ internal object ImeGeometryTokens { const val KEY_INNER_MARGIN_DP = 8f /** - * The board area keeps a side inset of its own on top of the key margins, - * so the outer columns breathe by the same amount as the gaps between - * keys: the 26-key area 2.5 units each side (`cls_input_kbd_inset_adaptive` - * `inset="2.5,-4.75,2.5,0"`, `style.xml:727-729`, named `cls_input_kbd` at - * `:764-766`), the nine-key area 1.5 (`cls_input_kbd9_inset_adaptive` - * `inset="1.5,-2.75,1.5,0"`, `style.xml:736-738`, `cls_input_kbd9` at - * `:768-770`) — the class the nine-key, stroke and number layouts carry, - * against the 26-key one on the pinyin and English boards - * (`layout/input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, - * `input_kbd_number.xml:3` and `input_kbd_pinyin26.xml:3`, - * `input_kbd_english26.xml:3`). - * - * The vertical half of those insets stays unported: it cancels the key - * margin so the first cap row starts flush under the toolbar (`style.xml:726` - * — "57 都给了 padding"), and our keyboard keeps its own top and bottom - * pads, which are what hold the height that the emulator gates measure. - */ - /** - * The expanded candidates page insets its content 2.5 units a side as well, + * The expanded candidates page insets its content 2.5 units a side, * whatever board opened it (`cls_input_kbd_morecand_inset_adaptive` * `inset="2.5,0,2.5,0"`, `style.xml:731-733`, on the page itself at * `layout/input_more_cand.xml:3`), and draws no top or bottom inset at all. + * The board area's own insets (`cls_input_kbd_inset_adaptive`, style.xml:727-729, + * and `cls_input_kbd9_inset_adaptive`, :736-738) are deliberately not ported: + * they resize the portrait keys, which the owner wants unchanged. */ const val MORE_CAND_SIDE_INSET_DP = 2.5f - const val BOARD_SIDE_INSET_DP = 2.5f - const val NINE_KEY_SIDE_INSET_DP = 1.5f const val KEY_POPUP_HEIGHT_DP = 52 /** * The long-press bubble is rounder than a key and carries no edge of its diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 38322a57..55025ef2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -267,16 +267,16 @@ Add new entries at the end. *Reason:* our panel headers drew a bare arrow on the page ground, which is neither the shape nor the colour the two pages name; the arrow also fell into the tools page's tile-icon rule and took the accent, where the reference keeps this key out of the tiles' colour family. The circle paints 40dp inside the 48dp touch target our controls hold to, and the glyph stays our own arrow: the reference's is a private-use character of its icon font. - **Four reference classes stay unported, with the evidence.** `cls_quickinput_button` declares a press fill (`quickinput_button_pushedbk`, black 8% / `#FFe8ebf0`) for "the tap-to-commit button of quick input" (`style.xml:1254-1262`, `values/colors.xml:149`, `values/dark_colors.xml:143`), but no layout in the skin tree instantiates it — the quick-input row itself is built from `cls_toolbar_idle_button` keys and `canditem_text` labels (`input_toolbar_quickinput.xml`), which we ported. `cls_morecands_scroll` (`style.xml:1153`) is likewise declared and referenced nowhere in the shipped layouts. `cls_keyboard_button_add_custom_symbol` — the rail's "自定义" entry with its plus glyph (`style.xml:1334-1344`) — has no layout user either, so the rail keeps our own circled plus. `cls_period_comma_fusiyin` does reach the layouts — it is composed into the two ,。 key classes (`style.xml:304`, `:312`) — but it only gives that key's corner mark its geometry and the 40% text colour (`ficon_textcolor="@color/keybutton_text_2"`, `style.xml:215-221`, `values/colors.xml:14`), which our corner hint already carries through `ImeSurfacePolicy.keyHint`. *Reason:* a class the layouts never instantiate names no behaviour to port, and guessing at its trigger would invent one; the corner mark's colour was already the reference's. - A sweep over the colour tables says the same about 86 of their 170 names (`values/colors.xml`, `values/dark_colors.xml`, `values/transparent_colors.xml`): nothing outside those files mentions them. Some are key faces we do have — `backspace_button_selectedbk` rgb(207,207,207) / rgb(43,43,43), `history_symbollist_pushedbk`, `history_symbollist_ficon_text`, `number_symbollist_bk`, `number_symbollist_pushedbk`, `keybutton_ficon_disabledtext`, `morecands_list_pushedbk` — and their classes name a different colour instead (`cls_history_symbollist_button` presses to `canditem_pushedbk`, `style.xml:1061-1071`, the one we already paint). The rest belongs to surfaces we do not ship: the `entrance_*` and `recommend_*` blocks, `correct_morecheck_*`, `chat_assistant_*`, `ai_common_tip_text`, and the 助聊 chip's own `canditem_selectedbk` / `canditem_pinyin_text` (`style.xml:541`, `:1076-1085`), which is the only user of the selected-candidate fill. Every colour a shipped layout does instantiate is ported. + A sweep over the colour tables says the same about 86 of their 170 names (`values/colors.xml`, `values/dark_colors.xml`, `values/transparent_colors.xml`): nothing outside those files mentions them. Some are key faces we do have — `backspace_button_selectedbk` rgb(207,207,207) / rgb(43,43,43), `history_symbollist_pushedbk`, `history_symbollist_ficon_text`, `number_symbollist_bk`, `number_symbollist_pushedbk`, `keybutton_ficon_disabledtext`, `morecands_list_pushedbk` — and their classes name a different colour instead (`cls_history_symbollist_button` presses to `canditem_pushedbk`, `style.xml:1061-1071`, the one we already paint). The rest belongs to surfaces we do not ship: the `entrance_*` and `recommend_*` blocks, `correct_morecheck_*`, `chat_assistant_*`, `ai_common_tip_text`, and the 助聊 chip's own `canditem_selectedbk` / `canditem_pinyin_text` (`style.xml:541`, `:1076-1085`), which is the only user of the selected-candidate fill. Of the names a layout does use, the ones we have not written down are the disabled states (`setting_button_disabled*`, `toolbar_idle_button_*disabled`, `action_button_text_disable`, `highlight_disabledtext`), the translate and tip-upgrade surfaces (`trans_*`, `toolbar_tip_upgrade_*`), the page backgrounds (`default_bk`, `entrance_bk`), the two bubble shadows and `touch_tip_button_bk` — whose faces we paint from our own palettes — and `keybutton_back` / `keybutton_func_border` / `vertical_symbol_list_button_border`, whose classes name no border at the figures we use. - **The number keyboard's own symbol board keeps our shared symbol panel.** `input_kbd_number_symbol_cn.xml` gives that board a flat ten-column grid with the digits 1-0 across its first row (`:5-16`), where the letter keyboards' symbol board is `input_kbd_symbol.xml`. We open the same category panel from every mode and point it at the 数学 category when it comes from the number page (`ImePanelRenderer.syncSymbolCategoryForMode`). *Reason:* the ten-column grid is half the pitch our 48dp touch floor allows, and a digits row belongs to a flat numeric board rather than to a category list whose own categories carry the digits' work. Recorded so the next pass does not read the missing row as an oversight. - **The expanded page wears the colours its own classes name.** `cls_morecand_bk` fills the list sheet with `morecands_list_bk` — white at 40% on the light skin, white at 6% on the dark one — and edges it with `morecands_list_border`, black at 6% / white at 10%, at `bordersize="2"` and `bkround="11"`; each candidate cell repeats that border at 1 unit (`cls_morecands_button`, `style.xml:1141-1149`, `:1162-1166`, `values/colors.xml:121-123`, `values/dark_colors.xml:115-117`). The 全部 / 单字 group is `morecands_filter_container_bk`, black at 4% / white at 6% (`cls_morecand_filter_containder`, `style.xml:1157-1159`, `values/colors.xml:124`, `values/dark_colors.xml:118`), its chosen chip opaque white on the light skin and black at 24% over that pill on the dark one, labelled `filter_button_selecttext` with the other chip left in `keybutton_text_2` (`cls_filter_button`, `style.xml:773-780`, `values/colors.xml:125-126,14`). The corner backspace takes `morecands_backspace_bk` / `..._pushedbk`, this page's own opaque greys — rgb(233,233,233) and rgb(193,193,193) on the light skin, rgb(59,59,59) and rgb(42,42,42) on the dark one (`input_more_cand.xml:22-26`, `values/colors.xml:127-128`, `values/dark_colors.xml:121-122`). *Reason:* we had drawn this page from general roles — an opaque white sheet with the shared divider for its grid, the candidate-strip colour for the option group (which our page ground already matches, so the pill vanished), and a grey function key for the corner backspace. The reference names all four surfaces itself, and the chosen chip is one more case of a selected face that sinks on the dark skin where ours brightened. -- **The board takes the reference's own side inset, but not its top one.** - The area that holds the keys carries an inset of its own, on top of each key class's margins: `cls_input_kbd_inset_adaptive` `inset="2.5,-4.75,2.5,0"` on the boards that wear `cls_input_kbd` — pinyin and English (`input_kbd_pinyin26.xml:3`, `input_kbd_english26.xml:3`, `style.xml:727-729`, `:764-766`) — and `cls_input_kbd9_inset_adaptive` `inset="1.5,-2.75,1.5,0"` on the nine-key, stroke and number boards under `cls_input_kbd9` (`input_kbd_pinyin9.xml:3`, `input_kbd_stroke.xml:3`, `input_kbd_number.xml:3`, `style.xml:736-738`, `:768-770`). So the outer columns stand 4.75 units off the edge on the 26-key board, where two neighbouring caps stand 4.5 apart, and 4.0 on the nine-key one. We took the sideways figure only. - *Reason:* our boards drew the edge gap from the key margin alone, so the outermost column sat half as far from the edge as the columns beside it, which the reference does not do. Its vertical half cancels the key margin deliberately — the whole 57 units above the board are padding, so the first cap row starts flush under the toolbar (`style.xml:726`) — and porting that would move every key up and take the height out of our own top and bottom pads, which are what hold the keyboard height the emulator gates measure. Landscape and the floating window keep their own side figures, and the one-hand and split layouts arrange both sides themselves. The expanded candidates page takes its own 2.5-unit side inset in the same pass (`cls_input_kbd_morecand_inset_adaptive`, `style.xml:731-733`, on the page at `input_more_cand.xml:3`), so its card no longer touches the screen edges. +- **The portrait board keeps its own side figures: the reference's board inset is not ported.** + The area that holds the keys carries an inset of its own on top of each key class's margins — `cls_input_kbd_inset_adaptive` `inset="2.5,-4.75,2.5,0"` on the pinyin and English boards and `cls_input_kbd9_inset_adaptive` `inset="1.5,-2.75,1.5,0"` on the nine-key, stroke and number ones (`style.xml:727-729`, `:736-738`, on the classes `input_kbd_pinyin26.xml:3` and `input_kbd_pinyin9.xml:3` wear) — and it was tried on 2026-10-15 in `9311ff7`, then reverted the same day. Our boards keep the edge gap their key margins give them: 2.25 units on the 26-key board, 2.5 on the nine-key family. + *Reason:* the inset shifts every key on the board, 1.4px narrower over ten columns in portrait at 420dpi, and the owner wants the portrait nine-key and 26-key layouts left as they are. The expanded candidates page still takes its own 2.5-unit side inset (`cls_input_kbd_morecand_inset_adaptive`, `style.xml:731-733`, on the page at `input_more_cand.xml:3`): it moves a card, not keys. - **The landscape key faces keep our one compactness factor, not the reference's per-family figures.** The reference states a landscape face for every family beside its portrait one — `key_26_land_letter` 51 against `key_letter` 57, `key_26_land_letter_english` 54 against 60, `key_stroke_land` 45 against 60, `key_pinyin9_land_letter` 39 against `key_letter9` 48, `key_pinyin9_land_number` 36 against `key_number9` 54, `key_number_land_key` 39, `key_symbol_land_key` 48 against 57, `key_26_land_punctuation` 54 against 72 (`style.xml:4-9`, `:20-26`, `:36-43`, `:50`, `:53`) — so its landscape shrinks each family by its own ratio, from 0.90 (English letters) down to 0.67 (nine-key digits). Our landscape shrinks the whole surface by one factor, `ImeReferenceSizing.scale`'s height term, which lands on the reference's own 26-key letter ratio (0.895 against our 0.884 on a 411dp-high display) and leaves the nine-key, stroke and number faces about 2-3 units larger than the reference's landscape figures. *Reason:* the landscape half of the skin declares only weights, fractions and paddings — `land/style.xml` overrides `cls_input_kbd_adaptive` to `padding1x="0,50,0,0"` and the area insets to `0.0648508` / `0.095` of the window, and its font table is the only place a landscape *size* appears. The row heights those faces sit in come from the reference's own layout engine, and every row in the portrait layouts is `height="match_parent"` with no weight (`input_kbd_pinyin26.xml:4,47,91,132`), so there is no figure to scale a per-family face against: taking 15 units for the stroke keys would put them on rows we would have to invent. Recorded with the figures so a later pass that finds the landscape geometry can finish the job. From 61e15b245d0551ec8500c7a83a307addfa524573 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:06:09 +0800 Subject: [PATCH 54/94] Add the reference's pinyin display setting: candidate bar or input box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doubao ships 「输入拼音显示」 as `pinyin_location_style` with defaultValue 0 (显示在候选栏) and 1 for 显示在输入框, and writes the preedit into the app's own field only in the second case: `UpdateCompStr` gates on that value plus `isSupportPinyinInEditor()` (KeyboardJni.java:3678-3691), `cacheCompStrAndPreedit` runs `UpdatePreedit` (:3902-3906), and that paints the pinyin with `setComposingRegion` (:3765) and `setComposingText` (:3772). Port the same two choices beside 双拼方案 in the app's settings page; the default stays the candidate bar, as the reference's own default is. ComposingTextPolicy holds the gate on our side. A password field never takes the composition, whatever it reports as its type; a number, decimal, phone or date field takes a character set the preedit would land outside of; a TYPE_NULL editor takes key events rather than text editing. Changing the setting with a field focused moves the live composition in or back out, and the keyboard's pre-edit row appears or gives way to the candidates accordingly. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/PinyinLocationInstrumentedTest.kt | 168 ++++++++++++++++++ .../PinyinLocationServiceInstrumentedTest.kt | 168 ++++++++++++++++++ .../PinyinLocationWiringInstrumentedTest.kt | 28 +++ .../slacker/openime/ImeSettingsActivity.kt | 6 + .../slacker/openime/LocalVoiceImeService.kt | 44 ++++- .../java/llc/slacker/openime/core/ImeState.kt | 2 + .../slacker/openime/core/PinyinLocation.kt | 29 +++ .../openime/data/ImeSettingsRepository.kt | 14 ++ .../openime/editor/ComposingTextPolicy.kt | 41 +++++ .../openime/keyboard/ImeKeyboardView.kt | 25 ++- .../openime/panel/SettingsPanelController.kt | 13 +- .../openime/ComposingTextPolicyTest.kt | 69 +++++++ .../llc/slacker/openime/PinyinLocationTest.kt | 27 +++ docs/DECISIONS.md | 6 + 15 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt create mode 100644 app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt create mode 100644 app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/core/PinyinLocation.kt create mode 100644 app/src/main/java/llc/slacker/openime/editor/ComposingTextPolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/ComposingTextPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/PinyinLocationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b3c083..06aaca89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 设置页的键盘卡片新增「输入拼音显示」:默认仍是拼音留在键盘顶上的候选栏;选「显示在输入框」后,输入中的拼音直接写进应用自己的输入框,键盘上不再重复这一行,候选行照旧。密码框、数字/日期/电话这类字段,以及只吃按键事件的终端与游戏不写——否则拼音会进到密码里,或者落在一个只收数字的框里。参考皮肤里同一个设置(默认 0 = 显示在候选栏)也是这么分的。 - 展开候选页补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。竖屏键区不加参考皮肤给键区的那道侧边留白——它会改动九键与 26 键的键位大小,竖屏布局保持原样。 - 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 - 工具页与切换键盘页顶部的返回键改为参考皮肤自己的圆形键:一层浅色圆底(浅色 80% 白、深色 12% 白),按下换成参考给这一类键的按下色(浅色 6% 黑、深色 4% 白),图标用参考的按键图标色(浅色 85% 黑、深色纯白);原先是一个没有底色的箭头。圆的直径 40dp,触控区仍是 48dp。 diff --git a/app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt new file mode 100644 index 00000000..363ae965 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationInstrumentedTest.kt @@ -0,0 +1,168 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.PinyinLocation +import llc.slacker.openime.keyboard.ImeKeyboardView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 「输入拼音显示」的两个取值在键盘这一侧的表现:候选栏那一档键盘顶上留预编辑 + * 行,输入框那一档不留——拼音交给应用自己的输入框去显示,候选行照旧。 + */ +@RunWith(AndroidJUnit4::class) +class PinyinLocationInstrumentedTest { + private val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.setPinyinLocation(PinyinLocation.CANDIDATE_BAR) + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun tap(harness: DirectActivityHarness, view: View) { + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + harness.awaitMain { + val event = MotionEvent.obtain( + downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, + view.width / 2f, view.height / 2f, 0, + ) + try { view.dispatchTouchEvent(event) } finally { event.recycle() } + true + } + SystemClock.sleep(150) + } + + private fun type(harness: DirectActivityHarness, keyboard: ImeKeyboardView, keys: String) { + keys.forEach { letter -> + val key = harness.awaitMain { + keyboard.findViewWithTag("key:$letter")?.takeIf { it.width > 0 } + } + assertNotNull("The $letter key must be on the surface", key) + tap(harness, key!!) + } + } + + /** (预编辑行是否显示着字, 行里的字)。 */ + private fun probeEditor(keyboard: ImeKeyboardView): Pair { + val editor = keyboard.findViewWithTag("pinyin-composition-editor") + val text = editor?.text?.toString().orEmpty() + val showing = editor != null && editor.visibility == View.VISIBLE && text.isNotEmpty() + return showing to text + } + + private fun awaitEditorText( + harness: DirectActivityHarness, + keyboard: ImeKeyboardView, + expected: String, + ): String? = harness.awaitMain { + probeEditor(keyboard).takeIf { it.first && it.second == expected }?.second + } + + private fun editorStateNow( + harness: DirectActivityHarness, + keyboard: ImeKeyboardView, + ): Pair = harness.awaitMain { probeEditor(keyboard) } + + @Test + fun theEditorRowCarriesThePinyinOnlyInTheCandidateBarMode() { + withKeyboard { harness, keyboard -> + harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } + type(harness, keyboard, "nihao") + assertEquals("候选栏:预编辑行显示拼音", "nihao", awaitEditorText(harness, keyboard, "nihao")) + + harness.awaitMain { keyboard.setPinyinLocation(PinyinLocation.INPUT_BOX); true } + val (showing, _) = editorStateNow(harness, keyboard) + assertFalse("输入框:键盘上不再重复这一行", showing) + val zone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + assertTrue("候选行本身照旧占着位置", zone.height > 0) + + harness.awaitMain { keyboard.setPinyinLocation(PinyinLocation.CANDIDATE_BAR); true } + assertEquals("切回候选栏,拼音回到键盘上", "nihao", awaitEditorText(harness, keyboard, "nihao")) + } + } + + @Test + fun theSettingSurvivesARestartOfTheSurface() { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val first = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + harness.awaitMain { if (first.width > 0) true else null } + harness.awaitMain { + first.setMode(KeyboardMode.PINYIN_26) + first.setPinyinLocation(PinyinLocation.INPUT_BOX) + true + } + harness.awaitMain { + first.shutdown() + (first.parent as? ViewGroup)?.removeView(first) + true + } + + val second = harness.awaitMain { activity -> + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (second.width > 0) true else null } + harness.awaitMain { second.setMode(KeyboardMode.PINYIN_26); true } + type(harness, second, "nihao") + val (showing, _) = editorStateNow(harness, second) + assertFalse("重新建一次键盘,设置还在", showing) + // 打的字没丢:把它切回候选栏就能看见。 + harness.awaitMain { second.setPinyinLocation(PinyinLocation.CANDIDATE_BAR); true } + assertEquals("nihao", awaitEditorText(harness, second, "nihao")) + } finally { + harness.awaitMain { + second.setPinyinLocation(PinyinLocation.CANDIDATE_BAR) + second.shutdown() + (second.parent as? ViewGroup)?.removeView(second) + true + } + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt new file mode 100644 index 00000000..cf5ca7f8 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationServiceInstrumentedTest.kt @@ -0,0 +1,168 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.text.InputType +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.PinyinLocation +import llc.slacker.openime.editor.InputConnectionGateway +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 「输入拼音显示」在服务这一侧:选输入框时拼音进应用自己的输入框,切档时在场的 + * 那段预编辑跟着搬(切回候选栏要把它收回),收不下的输入框一律不写。 + */ +@RunWith(AndroidJUnit4::class) +class PinyinLocationServiceInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun withService( + pinyinLocation: PinyinLocation, + inputType: Int, + passwordField: Boolean = false, + events: MutableList, + test: (LocalVoiceImeService) -> Unit, + ) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val connection = Proxy.newProxyInstance( + InputConnection::class.java.classLoader, + arrayOf(InputConnection::class.java), + ) { _, method, args -> + when (method.name) { + "setComposingText" -> { events += "compose:${args!![0]}"; true } + "finishComposingText" -> { events += "finish"; true } + "commitText" -> { events += "commit:${args!![0]}"; true } + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as InputConnection + + val service = LocalVoiceImeService() + // The service is constructed directly instead of being started by the + // platform, so it has no base context yet. + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + + setField(service, "gateway", InputConnectionGateway(null, { connection })) + setField(service, "lastComposition", "nihao") + setField( + service, + "state", + ImeState( + keyboardMode = KeyboardMode.PINYIN_26, + composition = "nihao", + passwordField = passwordField, + pinyinLocation = pinyinLocation, + editorInfo = EditorInfo().apply { this.inputType = inputType }, + ), + ) + test(service) + } + } + + private fun setField(service: LocalVoiceImeService, name: String, value: Any) { + LocalVoiceImeService::class.java.getDeclaredField(name).apply { + isAccessible = true + set(service, value) + } + } + + /** 打一个字母走的那条路(`updateComposition` 读设置与输入框类型决定写不写)。 */ + private fun typePinyin(service: LocalVoiceImeService, next: String) { + LocalVoiceImeService::class.java + .getDeclaredMethod("updateComposition", String::class.java, List::class.java) + .apply { isAccessible = true } + .invoke(service, next, emptyList()) + } + + @Test + fun typingPinyinEntersTheEditorOnlyInTheInputBoxMode() { + val events = mutableListOf() + withService(PinyinLocation.CANDIDATE_BAR, InputType.TYPE_CLASS_TEXT, events = events) { service -> + typePinyin(service, "nihao") + assertEquals("候选栏一档:拼音不上输入框", emptyList(), events) + } + + val editorEvents = mutableListOf() + withService(PinyinLocation.INPUT_BOX, InputType.TYPE_CLASS_TEXT, events = editorEvents) { service -> + typePinyin(service, "nihao") + assertEquals(listOf("compose:nihao"), editorEvents) + } + } + + @Test + fun fieldsThatCannotHoldPinyinKeepItOnTheKeyboard() { + listOf( + "数字框" to InputType.TYPE_CLASS_NUMBER, + "小数框" to (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL), + "电话框" to InputType.TYPE_CLASS_PHONE, + "日期框" to (InputType.TYPE_CLASS_DATETIME or InputType.TYPE_DATETIME_VARIATION_DATE), + "只吃按键的框" to InputType.TYPE_NULL, + ).forEach { (name, type) -> + val events = mutableListOf() + withService(PinyinLocation.INPUT_BOX, type, events = events) { service -> + typePinyin(service, "nihao") + assertEquals(name, emptyList(), events) + } + } + } + + @Test + fun aPasswordFieldNeverTakesTheComposition() { + val typed = mutableListOf() + withService( + PinyinLocation.INPUT_BOX, + InputType.TYPE_CLASS_TEXT, + passwordField = true, + events = typed, + ) { service -> typePinyin(service, "nihao") } + assertEquals("密码框不收拼音", emptyList(), typed) + + // 密码框有时报纯文本类型,只能靠这个标志挡住。收尾事件(finishComposingText) + // 不带字,允许;带字的那条通道不能走。 + val switched = mutableListOf() + withService( + PinyinLocation.CANDIDATE_BAR, + InputType.TYPE_CLASS_TEXT, + passwordField = true, + events = switched, + ) { service -> service.onPinyinLocationChanged(PinyinLocation.INPUT_BOX) } + assertEquals( + "密码框里切档也不写拼音", + emptyList(), + switched.filter { it.contains("nihao") }, + ) + } + + @Test + fun switchingTheSettingMovesTheCompositionInAndBackOut() { + val events = mutableListOf() + withService(PinyinLocation.CANDIDATE_BAR, InputType.TYPE_CLASS_TEXT, events = events) { service -> + service.onPinyinLocationChanged(PinyinLocation.INPUT_BOX) + assertEquals("在场的拼音搬进输入框", listOf("compose:nihao"), events) + + events.clear() + service.onPinyinLocationChanged(PinyinLocation.CANDIDATE_BAR) + assertEquals("切回候选栏把输入框里那段收回", listOf("compose:", "finish"), events) + } + } + + @Test + fun switchingWithNothingComposingTouchesNothing() { + val events = mutableListOf() + withService(PinyinLocation.CANDIDATE_BAR, InputType.TYPE_CLASS_TEXT, events = events) { service -> + setField(service, "lastComposition", "") + service.onPinyinLocationChanged(PinyinLocation.INPUT_BOX) + assertEquals(emptyList(), events) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt new file mode 100644 index 00000000..7350d7c3 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PinyinLocationWiringInstrumentedTest.kt @@ -0,0 +1,28 @@ +package llc.slacker.openime + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.PinyinLocation +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 应用内设置页改这一项时,在场的输入法要收到通知:显示位置由服务决定写不写输入框, + * 不像双拼方案那样每次同步自读,只有键盘自己知道是不够的。 + */ +@RunWith(AndroidJUnit4::class) +class PinyinLocationWiringInstrumentedTest { + + @Test + fun theSettingsPageAnswersTheChange() { + val method = ImeSettingsActivity::class.java.getDeclaredMethod( + "onPinyinLocationChanged", + PinyinLocation::class.java, + ) + assertEquals( + "设置页要自己接这个回调,而不是用接口里的空实现", + ImeSettingsActivity::class.java, + method.declaringClass, + ) + } +} diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 5d37704c..b01df238 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -10,6 +10,7 @@ import android.widget.FrameLayout import android.window.OnBackInvokedCallback import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.PinyinLocation import llc.slacker.openime.core.ShiftState import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.keyboard.ImeKeyboardView @@ -227,6 +228,11 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { refreshLiveIme() } + /** 拼音显示位置由服务决定写不写输入框,改完要让它读到新值(双拼那种每次同步自读的不用)。 */ + override fun onPinyinLocationChanged(location: PinyinLocation) { + refreshLiveIme() + } + override fun onOpenAbout() { startActivity(Intent(this, AboutActivity::class.java)) } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index b7cbd470..10a1b307 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -37,6 +37,7 @@ import llc.slacker.openime.core.EmojiCatalog import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.PinyinLocation import llc.slacker.openime.core.ShiftState import llc.slacker.openime.core.VoiceUiState import llc.slacker.openime.core.dropLastCodePointSafe @@ -48,6 +49,7 @@ import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.data.RimeUserDictionaryArchive import llc.slacker.openime.data.SymbolRecentRepository import llc.slacker.openime.data.UserPhraseRepository +import llc.slacker.openime.editor.ComposingTextPolicy import llc.slacker.openime.editor.EditorInfoAdapter import llc.slacker.openime.editor.InputConnectionGateway import llc.slacker.openime.editor.InputMethodSubtypePolicy @@ -255,6 +257,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can hapticEnabled = ImeSettingsRepository.loadHaptic(this), popupEnabled = ImeSettingsRepository.loadPopup(this), fuzzyPinyinEnabled = ImeSettingsRepository.loadFuzzy(this), + pinyinLocation = ImeSettingsRepository.loadPinyinLocation(this), ) } @@ -511,6 +514,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can popup = state.popupEnabled, fuzzy = state.fuzzyPinyinEnabled, ) + // 拼音显示位置在设置页也能改;在场的预编辑跟着搬。 + applyPinyinLocationChange(ImeSettingsRepository.loadPinyinLocation(this)) } /** Apply settings changed by standalone Activities on the IME main thread. */ @@ -1397,6 +1402,27 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can state = state.copy(panel = if (open) Panel.CANDIDATE_EXPANDED else Panel.NONE) } + /** + * 输入中的拼音改显示位置了(候选栏 ↔ 输入框)。由键盘上的设置页直接调用; + * 应用内的设置页走 [reloadPersistedSettings] 里同一条路。 + */ + override fun onPinyinLocationChanged(location: PinyinLocation) = applyPinyinLocationChange(location) + + /** + * 在场的这段预编辑要跟着搬,否则输入框里会留下半截拼音,或者键盘上少了一行。 + */ + private fun applyPinyinLocationChange(location: PinyinLocation) { + val previous = state.pinyinLocation + state = state.copy(pinyinLocation = location) + if (previous == location || lastComposition.isEmpty()) return + if (composesInEditor()) { + gateway.setComposingText(lastComposition) + } else { + // 收回输入框里的那段 composing text,改回键盘自己显示。 + gateway.cancelComposing() + } + } + override fun onSymbolSelected(symbol: String) = guarded("onSymbolSelected") { prepareForManualInput() commitPendingComposition() @@ -1550,15 +1576,31 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // editor text in many apps. Deleting the final Pinyin character // must remove that span instead of leaving a raw letter behind. gateway.cancelComposing() - } else if (!state.keyboardMode.isChineseLayout) { + } else if (!state.keyboardMode.isChineseLayout || composesInEditor()) { // Chinese pre-edit (pinyin, nine-key digits, strokes) stays on the // keyboard, as on Sogou and iFlytek: written into the app's field it // showed "669" or "ni'hao" there and set off the app's own // suggestions. An English word is composed in place, as on Gboard. + // + // 选「显示在输入框」时拼音改走同一个通道,和参考皮肤的做法一致 + // (`KeyboardJni.UpdatePreedit` → `setComposingRegion` + + // `setComposingText`,见 PinyinLocation)。 gateway.setComposingText(next) } } + /** + * 拼音是否写进应用自己的输入框。除了用户选的那一项,还要看这个输入框能不能 + * 收:密码框里写预编辑等于把拼音打进密码,数字框(只收数字)写拼音只会被 + * 应用丢掉或过滤。 + */ + private fun composesInEditor(): Boolean = + ComposingTextPolicy.composesInEditor( + location = state.pinyinLocation, + passwordField = state.passwordField, + kind = EditorInfoAdapter.kind(state.editorInfo), + ) + /** * Finish a pending composing string before inserting a non-composing item. * This is what makes punctuation, emoji, symbols and mode changes behave diff --git a/app/src/main/java/llc/slacker/openime/core/ImeState.kt b/app/src/main/java/llc/slacker/openime/core/ImeState.kt index d5f4e0ee..1bfedb5d 100644 --- a/app/src/main/java/llc/slacker/openime/core/ImeState.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeState.kt @@ -62,6 +62,8 @@ data class ImeState( val fuzzyPinyinEnabled: Boolean = false, val editorInfo: EditorInfo? = null, val passwordField: Boolean = false, + // 输入中的拼音显示在候选栏还是应用的输入框,见 PinyinLocation。 + val pinyinLocation: PinyinLocation = PinyinLocation.CANDIDATE_BAR, val symbolCategory: String = "常用", // First real emoji category tab (see ImeData.emojiByCategory order). val emojiCategory: String = "笑脸", diff --git a/app/src/main/java/llc/slacker/openime/core/PinyinLocation.kt b/app/src/main/java/llc/slacker/openime/core/PinyinLocation.kt new file mode 100644 index 00000000..7ae76c90 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/PinyinLocation.kt @@ -0,0 +1,29 @@ +package llc.slacker.openime.core + +/** + * 输入中的拼音显示在哪里:候选栏(默认),还是应用自己的输入框。 + * + * 参考皮肤把这一项写作「输入拼音显示」,两个取值「显示在候选栏」与 + * 「显示在输入框」(`kbd_layout_pinyin_location_title`、 + * `kbd_layout_pinyin_location_in_keyboard`、 + * `kbd_layout_pinyin_location_in_editor`),默认是候选栏 + * (`xml/settings_preferences` 里 `pinyin_location_style` 的 `defaultValue=0`, + * APK 内路径 `res/cU.xml`)。选了输入框时,参考把拼音作为 composing region 加 + * composing text 写进输入框(`KeyboardJni.cacheCompStrAndPreedit` → + * `UpdatePreedit` → `setComposingRegion`/`setComposingText`),并且只在没有命中 + * 它的拦截名单应用时这么做(`KeyboardJni.isSupportPinyinInEditor`)。我们这边 + * 对应的是 [INPUT_BOX]:拼音交给应用的输入框显示,键盘上那行预编辑不再重复显示。 + */ +enum class PinyinLocation(val key: String, val label: String) { + /** 拼音留在键盘自己的预编辑行里,输入框保持原样。 */ + CANDIDATE_BAR("candidate_bar", "显示在候选栏"), + + /** 拼音写进应用的输入框(composing text),键盘上不再重复显示。 */ + INPUT_BOX("input_box", "显示在输入框"), + ; + + companion object { + fun fromKey(key: String?): PinyinLocation = + entries.firstOrNull { it.key == key } ?: CANDIDATE_BAR + } +} 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 53321d45..822f03fe 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -7,6 +7,7 @@ import llc.slacker.openime.core.DoublePinyinScheme import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.core.OneHandMode +import llc.slacker.openime.core.PinyinLocation import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme @@ -40,6 +41,7 @@ object ImeSettingsRepository { 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" + private const val KEY_PINYIN_LOCATION = "pinyin_location" /** * The user's preferred Chinese layout (26-key vs 9-key). Only PINYIN_26 and @@ -72,6 +74,18 @@ object ImeSettingsRepository { .edit().putString(KEY_ONE_HAND_MODE, mode.key).apply() } + /** 输入中的拼音显示在候选栏还是输入框;默认候选栏,与参考皮肤一致。 */ + fun loadPinyinLocation(context: Context): PinyinLocation = + PinyinLocation.fromKey( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_PINYIN_LOCATION, null), + ) + + fun savePinyinLocation(context: Context, location: PinyinLocation) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_PINYIN_LOCATION, location.key).apply() + } + /** 双拼键位方案(小鹤 / 自然码),默认小鹤。 */ fun loadDoublePinyinScheme(context: Context): DoublePinyinScheme = DoublePinyinScheme.fromKey( diff --git a/app/src/main/java/llc/slacker/openime/editor/ComposingTextPolicy.kt b/app/src/main/java/llc/slacker/openime/editor/ComposingTextPolicy.kt new file mode 100644 index 00000000..fd3c9380 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/editor/ComposingTextPolicy.kt @@ -0,0 +1,41 @@ +package llc.slacker.openime.editor + +import llc.slacker.openime.core.PinyinLocation + +/** + * 输入中的拼音写不写进应用自己的输入框(composing text),还是留在键盘的预编辑 + * 行里。两件事都要成立:用户选了「显示在输入框」(见 [PinyinLocation]),而且这 + * 个输入框收得下。 + */ +object ComposingTextPolicy { + + /** + * 密码框收下预编辑就等于把拼音打进密码里,数字、日期这类字段只收自己的字符 + * 集,终端和游戏(TYPE_NULL)只吃按键事件,都不写。框的类型读不出来时按普通 + * 文本框处理,写进去——和参考皮肤默认「显示在输入框」的取向一致。 + */ + fun composesInEditor( + location: PinyinLocation, + passwordField: Boolean, + kind: EditorInfoAdapter.EditorKind, + ): Boolean { + if (location != PinyinLocation.INPUT_BOX) return false + if (passwordField) return false + return when (kind) { + EditorInfoAdapter.EditorKind.TEXT, + EditorInfoAdapter.EditorKind.MULTILINE, + EditorInfoAdapter.EditorKind.EMAIL, + EditorInfoAdapter.EditorKind.URL, + EditorInfoAdapter.EditorKind.UNKNOWN, + -> true + + EditorInfoAdapter.EditorKind.PASSWORD, + EditorInfoAdapter.EditorKind.NUMBER, + EditorInfoAdapter.EditorKind.DECIMAL, + EditorInfoAdapter.EditorKind.PHONE, + EditorInfoAdapter.EditorKind.DATETIME, + EditorInfoAdapter.EditorKind.RAW_KEYS, + -> false + } + } +} 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 d2f216e0..4d8849b8 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.core.Panel import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.core.DoublePinyinScheme import llc.slacker.openime.core.OneHandMode +import llc.slacker.openime.core.PinyinLocation import llc.slacker.openime.core.ShiftState import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.ImeSettingsRepository @@ -142,6 +143,9 @@ open class ImeKeyboardView( /** 震动手感 or 按键音效 changed; the new value is already saved. */ fun onFeedbackStyleChanged() {} + + /** 输入拼音显示位置改了(候选栏 ↔ 输入框);新值已经存好。 */ + fun onPinyinLocationChanged(location: PinyinLocation) {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} fun onLandscapeLayoutChanged(layout: LandscapeLayout) {} fun onOpenAbout() {} @@ -719,6 +723,9 @@ open class ImeKeyboardView( private fun panelBodyHeightDp(): Int = layoutMetrics.panelBodyHeightDp private var syncingComposition = false private var passwordField = false + + /** 输入中的拼音显示在候选栏还是应用的输入框;输入框时键盘不再重复显示。 */ + private var pinyinLocation = ImeSettingsRepository.loadPinyinLocation(context) private var inlineEditTarget: EditText? = null private lateinit var mainDock: LinearLayout @@ -848,6 +855,7 @@ open class ImeKeyboardView( currentLandscapeLayout = { ImeSettingsRepository.loadLandscapeLayout(context) }, currentOneHandMode = { oneHandMode }, currentDoublePinyinScheme = { ImeSettingsRepository.loadDoublePinyinScheme(context) }, + currentPinyinLocation = { pinyinLocation }, currentKeySoundStyle = { keySounds.style }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, @@ -875,6 +883,7 @@ open class ImeKeyboardView( onLandscapeLayoutChanged = ::setLandscapeLayout, onOneHandModeChanged = ::setOneHandMode, onDoublePinyinSchemeChanged = ::setDoublePinyinScheme, + onPinyinLocationChanged = ::setPinyinLocation, onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, @@ -1748,6 +1757,7 @@ open class ImeKeyboardView( fun renderState(state: ImeState) { val passwordStateChanged = passwordField != state.passwordField passwordField = state.passwordField + pinyinLocation = state.pinyinLocation if (passwordStateChanged) { // A recording started in the previous editor must not end in this one. if (voiceGestureSession || voicePanelController.active || voicePanelController.pending) { @@ -1993,6 +2003,16 @@ open class ImeKeyboardView( if (!floatingKeyboardController.enabled && width > 0) updateResponsiveGeometry(width) } + /** 输入拼音显示位置(候选栏 / 输入框);键盘上那行预编辑随之出现或收起。 */ + internal fun setPinyinLocation(location: PinyinLocation) { + if (pinyinLocation == location) return + pinyinLocation = location + ImeSettingsRepository.savePinyinLocation(context, location) + updateTopZone(composition.text?.isNotEmpty() == true) + // 服务端要跟着改:写进输入框还是留在键盘上由它决定。 + listener.onPinyinLocationChanged(location) + } + /** 双拼键位方案;Rime 每次同步都读取设置,切换后重查当前输入即可生效。 */ internal fun setDoublePinyinScheme(scheme: DoublePinyinScheme) { ImeSettingsRepository.saveDoublePinyinScheme(context, scheme) @@ -2280,7 +2300,10 @@ open class ImeKeyboardView( topZone.renderState( state = state, showCompositionEditor = (composing || candidateBarController.expandedOpen) && - mode != KeyboardMode.ENGLISH_26, + mode != KeyboardMode.ENGLISH_26 && + // 拼音写进输入框时,键盘上不再重复这一行(参考皮肤把两种显示 + // 位置做成二选一,见 PinyinLocation)。 + pinyinLocation != PinyinLocation.INPUT_BOX, ) } 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 5eb78093..0443f3a9 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -23,6 +23,7 @@ import llc.slacker.openime.R import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.core.DoublePinyinScheme import llc.slacker.openime.core.OneHandMode +import llc.slacker.openime.core.PinyinLocation import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.theme.ImeAppearance @@ -64,6 +65,7 @@ internal class SettingsPanelController( private val currentLandscapeLayout: () -> LandscapeLayout, private val currentOneHandMode: () -> OneHandMode, private val currentDoublePinyinScheme: () -> DoublePinyinScheme, + private val currentPinyinLocation: () -> PinyinLocation, private val currentKeySoundStyle: () -> KeySoundStyle, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, @@ -83,6 +85,7 @@ internal class SettingsPanelController( private val onLandscapeLayoutChanged: (LandscapeLayout) -> Unit, private val onOneHandModeChanged: (OneHandMode) -> Unit, private val onDoublePinyinSchemeChanged: (DoublePinyinScheme) -> Unit, + private val onPinyinLocationChanged: (PinyinLocation) -> Unit, private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, @@ -186,7 +189,10 @@ internal class SettingsPanelController( sliderRow(currentKeyboardHeightLabel(), R.drawable.ic_pref_height, ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT, currentKeyboardHeightPercent(), ImeGeometryTokens.KEYBOARD_HEIGHT_DEFAULT_PERCENT, onChange = onKeyboardHeightChanged), ) content.addSection("键盘") - content.addCard(doublePinyinSchemeRow(R.drawable.ic_kb_double)) + content.addCard( + doublePinyinSchemeRow(R.drawable.ic_kb_double), + pinyinLocationRow(R.drawable.ic_pref_cursor), + ) content.addSection("横屏") content.addCard( landscapeLayoutRow(R.drawable.ic_pref_width), @@ -595,6 +601,11 @@ internal class SettingsPanelController( onDoublePinyinSchemeChanged(DoublePinyinScheme.entries.first { it.label == label }) } + private fun pinyinLocationRow(iconRes: Int): View = + choiceRow("输入拼音显示", iconRes, PinyinLocation.entries.map { it.label }, currentPinyinLocation().label) { label -> + onPinyinLocationChanged(PinyinLocation.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/test/java/llc/slacker/openime/ComposingTextPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ComposingTextPolicyTest.kt new file mode 100644 index 00000000..295f375f --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ComposingTextPolicyTest.kt @@ -0,0 +1,69 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.PinyinLocation +import llc.slacker.openime.editor.ComposingTextPolicy +import llc.slacker.openime.editor.EditorInfoAdapter.EditorKind +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComposingTextPolicyTest { + + @Test + fun theCandidateBarSettingNeverWritesIntoTheEditor() { + EditorKind.entries.forEach { kind -> + assertFalse( + "$kind", + ComposingTextPolicy.composesInEditor(PinyinLocation.CANDIDATE_BAR, false, kind), + ) + } + } + + @Test + fun textFieldsTakeTheComposition() { + listOf( + EditorKind.TEXT, + EditorKind.MULTILINE, + EditorKind.EMAIL, + EditorKind.URL, + EditorKind.UNKNOWN, + ).forEach { kind -> + assertTrue( + "$kind", + ComposingTextPolicy.composesInEditor(PinyinLocation.INPUT_BOX, false, kind), + ) + } + } + + @Test + fun fieldsThatCannotHoldPinyinKeepItOnTheKeyboard() { + listOf( + EditorKind.PASSWORD, + EditorKind.NUMBER, + EditorKind.DECIMAL, + EditorKind.PHONE, + EditorKind.DATETIME, + EditorKind.RAW_KEYS, + ).forEach { kind -> + assertFalse( + "$kind", + ComposingTextPolicy.composesInEditor(PinyinLocation.INPUT_BOX, false, kind), + ) + } + } + + /** 密码框有时报 TEXT,只能靠 passwordField 这个标志挡住。 */ + @Test + fun aPasswordFlagBlocksEvenATextField() { + assertFalse( + ComposingTextPolicy.composesInEditor(PinyinLocation.INPUT_BOX, true, EditorKind.TEXT), + ) + assertFalse( + ComposingTextPolicy.composesInEditor( + PinyinLocation.INPUT_BOX, + true, + EditorKind.MULTILINE, + ), + ) + } +} diff --git a/app/src/test/java/llc/slacker/openime/PinyinLocationTest.kt b/app/src/test/java/llc/slacker/openime/PinyinLocationTest.kt new file mode 100644 index 00000000..3b1193c8 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PinyinLocationTest.kt @@ -0,0 +1,27 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.PinyinLocation +import org.junit.Assert.assertEquals +import org.junit.Test + +class PinyinLocationTest { + @Test + fun missingOrUnknownValuesFallBackToTheCandidateBar() { + listOf(null, "", "inputbox", "候选栏", "INPUT_BOX ").forEach { stored -> + assertEquals(PinyinLocation.CANDIDATE_BAR, PinyinLocation.fromKey(stored)) + } + } + + @Test + fun bothStoredValuesRestore() { + assertEquals(PinyinLocation.CANDIDATE_BAR, PinyinLocation.fromKey("candidate_bar")) + assertEquals(PinyinLocation.INPUT_BOX, PinyinLocation.fromKey("input_box")) + } + + /** 两个标签就是参考皮肤设置项里的那两句。 */ + @Test + fun labelsKeepTheReferenceCopy() { + assertEquals("显示在候选栏", PinyinLocation.CANDIDATE_BAR.label) + assertEquals("显示在输入框", PinyinLocation.INPUT_BOX.label) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 55025ef2..08cf476a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -283,3 +283,9 @@ Add new entries at the end. - **The accent key's label keeps our contrast rule, not the reference's white.** `cls_button_enter` names `action_button_text` for the label of every action key — white on all three palettes, since only the light `values/colors.xml:36` defines it and the dark and translucent files do not override it — over `theme_color` rgb(79,132,255) (`style.xml:809-816`, `values/colors.xml:3`), a pairing of 3.46:1. Our action label is `contrastText(primary)` (`ImeThemeApplier`, `ImeDrawableFactory.contrastText` → `ImeContrastPolicy.contrastText`), which takes the darker of the two: 5.67:1 against our light `#1D9BF0` and 8.76:1 against our dark `#6EC3F7`, where white would give 3.00:1 and 1.94:1. *Reason:* the second entry of this log sets a 4.5:1 floor for text and our palette's dark skin is a deliberately lighter blue than the light skin's, so the reference's white label would sit at 1.94:1 there. The key face, its pressed face and the pressed label are the reference's own; only the resting label follows the contrast rule. +- **The pinyin display options are the reference's own two, and the default is unchanged.** + Doubao ships 「输入拼音显示」 as an `IntPreference` on `xml/settings_preferences` with `defaultValue=0` (`settings_preferences.xmltree.txt:366-369`, the key resource `string/pinyin_location_style`), whose two labels are `kbd_layout_pinyin_location_in_keyboard` 「显示在候选栏」 and `kbd_layout_pinyin_location_in_editor` 「显示在输入框」. In `UpdateCompStr` the preedit reaches the app's editor only when that value is 1 and `isSupportPinyinInEditor()` holds (`KeyboardJni.java:3678-3691`); `cacheCompStrAndPreedit` then calls `UpdatePreedit(str + str2)` (`:3902-3906`), which paints the pinyin through `setComposingRegion(preEditStartPosition, cursor)` and `setComposingText(str, 1)` (`:3765`, `:3772`). `isSupportPinyinInEditor()` is false while its translate view is up, while a common-phrase editor is focused, and for apps on its interceptor list (`:5017-5044`). Our port gives the same two choices in the settings page's keyboard card, keeps the field empty of pinyin on the default, and writes it with `setComposingText` in `LocalVoiceImeService` when the choice is 「显示在输入框」 and `ComposingTextPolicy` allows the field to take it. The decompiled evidence is archived in the teardown tree at `doubaoime-teardown/jadx-ime-editor/` with the commands that produce it. + *Reason:* our boards put the pinyin on the keyboard only, where the reference treats the choice as the user's. The default matches because the reference's own default is the candidate bar, so nothing moves for anyone who does not open the setting. The fields that cannot hold pinyin stay on the keyboard: a password field would take the roman letters into the secret, and a number, date or phone field takes a restricted character set the preedit would land outside of; a `TYPE_NULL` editor (terminals, games) takes key events rather than text editing, so there is nothing to compose into. Where the reference paints an explicit `setComposingRegion` before its text, our `InputConnectionGateway.setComposingText` sends the text alone (`editor/InputConnectionGateway.kt:137-147`, the app underlines the region `setComposingText` implies) and leaves the region to the app, so a field whose type we could not read is never told about a span it did not ask for. +- **A search field keeps the user's choice rather than the reference's exception.** + The reference composes into the editor on a search field whatever `pinyin_location_style` says, through the second branch of the same gate (`KeyboardJni.java:3687-3688`, `EnterActionType.kIME_ACTION_SEARCH`). + *Reason:* that branch serves the reference's own search entry, not a rule about search fields, and it would make the setting answer differently in one kind of field with nothing on screen to explain it. Recorded so a later pass reads the missing branch as a decision rather than an oversight. From 4f994f671f4770d0842a2eefb766fe64c7482e45 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:36:50 +0800 Subject: [PATCH 55/94] Record the reference's candidate size scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doubao's 候选字大小 is a seven-stop 13/15/17/19/21/23/25 sp scale whose stored value is the size itself, defaulting to the middle stop, with the skin carrying the matching gears as fonts (cand_font13/15/17/21/23/25, style.xml:83-111). No 19 gear exists, so the default draws the base cand_font, 56 units, which is the 18sp our candidate row already paints: the default needs no correction, and the scale itself is a new control rather than an alignment, so it is recorded with its figures and left for the owner to call. Co-Authored-By: Claude Code --- docs/DECISIONS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 08cf476a..660f8adf 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -289,3 +289,6 @@ Add new entries at the end. - **A search field keeps the user's choice rather than the reference's exception.** The reference composes into the editor on a search field whatever `pinyin_location_style` says, through the second branch of the same gate (`KeyboardJni.java:3687-3688`, `EnterActionType.kIME_ACTION_SEARCH`). *Reason:* that branch serves the reference's own search entry, not a rule about search fields, and it would make the setting answer differently in one kind of field with nothing on screen to explain it. Recorded so a later pass reads the missing branch as a decision rather than an oversight. +- **The candidate size control stays unported, with the reference's own figures recorded.** + Doubao sells 候选字大小 as a seven-stop scale of 13 / 15 / 17 / 19 / 21 / 23 / 25 sp — the stored value *is* the size, compared against those seven constants in its settings view (`KeyboardLayoutCandidateFontView.smali`, `a()`), `defaultValue=19` (`settings_preferences.xmltree.txt`, key `string/candidate_font_size`) with the slider resting on the middle stop (`setDefaultPosition(3)`), and a live preview of a candidate (豆包输入法) with its pinyin above it at that size (`res/xG.xml`, `layout/layout_settings_candidate_font_change`). The skin carries the same scale as fonts one gear short of it — `cand_font13/15/17/21/23/25` at 39/45/51/63/69/75 units, with matching first-candidate and pinyin-line faces (`skin-full/assets/skin/default/style.xml:83-111`) — so the 19 of the default draws the base `cand_font`, 56 units, which is the 18sp our `ImeTypographyTokens.CANDIDATE_SP` already paints. The teardown keeps the bytecode and the commands that produce it at `doubaoime-teardown/jadx-cand-font-size/`. + *Reason:* the default needs no correction — our candidate row already wears the face the reference's default gear resolves to — and the scale itself is a new user-facing control rather than an alignment of one we already draw, so it waits for the owner's word rather than arriving on its own. Recorded here so the figures are not re-derived a third time. From 27c9d6340bf507959634f01e5bcbed8e835ba646 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:13:20 +0800 Subject: [PATCH 56/94] Keep the best syllable paths, not the first ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reading can split many ways, and the catalogue's own letter entries make the useless splits plentiful: `luanan` is luan+an, but lu+a+nan arrives first and filled the node's four path slots, so the word lookup never saw the reading. 36 of 4007 random readings came back with no candidate at all while the lexicon holds one (乱按/卵鞍, 格雷米奥), and the space bar then committed the raw Latin letters. The reference enumerates every path and ranks them only afterwards (a 0.99 decay per syllable edge), so a cap must keep the best paths rather than the first ones: the node lists now stay ordered by syllable count and the head cap rises from 4 to 16. Against the reference lexicon's own order, the same 4007 readings go from 3969 to 4006 first-candidate matches and from 36 empty-with-entries to 0. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/engine/PinyinEngine.kt | 2 +- .../slacker/openime/engine/PinyinSegmenter.kt | 19 ++++- .../slacker/openime/PinyinEngineDataTest.kt | 9 ++ .../openime/PinyinEngineRankCompareTest.kt | 84 +++++++++++++++++++ .../slacker/openime/PinyinSegmenterTest.kt | 61 ++++++++++++++ docs/DECISIONS.md | 3 + 7 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/llc/slacker/openime/PinyinEngineRankCompareTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 06aaca89..55ff4439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 修掉拼音引擎里一类「词库明明有词、候选栏却一个都不出」的问题:一个读音可以切成多种音节(`luanan` 既是 luan+an,也是 lu+a+nan),切分时每个位置保留的候选路径原来按先来后到截前 4 条,切成更多音节、实际不会命中的切法能先把位置占满,真正的那条挤不进来,于是候选栏空白、空格把原拼音原样送进输入框。现在每个位置按音节数从少到多保留最好的路径,保留条数从 4 提到 16——参考实现本来就是枚举全部路径、按每条音节 ×0.99 递减打分后再排序,路径条数不设上限。随机取 4007 个读音对照参考词典自己的排序:修复前 36 个读音有词却无候选(如 `luanan` 的「乱按」「卵鞍」、`geleimiao` 的「格雷米奥」),修复后 0 个,首选词与参考词典一致 3969 → 4006/4006。 - 设置页的键盘卡片新增「输入拼音显示」:默认仍是拼音留在键盘顶上的候选栏;选「显示在输入框」后,输入中的拼音直接写进应用自己的输入框,键盘上不再重复这一行,候选行照旧。密码框、数字/日期/电话这类字段,以及只吃按键事件的终端与游戏不写——否则拼音会进到密码里,或者落在一个只收数字的框里。参考皮肤里同一个设置(默认 0 = 显示在候选栏)也是这么分的。 - 展开候选页补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。竖屏键区不加参考皮肤给键区的那道侧边留白——它会改动九键与 26 键的键位大小,竖屏布局保持原样。 - 展开候选页改为参考皮肤自己给这一页的颜色:列表整张底是 40% 白(深色 6% 白)配 2 单位边框(浅色 6% 黑、深色 10% 白)、圆角 11,候选格之间的细线用同一条边框色;「全部/单字」这一组的胶囊底改为浅色 4% 黑、深色 6% 白,选中那格浅色仍是不透明白、深色改为压暗(24% 黑),未选文字改为参考的 40% 键文字色;列表右下角的退格键改用参考给这个键自己的实色(浅色 rgb(233,233,233)、深色 rgb(59,59,59),按下 rgb(193,193,193)、rgb(42,42,42))。原来整张列表是纯白配通用分隔线,分组胶囊用的是候选栏底色,在页面上看不出来,退格键是灰色功能键底。 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt index 8e1992b1..a097c950 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt @@ -268,7 +268,7 @@ class PinyinEngine internal constructor( private const val CANDIDATE_LIMIT = 60 private const val SENTENCE_LIMIT = 3 private const val MAX_INPUT = 64 - private const val HEAD_PATHS = 4 + private const val HEAD_PATHS = 16 private const val TAIL_SYLLABLES = 32 private const val PATH_LIMIT = 32 private const val MAX_WORD_SYLLABLES = 6 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index 4e32d2ba..01fc9ed9 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -103,12 +103,11 @@ internal class PinyinSegmenter( val flags = edgeFuzzy.getValue(spelling) val next = frontier[position + edge] ?: continue for (index in targets.indices) { - if (next.size >= maxPaths) break val ids = path.ids.copyOf(path.ids.size + 1) ids[ids.size - 1] = targets[index] val starts = path.starts.copyOf(path.starts.size + 1) starts[starts.size - 1] = position + edge - next.add(SegmentPath(ids, starts, path.fuzzy || flags[index])) + addPath(next, SegmentPath(ids, starts, path.fuzzy || flags[index]), maxPaths) } } } @@ -116,6 +115,22 @@ internal class PinyinSegmenter( return frontier[length] ?: emptyList() } + /** + * Keep the best [maxPaths] paths of one lattice node. + * + * The reference enumerates every path of the lattice and only ranks them + * afterwards (a 0.99 decay per syllable edge), so a path that arrives once + * the cap is full must evict a worse one rather than be dropped: fewer + * syllables is always the better reading (`luanan` is luan+an, not lu+a+nan). + */ + private fun addPath(into: ArrayList, path: SegmentPath, maxPaths: Int) { + var at = 0 + while (at < into.size && into[at].ids.size <= path.ids.size) at++ + if (at >= maxPaths) return + if (into.size == maxPaths) into.removeAt(into.size - 1) + into.add(at, path) + } + /** Syllable edges whose spelling starts with the trailing fragment [fragment]. */ fun syllablesStartingWith(fragment: String, max: Int = 32): List { if (fragment.isEmpty()) return emptyList() diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index 8a3ffda8..c0870f90 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -70,6 +70,15 @@ class PinyinEngineDataTest { assertTrue("candidates top3 below floor: $top3/${cases.size}", top3 * 100 >= cases.size * 95) } + @Test + fun aReadingSplitManyWaysStillFindsItsWord() { + val engine = loadEngine() + // 乱按/卵鞍 are luan+an; the letters also read as lu+a+nan, and the + // split into fewer syllables must be the one that reaches the lookup. + assertEquals("卵鞍", engine.candidates("luanan").firstOrNull()) + assertTrue(engine.candidates("luanan").contains("乱按")) + } + @Test fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { val engine = loadEngine() diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineRankCompareTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineRankCompareTest.kt new file mode 100644 index 00000000..f1d44471 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineRankCompareTest.kt @@ -0,0 +1,84 @@ +package llc.slacker.openime + +import llc.slacker.openime.engine.BigramIndex +import llc.slacker.openime.engine.LexiconIndex +import llc.slacker.openime.engine.PinyinEngine +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.nio.channels.FileChannel + +/** + * Dumps the engine's candidate order for a list of readings so it can be + * compared with the reference lexicon's own order (the volume run's readings). + * + * The comparison inputs are local, not part of the repository: + * `build/imengine-compare/readings.tsv` (reading, expected word). Without it the + * test skips. Run scripts/build_imengine_data.py first for the tables. + */ +class PinyinEngineRankCompareTest { + + private fun engineAsset(name: String): File? = sequenceOf( + File("src/main/assets/imengine/$name"), + File("app/src/main/assets/imengine/$name"), + ).firstOrNull { it.isFile } + + private fun map(file: File) = + java.io.RandomAccessFile(file, "r").use { raf -> + raf.channel.map(FileChannel.MapMode.READ_ONLY, 0, raf.length()) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + } + + @Test + fun dumpCandidateOrderForTheVolumeReadings() { + val lex = engineAsset("lex.bin") + assumeTrue("imengine/lex.bin not built", lex != null) + val name = System.getenv("OPENIME_COMPARE_READINGS") ?: "readings.tsv" + val readings = sequenceOf( + File("../build/imengine-compare/$name"), + File("build/imengine-compare/$name"), + ).firstOrNull { it.isFile } + assumeTrue("build/imengine-compare/$name absent", readings != null) + + val bigram = engineAsset("bigram.bin")?.let { BigramIndex(map(it)) } + val engine = PinyinEngine(LexiconIndex(map(lex!!)), bigram) + val out = File(readings!!.parentFile, "ours.tsv") + val reference = File(readings.parentFile, "doubao.tsv") + // The reference's own order for the same readings, when it was dumped. + val referenceOrder = if (reference.isFile) { + reference.readLines().filter { it.isNotBlank() }.associate { line -> + val (reading, _, top) = line.split("\t") + reading to top.split("|").filter { it.isNotEmpty() } + } + } else { + emptyMap() + } + var n = 0 + var agreed = 0 + val emptyWithReference = ArrayList() + out.bufferedWriter().use { writer -> + readings.readLines().filter { it.isNotBlank() }.forEach { line -> + val (reading, word) = line.split("\t") + val top = engine.candidates(reading, limit = 6) + writer.write("$reading\t$word\t${top.joinToString("|")}\n") + referenceOrder[reading]?.takeIf { it.isNotEmpty() }?.let { expected -> + if (top.isEmpty()) { + emptyWithReference += reading + } else if (top.first() == expected.first()) { + agreed++ + } + } + n++ + } + } + println("rank compare: wrote $n readings to ${out.absolutePath}") + if (referenceOrder.isNotEmpty()) { + println("rank compare: first candidate matches the reference for $agreed readings") + assertTrue( + "readings with a word but no candidate: $emptyWithReference", + emptyWithReference.isEmpty(), + ) + } + } +} diff --git a/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt b/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt new file mode 100644 index 00000000..83583ac9 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt @@ -0,0 +1,61 @@ +package llc.slacker.openime + +import llc.slacker.openime.engine.PinyinSegmenter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The lattice keeps its best paths, not its first ones. + * + * The syllable catalogue holds the single letters as syllables (they are the + * letter entries of the compiled table), so a reading like `luanan` also has + * the splits `lu+a+nan` and `l+u+a+nan`. With a cap that simply dropped later + * paths, the real reading `luan+an` never reached the word lookup and the + * keyboard showed no candidates at all for a reading the lexicon has. + */ +class PinyinSegmenterTest { + + /** The letters double as syllables, exactly like the compiled catalogue. */ + private val syllables = listOf( + "a", "an", "ao", "e", "ei", "g", "ge", "l", "le", "lei", "li", "lu", "luan", "m", "mi", + "miao", "n", "nan", "u", "zai", "bao", + ) + + private fun paths(reading: String, maxPaths: Int): List { + val segmenter = PinyinSegmenter(syllables) + return segmenter.segment(reading, maxPaths = maxPaths).map { path -> + path.ids.joinToString("+") { syllables[it] } + } + } + + @Test + fun theFewestSyllableReadingSurvivesASmallPathCap() { + val found = paths("luanan", maxPaths = 4) + assertEquals("luan+an", found.first()) + assertTrue("luan+an missing from $found", "luan+an" in found) + } + + @Test + fun pathsOfOneNodeComeBackFewestSyllablesFirst() { + val found = paths("luanan", maxPaths = 8) + val counts = found.map { it.count { char -> char == '+' } + 1 } + assertEquals("not sorted by syllable count: $found", counts.sorted(), counts) + } + + @Test + fun aReadingSplitIntoMoreSyllablesDoesNotCrowdOutTheWord() { + // 4 syllables against the 5+ syllable splits the letter entries allow. + assertTrue("ge+lei+mi+ao" in paths("geleimiao", maxPaths = 16)) + } + + @Test + fun everyPathConsumesTheWholeInput() { + for (reading in listOf("luanan", "geleimiao", "baozai")) { + for (path in paths(reading, maxPaths = 16)) { + val consumed = path.split("+").sumOf { it.length } + assertEquals("$path does not cover $reading", reading.length, consumed) + } + } + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 660f8adf..7cb4ad89 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -292,3 +292,6 @@ Add new entries at the end. - **The candidate size control stays unported, with the reference's own figures recorded.** Doubao sells 候选字大小 as a seven-stop scale of 13 / 15 / 17 / 19 / 21 / 23 / 25 sp — the stored value *is* the size, compared against those seven constants in its settings view (`KeyboardLayoutCandidateFontView.smali`, `a()`), `defaultValue=19` (`settings_preferences.xmltree.txt`, key `string/candidate_font_size`) with the slider resting on the middle stop (`setDefaultPosition(3)`), and a live preview of a candidate (豆包输入法) with its pinyin above it at that size (`res/xG.xml`, `layout/layout_settings_candidate_font_change`). The skin carries the same scale as fonts one gear short of it — `cand_font13/15/17/21/23/25` at 39/45/51/63/69/75 units, with matching first-candidate and pinyin-line faces (`skin-full/assets/skin/default/style.xml:83-111`) — so the 19 of the default draws the base `cand_font`, 56 units, which is the 18sp our `ImeTypographyTokens.CANDIDATE_SP` already paints. The teardown keeps the bytecode and the commands that produce it at `doubaoime-teardown/jadx-cand-font-size/`. *Reason:* the default needs no correction — our candidate row already wears the face the reference's default gear resolves to — and the scale itself is a new user-facing control rather than an alignment of one we already draw, so it waits for the owner's word rather than arriving on its own. Recorded here so the figures are not re-derived a third time. +- **The syllable lattice keeps its best paths, not its first ones.** + The reference builds every path of the lattice and decides between them only when it ranks candidates: each position emits every matching syllable edge of the syllable trie (`SyllableLattice::CreateLattice` objdump 0x5b7ab4 → `BuildLattice` 0x5b7d1c, trie walk 0x3eb924, `AddEdge` 0x5ba8a4), every edge scores 0.99 of its predecessor (0x3f7d70a4, used for ranking only, never to drop an edge), and its limits hold no path or syllable count — 1..64 input bytes and 6 bytes per syllable edge are the only ones found (`doubaoime-teardown/ENGINE-SPEC.md` §1.3, §1.4). This port's `PinyinSegmenter.segment` kept the *first* `maxPaths` paths of each lattice node instead, and the compiled catalogue carries the single letters as syllables, so `luanan` filled that node with `lu+a+nan` before `luan+an` arrived and the word lookup never saw the reading: 36 of 4007 random readings came back with no candidate although the lexicon holds one (`乱按` / `卵鞍` at `luan+an`, `格雷米奥` at `ge+lei+mi+ao`), and the space bar committed the raw Latin letters. `addPath` now keeps the best `maxPaths` paths per node, ordered by syllable count, and the head cap rose from 4 to 16. + *Reason:* the reference never loses a reading to a path it did not look at — a split into fewer syllables scores higher on its own 0.99-per-edge decay, which is exactly the order our node lists now hold — while this port's cap is its own performance guard and must not change which readings exist. On the same 4007 readings the empty-with-entries count goes from 36 to 0 and the first candidate agrees with the reference lexicon on all 4006 readings that have an entry (`PinyinEngineRankCompareTest` dumps the comparison and asserts it). The wider cap costs 121µs per query against 60µs, against the 16ms of one frame, and the lookup budget still bounds the word work itself. From 441d48795068c2248f1fd918ac6a71b21d0900ba Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:35:26 +0800 Subject: [PATCH 57/94] Reach the word table for the reading the user typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two readings the compiled lexicon holds never reached the candidate bar. A reading can be split many ways and the catalogue carries the single letters as syllables, so `adelianandeliefu` reads both as a+de+li+an+an+de+lie+fu (阿德里安安德烈夫) and as the one-syllable-shorter a+de+lia+nan+de+lie+fu. Fewer syllables sorted first, the duplicated `a` entry doubled the field, and the frontier of the last node filled up with splits that spell nothing before the real one arrived: the bar was empty for a word the table has. The lattice now takes the caller's test for "a word starts with this reading" and drops a path before it takes a slot — a path no word continues can produce no candidate, so it must not displace one that can. `LexiconIndex.prefixEnd` searched the table for the first word that does not start with the reading, which assumes those words form a prefix of the array. They form one run in the middle of a table sorted by reading, so the search walked away from it and answered 0 for every reading: no word that continues a typed reading was ever offered, and the bar leaned on the Rime supplement behind it. The bound is now the reading's successor. With the lookup alive, two ordering faults it had hidden are fixed too: a word that merely starts with the reading is a completion and ranks behind the words the reading spells, and the reading in full is looked up before the fragment passes so they cannot spend the budget first. The 50,000-reading comparison against the reference lexicon's own order agrees on every first candidate; the new tests pin the prefix range against a plain scan, the completion order and both readings. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/engine/LexiconIndex.kt | 34 +++++++--- .../slacker/openime/engine/PinyinEngine.kt | 63 +++++++++++++------ .../slacker/openime/engine/PinyinSegmenter.kt | 7 +++ .../slacker/openime/PinyinEngineDataTest.kt | 62 ++++++++++++++++++ .../slacker/openime/PinyinSegmenterTest.kt | 14 +++++ docs/DECISIONS.md | 6 ++ 7 files changed, 159 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ff4439..a563769b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 修掉拼音候选栏另外两类「词库里有词、却不出候选」的问题。一类是长读音切分被挤掉:`adelianandeliefu` 应切成 a+de+li+an+an+de+lie+fu(阿德里安安德烈夫),但同样这串字母还能切成音节更少的 a+de+lia+nan+de+lie+fu,单个字母本身也是合法音节(词表里就有字母词条),音节少的那条排在前面,把正确切分挤出候选路径之外,于是一个候选都没有。现在每个位置只保留「能接到词」的切分——查不到词继续的切分本来也不会产生候选,不该占位置。另一类是「以当前拼音开头的词」这个查询本身是坏的:按读音排序的词表里,以某个读音开头的词集中在中间一段,原来的二分查找把它们当成排在表尾处理,对任何读音都返回空区间,于是所有补全候选都不出(打 `ni` 只有单字,打 `jintian` 看不到「今天不是」这类词,全靠后面的 Rime 兜底才看着正常)。现在按读音的后继取区间。顺带两处排序:整串拼音能拼出的词(朋克)排在只是以它开头的词(朋克头)前面;先算整串读音、再算尾音未打完的片段,避免片段把查询预算吃光、用户真正打的那串反而没查到。随机 5 万个读音对照参考词典自己的排序,首选词一致 50000/50000(修复前补全候选全缺,只是恰好没影响这 5 万个读音的首选)。 - 修掉拼音引擎里一类「词库明明有词、候选栏却一个都不出」的问题:一个读音可以切成多种音节(`luanan` 既是 luan+an,也是 lu+a+nan),切分时每个位置保留的候选路径原来按先来后到截前 4 条,切成更多音节、实际不会命中的切法能先把位置占满,真正的那条挤不进来,于是候选栏空白、空格把原拼音原样送进输入框。现在每个位置按音节数从少到多保留最好的路径,保留条数从 4 提到 16——参考实现本来就是枚举全部路径、按每条音节 ×0.99 递减打分后再排序,路径条数不设上限。随机取 4007 个读音对照参考词典自己的排序:修复前 36 个读音有词却无候选(如 `luanan` 的「乱按」「卵鞍」、`geleimiao` 的「格雷米奥」),修复后 0 个,首选词与参考词典一致 3969 → 4006/4006。 - 设置页的键盘卡片新增「输入拼音显示」:默认仍是拼音留在键盘顶上的候选栏;选「显示在输入框」后,输入中的拼音直接写进应用自己的输入框,键盘上不再重复这一行,候选行照旧。密码框、数字/日期/电话这类字段,以及只吃按键事件的终端与游戏不写——否则拼音会进到密码里,或者落在一个只收数字的框里。参考皮肤里同一个设置(默认 0 = 显示在候选栏)也是这么分的。 - 展开候选页补上参考皮肤给这一页自己的侧边留白(2.5 单位),列表卡片两侧各留出一点底色;原来这张卡片贴着屏幕两边。竖屏键区不加参考皮肤给键区的那道侧边留白——它会改动九键与 26 键的键位大小,竖屏布局保持原样。 diff --git a/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt b/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt index e47a7038..65b98f0e 100644 --- a/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt +++ b/app/src/main/java/llc/slacker/openime/engine/LexiconIndex.kt @@ -93,15 +93,33 @@ internal class LexiconIndex(private val buffer: ByteBuffer) { return low } - /** First word after every word whose sequence starts with [ids]. */ + /** + * Whether any word starts with [ids], i.e. whether a reading that spelled + * [ids] so far still has somewhere to go. One binary search: the first word + * that is >= [ids] is the first word that can start with it. + */ + fun hasPrefix(ids: IntArray, length: Int): Boolean { + val at = lowerBound(ids, length) + return at < wordCount && startsWith(at, ids, length) + } + + /** + * First word after every word whose sequence starts with [ids]. + * + * The words that start with [ids] are one run in the *middle* of the table + * (below it are the sequences that are smaller at some syllable, above it + * the ones that are greater), so searching for "starts with [ids]" as if it + * were a prefix of the array walks away from the run: it answered 0 for + * every reading and no prefix of a typed reading ever reached a word. The + * probe is instead the sequence's successor — [ids] with its last syllable + * incremented: every word that starts with [ids] sorts before it, every + * word that does not sorts at or after it, and that bound is monotone. + */ fun prefixEnd(ids: IntArray, length: Int): Int { - var low = 0 - var high = wordCount - while (low < high) { - val middle = (low + high) ushr 1 - if (startsWith(middle, ids, length)) low = middle + 1 else high = middle - } - return low + if (length == 0) return wordCount + val probe = ids.copyOf(length) + probe[length - 1] = probe[length - 1] + 1 + return lowerBound(probe, length) } internal fun compare(word: Int, ids: IntArray, length: Int): Int { diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt index a097c950..8b2b8993 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt @@ -25,6 +25,15 @@ class PinyinEngine internal constructor( private val plainSegmenter = PinyinSegmenter(lexicon.syllables) private val fuzzySegmenters = HashMap, PinyinSegmenter>() + /** + * A reading only pays off if some word starts with it, so a split that no + * word continues is dropped instead of taking a slot in the lattice: the + * reference enumerates every path of the lattice (ENGINE-SPEC §1.3) and + * can afford to, but our capped frontier cannot — bogus splits that spell + * nothing (`a+de+lia+nan+...` for 阿德里安安德烈夫) would otherwise fill it. + */ + private val wordPrefix: (IntArray) -> Boolean = { ids -> lexicon.hasPrefix(ids, ids.size) } + val wordCount: Int get() = lexicon.size /** Candidate words for [input] (raw pinyin letters), best first. */ @@ -39,35 +48,49 @@ class PinyinEngine internal constructor( val found = HashMap() val budget = Budget() - for (split in 0..letters.length) { + // The reading in full first: those words are what the typing spells, + // and the fragment passes below are a fallback — running them first + // let them spend the budget, and the word the user actually typed + // never made it into the list. + for (path in segmenter.segment(letters, maxPaths = HEAD_PATHS, alive = wordPrefix)) { + collectExact(found, path.ids, path.fuzzy, budget, COMPLETE) + // A word that only starts with the reading is a completion, not a + // match: 朋克头 spells pengke and three more letters the user has + // not typed, so it follows 朋克/澎客/彭可. + collectPrefix(found, path.ids, path.fuzzy, budget, COMPLETION_WORDS, FRAGMENT) + } + + // Then the readings with an unfinished tail, longest head first: the + // closer the head is to the whole reading, the fewer letters it leaves + // for the tail to guess. + for (split in letters.length - 1 downTo 0) { if (budget.exhausted()) break val tail = letters.substring(split) if (tail.length > PinyinSegmenter.MAX_SYLLABLE_LENGTH) continue val headPaths = if (split == 0) { listOf(PinyinSegmenter.SegmentPath(IntArray(0), intArrayOf(0), fuzzy = false)) } else { - segmenter.segment(letters.substring(0, split), maxPaths = HEAD_PATHS) + segmenter.segment( + letters.substring(0, split), + maxPaths = HEAD_PATHS, + alive = wordPrefix, + ) } if (headPaths.isEmpty()) continue for (path in headPaths) { if (budget.exhausted()) break - if (tail.isEmpty()) { - collectExact(found, path.ids, path.fuzzy, budget, COMPLETE) - collectPrefix(found, path.ids, path.fuzzy, budget, COMPLETION_WORDS, COMPLETE) - } else { - // Words that already match the settled prefix stay candidates - // while the next syllable is still being typed. An empty - // prefix would match from the top of the table: skip it. - if (path.ids.isNotEmpty()) { - collectPrefix(found, path.ids, path.fuzzy, budget, FRAGMENT_WORDS, FRAGMENT) - } - for (edge in segmenter.syllablesStartingWith(tail, TAIL_SYLLABLES)) { - if (budget.exhausted()) break - val ids = path.ids.copyOf(path.ids.size + 1) - ids[ids.size - 1] = edge.id - collectPrefix(found, ids, path.fuzzy || edge.fuzzy, budget, FRAGMENT_WORDS, FRAGMENT) - budget.lookups++ - } + // Words that already match the settled prefix stay candidates + // while the next syllable is still being typed. An empty + // prefix would match from the top of the table: skip it. + if (path.ids.isNotEmpty()) { + collectPrefix(found, path.ids, path.fuzzy, budget, FRAGMENT_WORDS, FRAGMENT) + } + for (edge in segmenter.syllablesStartingWith(tail, TAIL_SYLLABLES)) { + if (budget.exhausted()) break + val ids = path.ids.copyOf(path.ids.size + 1) + ids[ids.size - 1] = edge.id + collectPrefix(found, ids, path.fuzzy || edge.fuzzy, budget, FRAGMENT_WORDS, FRAGMENT) + budget.lookups++ } } } @@ -268,7 +291,7 @@ class PinyinEngine internal constructor( private const val CANDIDATE_LIMIT = 60 private const val SENTENCE_LIMIT = 3 private const val MAX_INPUT = 64 - private const val HEAD_PATHS = 16 + private const val HEAD_PATHS = 64 private const val TAIL_SYLLABLES = 32 private const val PATH_LIMIT = 32 private const val MAX_WORD_SYLLABLES = 6 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index 01fc9ed9..9bf36709 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -15,6 +15,11 @@ import llc.slacker.openime.core.FuzzyRule * syllable pair (z/zh, n/l, an/ang...): the typed spelling may stand for a * syllable it is not literally; such edges carry `fuzzy = true` so callers * can rank exact readings first. + * + * [segment] takes an optional [alive] test: a path the caller cannot use is + * dropped before it takes a slot in the next node. The caller's word lookup + * only ever asks for readings that a word starts with, so a path that no word + * starts with can never contribute and must not crowd out one that can. */ internal class PinyinSegmenter( syllables: List, @@ -83,6 +88,7 @@ internal class PinyinSegmenter( letters: String, maxPaths: Int = 32, maxSyllables: Int = MAX_SYLLABLES, + alive: ((IntArray) -> Boolean)? = null, ): List { val length = letters.length if (length == 0) return emptyList() @@ -105,6 +111,7 @@ internal class PinyinSegmenter( for (index in targets.indices) { val ids = path.ids.copyOf(path.ids.size + 1) ids[ids.size - 1] = targets[index] + if (alive != null && !alive(ids)) continue val starts = path.starts.copyOf(path.starts.size + 1) starts[starts.size - 1] = position + edge addPath(next, SegmentPath(ids, starts, path.fuzzy || flags[index]), maxPaths) diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index c0870f90..0a0de65a 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -79,6 +79,68 @@ class PinyinEngineDataTest { assertTrue(engine.candidates("luanan").contains("乱按")) } + @Test + fun thePrefixRangeMatchesAPlainScan() { + val lex = engineAsset("lex.bin") + assumeTrue("imengine/lex.bin not built", lex != null) + val index = LexiconIndex(map(lex!!)) + // The words that start with a reading sit in the middle of a table + // sorted by reading, so the bound has to be the reading's successor, + // not a search for "starts with" over the array. + for (names in listOf( + listOf("ni"), + listOf("ni", "hao"), + listOf("ji", "tian"), + listOf("ji", "tian", "bu"), + listOf("a"), + )) { + val ids = IntArray(names.size) { index.syllables.indexOf(names[it]) } + val start = index.lowerBound(ids, ids.size) + var scan = start + while (scan < index.size && index.startsWith(scan, ids, ids.size)) scan++ + assertEquals( + "prefix range of ${names.joinToString("+")}", + scan, + index.prefixEnd(ids, ids.size), + ) + assertTrue("no words start with ${names.joinToString("+")}", scan > start) + } + } + + @Test + fun theWordTheReadingSpellsComesBeforeItsCompletions() { + val engine = loadEngine() + // 朋克 spells pengke exactly; 朋克头/朋克养生 only start with it. + assertEquals("朋克", engine.candidates("pengke").firstOrNull()) + // 要高考 is the word; 要高考了 extends it. + assertEquals("要高考", engine.candidates("yaogaokao").firstOrNull()) + } + + @Test + fun aTypedPrefixReachesTheWordsThatContinueIt() { + val engine = loadEngine() + // The tail is not a syllable yet: 你好 comes from ni + a tail that is + // still an h, and it has to be there without the user typing "hao". + val partial = engine.candidates("nih") + assertTrue("no candidates for a reading with an unfinished syllable: $partial", partial.contains("你好")) + // A whole word the reading starts: typing "jintian" offers 今天 first + // and the words that continue it behind. + val word = engine.candidates("jintian") + assertEquals("今天", word.firstOrNull()) + assertTrue("今天不是 missing from $word", word.contains("今天不是")) + } + + @Test + fun aLongWordSurvivesTheSplitsThatSpellNothing() { + val engine = loadEngine() + // a+de+li+an+an+de+lie+fu. The same letters also read a+de+lia+nan+ + // de+lie+fu — one syllable fewer, so it sorted ahead — and every + // syllable here doubles as a single letter, which widened the field + // again. Both used to fill the frontier before the real reading got + // there, and the bar came up empty for a word the table has. + assertEquals("阿德里安安德烈夫", engine.candidates("adelianandeliefu").firstOrNull()) + } + @Test fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { val engine = loadEngine() diff --git a/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt b/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt index 83583ac9..41644f49 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinSegmenterTest.kt @@ -58,4 +58,18 @@ class PinyinSegmenterTest { } } } + + @Test + fun aSplitNoWordContinuesDoesNotTakeASlot() { + val segmenter = PinyinSegmenter(syllables) + // The caller's lookup only asks for readings a word starts with, so the + // paths it can use are the ones the lattice should keep: here only + // luan+an leads anywhere, and the four-slot cap must not lose it. + val alive = { ids: IntArray -> + ids.size == 1 || (syllables[ids[0]] == "luan" && syllables[ids[1]] == "an") + } + val kept = segmenter.segment("luanan", maxPaths = 4, alive = alive) + .map { path -> path.ids.joinToString("+") { syllables[it] } } + assertEquals(listOf("luan+an"), kept) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7cb4ad89..fccc3b1c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -295,3 +295,9 @@ Add new entries at the end. - **The syllable lattice keeps its best paths, not its first ones.** The reference builds every path of the lattice and decides between them only when it ranks candidates: each position emits every matching syllable edge of the syllable trie (`SyllableLattice::CreateLattice` objdump 0x5b7ab4 → `BuildLattice` 0x5b7d1c, trie walk 0x3eb924, `AddEdge` 0x5ba8a4), every edge scores 0.99 of its predecessor (0x3f7d70a4, used for ranking only, never to drop an edge), and its limits hold no path or syllable count — 1..64 input bytes and 6 bytes per syllable edge are the only ones found (`doubaoime-teardown/ENGINE-SPEC.md` §1.3, §1.4). This port's `PinyinSegmenter.segment` kept the *first* `maxPaths` paths of each lattice node instead, and the compiled catalogue carries the single letters as syllables, so `luanan` filled that node with `lu+a+nan` before `luan+an` arrived and the word lookup never saw the reading: 36 of 4007 random readings came back with no candidate although the lexicon holds one (`乱按` / `卵鞍` at `luan+an`, `格雷米奥` at `ge+lei+mi+ao`), and the space bar committed the raw Latin letters. `addPath` now keeps the best `maxPaths` paths per node, ordered by syllable count, and the head cap rose from 4 to 16. *Reason:* the reference never loses a reading to a path it did not look at — a split into fewer syllables scores higher on its own 0.99-per-edge decay, which is exactly the order our node lists now hold — while this port's cap is its own performance guard and must not change which readings exist. On the same 4007 readings the empty-with-entries count goes from 36 to 0 and the first candidate agrees with the reference lexicon on all 4006 readings that have an entry (`PinyinEngineRankCompareTest` dumps the comparison and asserts it). The wider cap costs 121µs per query against 60µs, against the 16ms of one frame, and the lookup budget still bounds the word work itself. +- **The lattice keeps only the paths a word continues.** + The reading `adelianandeliefu` spells 阿德里安安德烈夫 as `a+de+li+an+an+de+lie+fu`, and the compiled catalogue carries every single letter as a syllable (the letter entries of the table), so the same letters also read `a+de+lia+nan+de+lie+fu` — one syllable fewer — and every one of those positions doubles again on the duplicated `a` entry. Fewer syllables sorts first, so with a cap of 16 the frontier of the last node held only splits that spell nothing and the word lookup never saw the reading: `candidates("adelianandeliefu")` came back empty although `lex.bin` holds the word (index 751). `PinyinSegmenter.segment` now takes the caller's test for "a word starts with this reading" and drops a new path before it takes a slot (`PinyinEngine.wordPrefix` → `LexiconIndex.hasPrefix`, one binary search), and the head cap rose from 16 to 64. + *Reason:* the caller's lookups can only ever return words that start with the reading (`collectExact` needs the reading to be the word, `collectPrefix` needs it to start one), so a path no word continues is worth nothing and must not displace one that a word does continue. The reference enumerates every path of the lattice itself and has no cap to fill (`doubaoime-teardown/ENGINE-SPEC.md` §1.3 "全部 0→len(s) 路径", §1.4 "音节个数上限: 未发现显式上限"), which is affordable when every position emits only the syllable trie's own matches; against a per-node cap the pruning is what makes the same set of readings survive. The 50,000-reading comparison against the reference lexicon's own order now agrees on every reading (`PinyinEngineRankCompareTest`), and the live bar shows 阿德里安安德烈夫 first. +- **The prefix bound is the reading's successor, not a search for "starts with".** + `LexiconIndex.prefixEnd` binary-searched the table for the first word that does *not* start with the reading, which assumes the words that do form a prefix of the array. They do not: the table is sorted by reading, so they form one run in the middle — below it sit the readings that are smaller at some syllable, above it the greater ones. Starting from the middle of the array, the search walked away from the run and answered 0 for every reading (`prefixEnd` for `ni` returned 0 where the words that start with it end at 1,105,481), so `collectPrefix` iterated an empty range and **no word that continues a typed reading was ever offered** — typing `ni` showed the single characters only and the bar leaned on the Rime supplement behind it for everything else. The probe is now the reading's successor — the reading with its last syllable incremented — which sorts after every word that starts with the reading and at or before every word that does not, and that bound is monotone. Two ordering faults that the dead lookup had hidden are fixed with it: a word that only *starts* with the reading (朋克头 for `pengke`) is now a completion and no longer outranks the words the reading spells (朋克/澎客/彭可), and the reading in full is looked up before the fragments, so the fragment passes can no longer spend the lookup budget before the word the user actually typed is reached. + *Reason:* a prefix scan is what turns a partial reading into candidates, and the reference's candidate column is full of them (its Convert runs SysDict before the dictionaries that only extend a reading, `ENGINE-SPEC.md` §0.5). The measured effect on the local comparison is unchanged at 50,000/50,000 first candidates, the completions come back on the live bar (`nih` → 你好/你换/你还), and a query stays at 40–380µs against the 16ms of one frame. From 8078ba934b33c10ec3f067b3a856bc79f9d98b56 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:47:18 +0800 Subject: [PATCH 58/94] Lead the candidate bar with the reading when no word spells it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `jintianxingqiwu` is not an entry of the compiled lexicon, so the bar answered with the words that merely start the same way — 今天心情, 今天新闻 — where the whole reading is 今天 + 星期五. The Rime supplement behind the engine had been showing 今天星期五 by accident, so the engine taking over the order made that case visibly worse. `PinyinEngine.decode` has composed sentences from the lattice all along and nothing called it. A reading that is a whole sequence of syllables and that no word spells now goes through it first, which is what the reference's own candidate column does (its Convert pipeline ends in a sentence stage). A reading a word does spell keeps that word first (`pengke` → 朋克), and a reading whose tail is still half a syllable has its own pass and gets no sentence. The sentence pass cannot use the word-prefix pruning the candidate pass uses: a sentence's path can sit inside one of its own words (我很好谢谢 = 我+很好+谢谢, and no word starts with 我很好些), so pruning there silently dropped whole sentences. Latency stays 30–280µs a query, 1.1–1.6ms when the sentence pass runs. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/engine/PinyinEngine.kt | 47 +++++++++++++++++-- .../slacker/openime/engine/PinyinSegmenter.kt | 6 ++- .../slacker/openime/PinyinEngineDataTest.kt | 15 ++++++ docs/DECISIONS.md | 4 ++ 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a563769b..b5a5d851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 整句拼音打全时,候选栏第一位改为整句本身:像 `jintianxingqiwu` 这样的读音,词库里没有对应的整词,以前候选栏给的是「今天心情」这类只借了前半段的词,现在先给「今天星期五」(今天+星期五)。词库里真有这个词的读音不变(`pengke` 仍是「朋克」在前)。尾音还没打完的拼音(如 `jintianxingqiw`)不出整句,仍按已打全的音节给词。参考实现本来就是每条读音都过一遍整句转换、候选栏以整句打头。 - 修掉拼音候选栏另外两类「词库里有词、却不出候选」的问题。一类是长读音切分被挤掉:`adelianandeliefu` 应切成 a+de+li+an+an+de+lie+fu(阿德里安安德烈夫),但同样这串字母还能切成音节更少的 a+de+lia+nan+de+lie+fu,单个字母本身也是合法音节(词表里就有字母词条),音节少的那条排在前面,把正确切分挤出候选路径之外,于是一个候选都没有。现在每个位置只保留「能接到词」的切分——查不到词继续的切分本来也不会产生候选,不该占位置。另一类是「以当前拼音开头的词」这个查询本身是坏的:按读音排序的词表里,以某个读音开头的词集中在中间一段,原来的二分查找把它们当成排在表尾处理,对任何读音都返回空区间,于是所有补全候选都不出(打 `ni` 只有单字,打 `jintian` 看不到「今天不是」这类词,全靠后面的 Rime 兜底才看着正常)。现在按读音的后继取区间。顺带两处排序:整串拼音能拼出的词(朋克)排在只是以它开头的词(朋克头)前面;先算整串读音、再算尾音未打完的片段,避免片段把查询预算吃光、用户真正打的那串反而没查到。随机 5 万个读音对照参考词典自己的排序,首选词一致 50000/50000(修复前补全候选全缺,只是恰好没影响这 5 万个读音的首选)。 - 修掉拼音引擎里一类「词库明明有词、候选栏却一个都不出」的问题:一个读音可以切成多种音节(`luanan` 既是 luan+an,也是 lu+a+nan),切分时每个位置保留的候选路径原来按先来后到截前 4 条,切成更多音节、实际不会命中的切法能先把位置占满,真正的那条挤不进来,于是候选栏空白、空格把原拼音原样送进输入框。现在每个位置按音节数从少到多保留最好的路径,保留条数从 4 提到 16——参考实现本来就是枚举全部路径、按每条音节 ×0.99 递减打分后再排序,路径条数不设上限。随机取 4007 个读音对照参考词典自己的排序:修复前 36 个读音有词却无候选(如 `luanan` 的「乱按」「卵鞍」、`geleimiao` 的「格雷米奥」),修复后 0 个,首选词与参考词典一致 3969 → 4006/4006。 - 设置页的键盘卡片新增「输入拼音显示」:默认仍是拼音留在键盘顶上的候选栏;选「显示在输入框」后,输入中的拼音直接写进应用自己的输入框,键盘上不再重复这一行,候选行照旧。密码框、数字/日期/电话这类字段,以及只吃按键事件的终端与游戏不写——否则拼音会进到密码里,或者落在一个只收数字的框里。参考皮肤里同一个设置(默认 0 = 显示在候选栏)也是这么分的。 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt index 8b2b8993..8d557fda 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt @@ -52,7 +52,8 @@ class PinyinEngine internal constructor( // and the fragment passes below are a fallback — running them first // let them spend the budget, and the word the user actually typed // never made it into the list. - for (path in segmenter.segment(letters, maxPaths = HEAD_PATHS, alive = wordPrefix)) { + val spellings = segmenter.segment(letters, maxPaths = HEAD_PATHS, alive = wordPrefix) + for (path in spellings) { collectExact(found, path.ids, path.fuzzy, budget, COMPLETE) // A word that only starts with the reading is a completion, not a // match: 朋克头 spells pengke and three more letters the user has @@ -94,17 +95,47 @@ class PinyinEngine internal constructor( } } } - if (found.isEmpty()) return emptyList() - return found.entries + // A reading whose every letter is a syllable but which no single word + // spells is still a reading: the reference composes it from the words + // it holds (`jintianxingqiwu` → 今天 + 星期五) and its bar leads with + // that, so a user who typed a whole sentence sees the sentence and not + // a word that merely starts the same way. The words must come up empty + // (the reading spells none) and the tail must be typed in full — a + // reading that still ends mid-syllable has its own pass above. + val sentences = if (found.values.none { it.coverage == COMPLETE } && isTypedInFull(letters)) { + decode(letters, limit = SENTENCE_LIMIT, fuzzyRules = fuzzyRules) + } else { + emptyList() + } + + val ranked = found.entries .sortedWith( compareBy> { it.value.coverage } .thenByDescending { it.value.score } .thenBy { it.key }, ) - .take(limit) .map { it.key } + if (ranked.isEmpty() && sentences.isEmpty()) return emptyList() + return (sentences + ranked).distinct().take(limit) } + /** + * Whether [letters] reads as whole syllables, i.e. whether some reading of + * it uses no single letter as a syllable of its own. + * + * A tail that is still half a syllable leaves only readings that end in a + * single letter (every letter is a syllable of the catalogue), so this is + * the line between `jintianxingqiwu` — five syllables, no word spells them, + * a sentence does — and `jintianxingqiw`, which the fragment pass above + * already answers for and which no sentence can cover. + */ + private fun isTypedInFull(letters: String): Boolean = + plainSegmenter.segment(letters, maxPaths = HEAD_PATHS).any { path -> + (0 until path.ids.size).all { index -> + path.starts[index + 1] - path.starts[index] >= SPELLED_SYLLABLE + } + } + /** Top sentence readings for [input]; each result is the joined words. */ fun decode( input: String, @@ -113,6 +144,10 @@ class PinyinEngine internal constructor( ): List { val letters = normalize(input) if (letters.isEmpty() || letters.length > MAX_INPUT) return emptyList() + // No word-prefix pruning here: a sentence's own spans are words, but + // the reading up to a span boundary can sit inside one of them + // (我很好谢谢 = 我+很好+谢谢, and no word starts with 我很好些), so the + // test the candidate pass uses would drop the sentences themselves. val paths = segmenterFor(fuzzyRules).segment(letters, maxPaths = PATH_LIMIT) if (paths.isEmpty()) return emptyList() val length = letters.length @@ -303,11 +338,13 @@ class PinyinEngine internal constructor( private const val COMPLETE = 0 private const val FRAGMENT = 1 private const val COMPLETION_WORDS = 64 - private const val FRAGMENT_WORDS = 64 + private const val FRAGMENT_WORDS = 16 private const val WORDS_PER_SPAN = 24 private const val TOTAL_WORDS = 1500 private const val TOTAL_LOOKUPS = 600 private const val STATE_K = 3 + /** Shortest syllable that still says the tail was typed in full. */ + private const val SPELLED_SYLLABLE = 2 /** Fuzzy candidates keep their frequency but pay this much score * (below the 6571-vs-4655 gap of 师傅/私服, so a clearly more common * fuzzy word still wins against a mediocre literal one). */ diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index 9bf36709..c9e6b76e 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -111,7 +111,11 @@ internal class PinyinSegmenter( for (index in targets.indices) { val ids = path.ids.copyOf(path.ids.size + 1) ids[ids.size - 1] = targets[index] - if (alive != null && !alive(ids)) continue + // A path that ends the input is a reading of it even + // when no word starts with it (the sentence pass takes + // those); only a path that still has letters to go is + // worth keeping solely for the word it may reach. + if (alive != null && position + edge < length && !alive(ids)) continue val starts = path.starts.copyOf(path.starts.size + 1) starts[starts.size - 1] = position + edge addPath(next, SegmentPath(ids, starts, path.fuzzy || flags[index]), maxPaths) diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index 0a0de65a..8de4d6e6 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -107,6 +107,21 @@ class PinyinEngineDataTest { } } + @Test + fun aReadingNoWordSpellsComesBackAsTheSentenceItIs() { + val engine = loadEngine() + // No lexicon entry spells jintianxingqiwu; 今天 + 星期五 does, and the + // bar leads with it the way the reference's candidate column does. + assertEquals("今天星期五", engine.candidates("jintianxingqiwu").firstOrNull()) + assertEquals("我很好谢谢", engine.candidates("wohenhaoxiexie").firstOrNull()) + // A word the reading does spell keeps the place: the sentence only + // fills in where there is no such word. + assertEquals("朋克", engine.candidates("pengke").firstOrNull()) + // Half a syllable at the end is not a sentence: the bar shows the + // words the finished syllables already reach. + assertEquals(listOf("今天心情", "今天新闻", "今天星期"), engine.candidates("jintianxingqiw").take(3)) + } + @Test fun theWordTheReadingSpellsComesBeforeItsCompletions() { val engine = loadEngine() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fccc3b1c..13c5c39a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -301,3 +301,7 @@ Add new entries at the end. - **The prefix bound is the reading's successor, not a search for "starts with".** `LexiconIndex.prefixEnd` binary-searched the table for the first word that does *not* start with the reading, which assumes the words that do form a prefix of the array. They do not: the table is sorted by reading, so they form one run in the middle — below it sit the readings that are smaller at some syllable, above it the greater ones. Starting from the middle of the array, the search walked away from the run and answered 0 for every reading (`prefixEnd` for `ni` returned 0 where the words that start with it end at 1,105,481), so `collectPrefix` iterated an empty range and **no word that continues a typed reading was ever offered** — typing `ni` showed the single characters only and the bar leaned on the Rime supplement behind it for everything else. The probe is now the reading's successor — the reading with its last syllable incremented — which sorts after every word that starts with the reading and at or before every word that does not, and that bound is monotone. Two ordering faults that the dead lookup had hidden are fixed with it: a word that only *starts* with the reading (朋克头 for `pengke`) is now a completion and no longer outranks the words the reading spells (朋克/澎客/彭可), and the reading in full is looked up before the fragments, so the fragment passes can no longer spend the lookup budget before the word the user actually typed is reached. *Reason:* a prefix scan is what turns a partial reading into candidates, and the reference's candidate column is full of them (its Convert runs SysDict before the dictionaries that only extend a reading, `ENGINE-SPEC.md` §0.5). The measured effect on the local comparison is unchanged at 50,000/50,000 first candidates, the completions come back on the live bar (`nih` → 你好/你换/你还), and a query stays at 40–380µs against the 16ms of one frame. +- **The bar leads with the sentence when no word spells the reading.** + The reference converts every reading through its dictionary pipeline and its candidate column opens with the reading itself: `jintianxingqiwu` comes back as 今天星期五 — 今天 + 星期五, a sentence, since no entry of its lexicon spells that reading (`doubaoime-teardown/ENGINE-SPEC.md` §0.5 lists the 18-source Convert pipeline, §2 its backtest). This port had the same conversion in `PinyinEngine.decode` and never called it: the candidate bar showed the words that merely start the reading instead (今天心情, 今天新闻), where the Rime supplement behind the engine had previously shown 今天星期五 by accident, so the alignment made that case look worse. `candidates` now composes the reading when it is a whole sequence of syllables and no word spells it, and puts the result first. + *Reason:* the reading the user typed is the candidate they mean, and the alternative — a word that shares the first syllables — is the one thing they did not type. The gate is deliberately narrow: a reading whose tail is still half a syllable (every letter of the catalogue is a syllable, so `jintianxingqiw` also reads as jin+tian+xing+qi+w) has its own pass and gets no sentence, and a reading a word does spell keeps that word first (`pengke` → 朋克, not a composition). The sentence pass is not word-prefix pruned, unlike the candidate pass, because a sentence's own path can sit inside one of its words (我很好谢谢 = 我+很好+谢谢, where no word starts with 我很好些) — pruning there silently dropped whole sentences. + Cost: a query costs 30–280µs as before, and 1.1–1.6ms when the sentence pass runs, against the 16ms of one frame. From cbe6909e3080ae95e1089dbe62ffb068a5c94f21 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:53:06 +0800 Subject: [PATCH 59/94] Return the committed text and the candidate bar to the volume run The per-keyboard volume E2E run reads both between keystrokes: it types thousands of words and needs one round trip per check to know what was committed and what the bar offered. Both answers come back base64-encoded so arbitrary editor text cannot break the log line. The commands reach the service only from the debug-only receiver; the release APK carries no receiver, as the harness commit on test/volume-e2e records. Co-Authored-By: Claude Code --- .../slacker/openime/LocalVoiceImeService.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 10a1b307..c9b977ab 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -826,6 +826,22 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can Log.i(TAG, "BOUNDS\n${keyboardView?.normalizedBoundsReport().orEmpty()}") true } + // Volume E2E reads: the harness types thousands of words, so it needs + // one round trip to check what was committed and what the candidate bar + // offers. Both answers go out base64-encoded (no line breaks), because + // the editor's text is arbitrary user text. Only the debug-only + // receiver can send these commands; the release APK has no receiver. + command.startsWith("editor-tail64:") -> runCatching { + val size = command.substringAfter("editor-tail64:").toIntOrNull()?.coerceIn(1, 2_000) ?: 400 + val snapshot = gateway.cursorSnapshot(size) ?: return@runCatching false + val committed = snapshot.text.take(snapshot.cursor) + Log.i(TAG, "EDITOR ${encodeBase64Utf8(committed.takeLast(size))}") + true + }.getOrDefault(false) + command == "candidates64" -> { + Log.i(TAG, "CANDIDATES ${encodeBase64Utf8(state.candidates.take(60).joinToString("\n"))}") + true + } command == "state" -> { Log.i( TAG, @@ -843,6 +859,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private fun decodeBase64Utf8(payload: String): String = String(java.util.Base64.getDecoder().decode(payload), Charsets.UTF_8) + private fun encodeBase64Utf8(text: String): String = + java.util.Base64.getEncoder().encodeToString(text.toByteArray(Charsets.UTF_8)) + /** * Feed text one *code point* per call. Iterating Char-by-Char split every * surrogate pair, so a test could never exercise emoji input through the From f221ef9850de125658e23085aa3f6427666b5533 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:01:12 +0800 Subject: [PATCH 60/94] Read the sentence words off the lattice, one position at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's lattice is a node per position and an edge per syllable match there, and it reads its words off those edges (ENGINE-SPEC §1.3, "对每个可达位置独立从 Trie root 走后缀"). Decode read them off whole paths instead: every span of every path the segmenter returned, each span looked up once per path that crosses it. A sentence's paths run into the hundreds (1352 for wohenhaoxiexie, 820 for adelianandeliefu). PinyinSegmenter.lattice returns the per-position edges, and decode walks them from each start position, growing a reading one syllable edge at a time up to MAX_WORD_SYLLABLES. The comparison against the reference lexicon holds at 50000/50000 first candidates; a query costs 43-171us, 758us-1.38ms when the sentence pass runs. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/engine/PinyinEngine.kt | 55 +++++++++++-------- .../slacker/openime/engine/PinyinSegmenter.kt | 30 ++++++++++ docs/DECISIONS.md | 3 + 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a5d851..bc6f176c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 - 整句拼音打全时,候选栏第一位改为整句本身:像 `jintianxingqiwu` 这样的读音,词库里没有对应的整词,以前候选栏给的是「今天心情」这类只借了前半段的词,现在先给「今天星期五」(今天+星期五)。词库里真有这个词的读音不变(`pengke` 仍是「朋克」在前)。尾音还没打完的拼音(如 `jintianxingqiw`)不出整句,仍按已打全的音节给词。参考实现本来就是每条读音都过一遍整句转换、候选栏以整句打头。 - 修掉拼音候选栏另外两类「词库里有词、却不出候选」的问题。一类是长读音切分被挤掉:`adelianandeliefu` 应切成 a+de+li+an+an+de+lie+fu(阿德里安安德烈夫),但同样这串字母还能切成音节更少的 a+de+lia+nan+de+lie+fu,单个字母本身也是合法音节(词表里就有字母词条),音节少的那条排在前面,把正确切分挤出候选路径之外,于是一个候选都没有。现在每个位置只保留「能接到词」的切分——查不到词继续的切分本来也不会产生候选,不该占位置。另一类是「以当前拼音开头的词」这个查询本身是坏的:按读音排序的词表里,以某个读音开头的词集中在中间一段,原来的二分查找把它们当成排在表尾处理,对任何读音都返回空区间,于是所有补全候选都不出(打 `ni` 只有单字,打 `jintian` 看不到「今天不是」这类词,全靠后面的 Rime 兜底才看着正常)。现在按读音的后继取区间。顺带两处排序:整串拼音能拼出的词(朋克)排在只是以它开头的词(朋克头)前面;先算整串读音、再算尾音未打完的片段,避免片段把查询预算吃光、用户真正打的那串反而没查到。随机 5 万个读音对照参考词典自己的排序,首选词一致 50000/50000(修复前补全候选全缺,只是恰好没影响这 5 万个读音的首选)。 - 修掉拼音引擎里一类「词库明明有词、候选栏却一个都不出」的问题:一个读音可以切成多种音节(`luanan` 既是 luan+an,也是 lu+a+nan),切分时每个位置保留的候选路径原来按先来后到截前 4 条,切成更多音节、实际不会命中的切法能先把位置占满,真正的那条挤不进来,于是候选栏空白、空格把原拼音原样送进输入框。现在每个位置按音节数从少到多保留最好的路径,保留条数从 4 提到 16——参考实现本来就是枚举全部路径、按每条音节 ×0.99 递减打分后再排序,路径条数不设上限。随机取 4007 个读音对照参考词典自己的排序:修复前 36 个读音有词却无候选(如 `luanan` 的「乱按」「卵鞍」、`geleimiao` 的「格雷米奥」),修复后 0 个,首选词与参考词典一致 3969 → 4006/4006。 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt index 8d557fda..1644689d 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinEngine.kt @@ -144,33 +144,39 @@ class PinyinEngine internal constructor( ): List { val letters = normalize(input) if (letters.isEmpty() || letters.length > MAX_INPUT) return emptyList() - // No word-prefix pruning here: a sentence's own spans are words, but - // the reading up to a span boundary can sit inside one of them - // (我很好谢谢 = 我+很好+谢谢, and no word starts with 我很好些), so the - // test the candidate pass uses would drop the sentences themselves. - val paths = segmenterFor(fuzzyRules).segment(letters, maxPaths = PATH_LIMIT) - if (paths.isEmpty()) return emptyList() val length = letters.length + // Words are read off the lattice, one start position at a time, the way + // the reference walks its own: every reading of the next up to + // MAX_WORD_SYLLABLES syllables is a word lookup, and a reading is + // followed only as far as the lattice carries it. (Reading words off + // whole paths instead looks the same window up once per path that + // crosses it, and the paths a sentence spans run into the hundreds.) + val lattice = segmenterFor(fuzzyRules).lattice(letters) + if (lattice.isEmpty()) return emptyList() val edges = Array(length + 1) { ArrayList(8) } - for (path in paths) { - val syllables = path.ids.size - for (from in 0 until syllables) { - val charFrom = path.starts[from] - var to = from - while (to < syllables && to - from < MAX_WORD_SYLLABLES) { - val charTo = path.starts[to + 1] - val span = path.ids.copyOfRange(from, to + 1) - val start = lexicon.lowerBound(span, span.size) - val end = lexicon.exactEnd(span, span.size) - var taken = 0 - for (word in start until end) { - if (taken >= WORDS_PER_SPAN) break - taken++ - edges[charFrom].add(Edge(charTo, word, path.fuzzy)) + for (start in 0 until length) { + var readings = listOf(Reading(START_IDS, start, fuzzy = false)) + for (depth in 1..MAX_WORD_SYLLABLES) { + val next = ArrayList(readings.size * 2) + for (reading in readings) { + if (reading.end >= length) continue + for (edge in lattice[reading.end]) { + val ids = reading.ids.copyOf(reading.ids.size + 1) + ids[ids.size - 1] = edge.id + val first = lexicon.lowerBound(ids, ids.size) + val last = lexicon.exactEnd(ids, ids.size) + var taken = 0 + for (word in first until last) { + if (taken >= WORDS_PER_SPAN) break + taken++ + edges[start].add(Edge(edge.end, word, reading.fuzzy || edge.fuzzy)) + } + next.add(Reading(ids, edge.end, reading.fuzzy || edge.fuzzy)) } - to++ } + if (next.isEmpty()) break + readings = next } } @@ -308,6 +314,9 @@ class PinyinEngine internal constructor( private class Edge(val to: Int, val word: Int, val fuzzy: Boolean) + /** A reading of the letters from one start position to [end]. */ + private class Reading(val ids: IntArray, val end: Int, val fuzzy: Boolean) + /** * One candidate: how much of the typed input the reading explains * ([coverage], COMPLETE beats FRAGMENT) and the word's frequency score. @@ -328,7 +337,6 @@ class PinyinEngine internal constructor( private const val MAX_INPUT = 64 private const val HEAD_PATHS = 64 private const val TAIL_SYLLABLES = 32 - private const val PATH_LIMIT = 32 private const val MAX_WORD_SYLLABLES = 6 private const val EXACT_WORDS = 48 /** Coverage ranks first: a word whose reading spells out every typed @@ -343,6 +351,7 @@ class PinyinEngine internal constructor( private const val TOTAL_WORDS = 1500 private const val TOTAL_LOOKUPS = 600 private const val STATE_K = 3 + private val START_IDS = IntArray(0) /** Shortest syllable that still says the tail was typed in full. */ private const val SPELLED_SYLLABLE = 2 /** Fuzzy candidates keep their frequency but pay this much score diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index c9e6b76e..9e0d1b46 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -142,6 +142,36 @@ internal class PinyinSegmenter( into.add(at, path) } + /** One syllable edge of the lattice: it consumes letters up to [end]. */ + internal class LatticeEdge(val end: Int, val id: Int, val fuzzy: Boolean) + + /** + * The syllable lattice of [letters]: for every position, every syllable of + * the catalogue that matches there. + * + * The reference builds its lattice exactly this way — `BuildLattice` walks + * its syllable trie from each reachable position and adds one edge per + * match (`ENGINE-SPEC.md` §1.1, §1.3) — and reads words off the edges. A + * caller that reads words off whole paths instead looks the same window up + * once per path that crosses it. + */ + fun lattice(letters: String): List> { + val length = letters.length + return (0 until length).map { position -> + val out = ArrayList(4) + val maxEdge = minOf(MAX_SYLLABLE_LENGTH, length - position) + for (edge in 1..maxEdge) { + val spelling = letters.substring(position, position + edge) + val targets = edgeIds[spelling] ?: continue + val flags = edgeFuzzy.getValue(spelling) + for (index in targets.indices) { + out.add(LatticeEdge(position + edge, targets[index], flags[index])) + } + } + out + } + } + /** Syllable edges whose spelling starts with the trailing fragment [fragment]. */ fun syllablesStartingWith(fragment: String, max: Int = 32): List { if (fragment.isEmpty()) return emptyList() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 13c5c39a..9ff56153 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -305,3 +305,6 @@ Add new entries at the end. The reference converts every reading through its dictionary pipeline and its candidate column opens with the reading itself: `jintianxingqiwu` comes back as 今天星期五 — 今天 + 星期五, a sentence, since no entry of its lexicon spells that reading (`doubaoime-teardown/ENGINE-SPEC.md` §0.5 lists the 18-source Convert pipeline, §2 its backtest). This port had the same conversion in `PinyinEngine.decode` and never called it: the candidate bar showed the words that merely start the reading instead (今天心情, 今天新闻), where the Rime supplement behind the engine had previously shown 今天星期五 by accident, so the alignment made that case look worse. `candidates` now composes the reading when it is a whole sequence of syllables and no word spells it, and puts the result first. *Reason:* the reading the user typed is the candidate they mean, and the alternative — a word that shares the first syllables — is the one thing they did not type. The gate is deliberately narrow: a reading whose tail is still half a syllable (every letter of the catalogue is a syllable, so `jintianxingqiw` also reads as jin+tian+xing+qi+w) has its own pass and gets no sentence, and a reading a word does spell keeps that word first (`pengke` → 朋克, not a composition). The sentence pass is not word-prefix pruned, unlike the candidate pass, because a sentence's own path can sit inside one of its words (我很好谢谢 = 我+很好+谢谢, where no word starts with 我很好些) — pruning there silently dropped whole sentences. Cost: a query costs 30–280µs as before, and 1.1–1.6ms when the sentence pass runs, against the 16ms of one frame. +- **The sentence pass reads words off the lattice, one start position at a time.** + The reference's lattice is a node per position and an edge per syllable match there, and it reads its words off those edges: `BuildLattice` walks the syllable trie from each reachable position and `AddEdge` records one edge per match, so the same window is never looked up twice (`doubaoime-teardown/ENGINE-SPEC.md` §1.3 "pass1: 音节边(对每个可达位置独立从 Trie root 走后缀)"). `PinyinEngine.decode` read them off whole paths instead — every span of every path the segmenter returned, each span looked up once per path that crosses it — and the paths a sentence spans are many: 1352 for `wohenhaoxiexie`, 820 for `adelianandeliefu`, 360 for `zhongguogongchandang` (measured uncapped, 176 for `jintianxingqiwu`). The pass now walks the lattice from each start position, carrying a reading that grows one syllable edge at a time and stops where the lattice stops, `MAX_WORD_SYLLABLES` syllables in. + *Reason:* the reference's own shape is the cheaper one — a word lookup per (start, reading) rather than per (path, span) — and the port had no reason to keep the more expensive one once the sentence pass became reachable from the bar. The comparison against the reference lexicon's order is unchanged at 50,000/50,000 first candidates, the 14 sentence cases in `PinyinEngineDataTest` all come back first, and a query costs 43–171µs when no sentence runs against 758µs–1.38ms when one does (`wohenhaoxiexie` is the longest the bar ever composes), the 16ms of one frame, or 57µs per reading over the 50,000-reading sample. From 31ddfce3af7428811ac38e9f8c8b25647b2bf41a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:06:30 +0800 Subject: [PATCH 61/94] Spell the syllables the reference spells with v The reference's syllable trie carries jv/jvan/jve/jvn at ju/juan/jue/jun, the same for q/x/y, and lve/nve at lue/nue, all with C=0 D=0 -- outside both fuzzy masks, so no setting gates them (f_syllable-variants.tsv). Our catalogue is the map's canonical names only, so typing jv found no syllable and no word spells j+v: the bar came back empty, as it did for xv, xvan, jve, qvn and yvn. PinyinSegmenter derives the v spelling of every syllable the reference has one for and adds it as an ordinary edge (non-fuzzy, no score penalty), in the lattice and in the fragment scan. All 16 j/q/x/y spellings now return the canonical spelling's candidate list; the 50000-reading comparison is unchanged at 50000/50000. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/engine/PinyinSegmenter.kt | 37 ++++++++++++++++++- .../slacker/openime/PinyinEngineDataTest.kt | 37 +++++++++++++++++++ docs/DECISIONS.md | 3 ++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6f176c..ddda6b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 26 键上把 ü 写成 v 的拼法现在能打出对应的音节:参考实现的音节表把 `jv`/`jvan`/`jve`/`jvn` 当作 `ju`/`juan`/`jue`/`jun` 自己的拼写(`q`、`x`、`y` 三族同理,另有 `lve`/`nve` 对 `lue`/`nue`),这几条与模糊音设置无关(参考里它们的两个掩码字节都是 0),而我们的音节表只有规范拼写,于是打 `jv`、`xv`、`xvan`、`yvn`、`jve`、`qvn` 一个候选都没有——26 键上没有 ü 这个字母,v 就是它惯用的写法。现在这些拼法与规范拼法给出同一列候选。 - 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 - 整句拼音打全时,候选栏第一位改为整句本身:像 `jintianxingqiwu` 这样的读音,词库里没有对应的整词,以前候选栏给的是「今天心情」这类只借了前半段的词,现在先给「今天星期五」(今天+星期五)。词库里真有这个词的读音不变(`pengke` 仍是「朋克」在前)。尾音还没打完的拼音(如 `jintianxingqiw`)不出整句,仍按已打全的音节给词。参考实现本来就是每条读音都过一遍整句转换、候选栏以整句打头。 - 修掉拼音候选栏另外两类「词库里有词、却不出候选」的问题。一类是长读音切分被挤掉:`adelianandeliefu` 应切成 a+de+li+an+an+de+lie+fu(阿德里安安德烈夫),但同样这串字母还能切成音节更少的 a+de+lia+nan+de+lie+fu,单个字母本身也是合法音节(词表里就有字母词条),音节少的那条排在前面,把正确切分挤出候选路径之外,于是一个候选都没有。现在每个位置只保留「能接到词」的切分——查不到词继续的切分本来也不会产生候选,不该占位置。另一类是「以当前拼音开头的词」这个查询本身是坏的:按读音排序的词表里,以某个读音开头的词集中在中间一段,原来的二分查找把它们当成排在表尾处理,对任何读音都返回空区间,于是所有补全候选都不出(打 `ni` 只有单字,打 `jintian` 看不到「今天不是」这类词,全靠后面的 Rime 兜底才看着正常)。现在按读音的后继取区间。顺带两处排序:整串拼音能拼出的词(朋克)排在只是以它开头的词(朋克头)前面;先算整串读音、再算尾音未打完的片段,避免片段把查询预算吃光、用户真正打的那串反而没查到。随机 5 万个读音对照参考词典自己的排序,首选词一致 50000/50000(修复前补全候选全缺,只是恰好没影响这 5 万个读音的首选)。 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index 9e0d1b46..79a592c6 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -45,6 +45,10 @@ internal class PinyinSegmenter( fuzzy.getOrPut(spelling) { ArrayList(2) }.add(isFuzzy) } syllables.forEachIndexed { id, name -> add(name, id, isFuzzy = false) } + syllables.forEachIndexed { id, name -> + val spelling = vSpelling(name) ?: return@forEachIndexed + if (spelling !in nameToId) add(spelling, id, isFuzzy = false) + } if (fuzzyRules.isNotEmpty()) { syllables.forEachIndexed { id, name -> for (rule in fuzzyRules) { @@ -73,6 +77,10 @@ internal class PinyinSegmenter( val scan = ArrayList>(syllables.size * 2) syllables.forEachIndexed { id, name -> scan += Triple(name, id, false) } + syllables.forEachIndexed { id, name -> + val spelling = vSpelling(name) ?: return@forEachIndexed + if (spelling !in nameToId) scan += Triple(spelling, id, false) + } if (fuzzyRules.isNotEmpty()) { syllables.forEachIndexed { id, name -> for (rule in fuzzyRules) { @@ -179,8 +187,10 @@ internal class PinyinSegmenter( val seen = HashSet(max * 2) for ((spelling, id, isFuzzy) in scanEdges) { if (spelling.length >= fragment.length && spelling.startsWith(fragment)) { - if (isFuzzy && !seen.add(id)) continue - if (!isFuzzy) seen.add(id) + // Two spellings of one syllable - a v form (`ju`/`jv`) or a + // fuzzy pair whose other spelling is also a syllable - are one + // edge, or the same words are looked up twice per fragment. + if (!seen.add(id)) continue out.add(SyllableEdge(id, isFuzzy)) if (out.size >= max) break } @@ -191,5 +201,28 @@ internal class PinyinSegmenter( companion object { const val MAX_SYLLABLE_LENGTH = 6 const val MAX_SYLLABLES = 10 + + /** + * The `v` spelling of [syllable], or null when it has none. + * + * The reference's syllable trie carries these as ordinary matches of + * the syllable — `jv`, `jvan`, `jve`, `jvn` for `ju`, `juan`, `jue`, + * `jun`, the same for q/x/y, and `lve`/`nve` for `lue`/`nue` — with + * both mask bytes zero, so they match whatever the fuzzy settings say + * (`doubaoime-teardown/f_syllable-variants.tsv`, the C=0 D=0 rows whose + * spelling differs from the syllable). In pinyin the `u` after j/q/x/y + * is the ü the keyboard has no key for, which is exactly where the + * reference spells it `v`. + */ + private fun vSpelling(syllable: String): String? { + val initial = syllable.getOrNull(0) ?: return null + if (initial in "jqxy" && syllable.getOrNull(1) == 'u') { + return initial + "v" + syllable.substring(2) + } + if (initial in "ln" && syllable.endsWith("ue")) { + return syllable.dropLast(2) + "ve" + } + return null + } } } diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index 8de4d6e6..59c0fd59 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -156,6 +156,43 @@ class PinyinEngineDataTest { assertEquals("阿德里安安德烈夫", engine.candidates("adelianandeliefu").firstOrNull()) } + @Test + fun theVSpellingOfUReachesTheSameWords() { + val engine = loadEngine() + // Pinyin writes the ü the 26 keys have no letter for as v, and the + // reference's syllable trie carries those spellings as ordinary + // matches of the syllable (f_syllable-variants.tsv, the rows with both + // mask bytes zero): jv/jvan/jve/jvn stand at ju/juan/jue/jun, the same + // for q/x/y. Typing jv used to come back empty — the single letters are + // syllables of the catalogue, so j+v was a reading, but no word spells + // it. + for ((spelling, plain) in listOf( + "jv" to "ju", + "jvan" to "juan", + "jve" to "jue", + "jvn" to "jun", + "qv" to "qu", + "qvan" to "quan", + "qve" to "que", + "qvn" to "qun", + "xv" to "xu", + "xvan" to "xuan", + "xve" to "xue", + "xvn" to "xun", + "yv" to "yu", + "yvan" to "yuan", + "yve" to "yue", + "yvn" to "yun", + )) { + assertEquals( + "candidates of $spelling differ from $plain", + engine.candidates(plain), + engine.candidates(spelling), + ) + assertTrue("no candidates for $spelling", engine.candidates(spelling).isNotEmpty()) + } + } + @Test fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { val engine = loadEngine() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9ff56153..1fb6bb6e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -308,3 +308,6 @@ Add new entries at the end. - **The sentence pass reads words off the lattice, one start position at a time.** The reference's lattice is a node per position and an edge per syllable match there, and it reads its words off those edges: `BuildLattice` walks the syllable trie from each reachable position and `AddEdge` records one edge per match, so the same window is never looked up twice (`doubaoime-teardown/ENGINE-SPEC.md` §1.3 "pass1: 音节边(对每个可达位置独立从 Trie root 走后缀)"). `PinyinEngine.decode` read them off whole paths instead — every span of every path the segmenter returned, each span looked up once per path that crosses it — and the paths a sentence spans are many: 1352 for `wohenhaoxiexie`, 820 for `adelianandeliefu`, 360 for `zhongguogongchandang` (measured uncapped, 176 for `jintianxingqiwu`). The pass now walks the lattice from each start position, carrying a reading that grows one syllable edge at a time and stops where the lattice stops, `MAX_WORD_SYLLABLES` syllables in. *Reason:* the reference's own shape is the cheaper one — a word lookup per (start, reading) rather than per (path, span) — and the port had no reason to keep the more expensive one once the sentence pass became reachable from the bar. The comparison against the reference lexicon's order is unchanged at 50,000/50,000 first candidates, the 14 sentence cases in `PinyinEngineDataTest` all come back first, and a query costs 43–171µs when no sentence runs against 758µs–1.38ms when one does (`wohenhaoxiexie` is the longest the bar ever composes), the 16ms of one frame, or 57µs per reading over the 50,000-reading sample. +- **A syllable is also spelled the way the reference's trie spells it.** + The reference's syllable trie carries the ü the 26 keys have no letter for as `v`: `jv`, `jvan`, `jve`, `jvn` stand at `ju`, `juan`, `jue`, `jun`, `qv`/`xvan`/`yvn` and the rest of the q/x/y families do the same, and `lve`/`nve` join `lue`/`nue` (`doubaoime-teardown/f_syllable-variants.tsv`, the rows with `A=3` whose spelling differs from their `s1_name` and both masks zero). Those rows are *always* matchable — `C=0, D=0` is outside both the variant layer (`cfg[124]`) and the classic fuzzy layer (`cfg[120]`), so no setting gates them, unlike the fuzzy pairs of §1.5. This port's catalogue is the map's canonical names only (`syllable-id-map.tsv`: `ju`, `lue`, `lv`), so `jv` had no syllable edge and no word spells `j` + `v`: `candidates("jv")` came back empty, and with it `xv`, `xvan`, `jve`, `qvn`, `yvn`. `PinyinSegmenter` now derives the `v` spelling of every syllable the reference has one for and adds it as an ordinary (non-fuzzy) edge, in the lattice and in the trailing-fragment scan alike. + *Reason:* v is how a user without an ü key writes these syllables, and the reference answers them with the canonical syllable's own words rather than with a fuzzy guess, so the port must too — the edge carries no fuzzy flag and takes no score penalty. The derivation follows the reference's own list exactly: the `u` after j/q/x/y is that ü (`ju`, `juan`, `jue`, `jun` — every j/q/x/y syllable has one), and `lue`/`nue` are the l/n pair, which the extracted table confirms to the row. All 16 j/q/x/y spellings now return the canonical spelling's own candidate list (`PinyinEngineDataTest.theVSpellingOfUReachesTheSameWords`), and the 50,000-reading comparison is unchanged at 50,000/50,000. From 9d6f480e2b2f34355c9d06d822b2530e6134b070 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:17:45 +0800 Subject: [PATCH 62/94] Lead the 26-key bar with the words the user picked before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's first conversion stage is the user's own dictionary (ConvertByUserDict is the first call of the PinYinConvert pipeline and nothing gates it), ahead of the system dictionary and everything else. This port kept the local learner quiet wherever librime answers, which held while librime's order was the visible one; on 26-key the built-in engine owns that order and librime's answer is merged behind it, so a picked word could never rise — 数理 stayed behind 梳理 however often it was picked. candidatesFor now leads the 26-key list with the words recorded for the reading, and a 26-key commit records into that learner. Three picks still gate a word's appearance, password fields still record nothing, and the store stays inside the APK. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/LocalVoiceImeService.kt | 38 ++++++++++++++----- docs/DECISIONS.md | 3 ++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddda6b8c..c4ab8a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 - 26 键上把 ü 写成 v 的拼法现在能打出对应的音节:参考实现的音节表把 `jv`/`jvan`/`jve`/`jvn` 当作 `ju`/`juan`/`jue`/`jun` 自己的拼写(`q`、`x`、`y` 三族同理,另有 `lve`/`nve` 对 `lue`/`nue`),这几条与模糊音设置无关(参考里它们的两个掩码字节都是 0),而我们的音节表只有规范拼写,于是打 `jv`、`xv`、`xvan`、`yvn`、`jve`、`qvn` 一个候选都没有——26 键上没有 ü 这个字母,v 就是它惯用的写法。现在这些拼法与规范拼法给出同一列候选。 - 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 - 整句拼音打全时,候选栏第一位改为整句本身:像 `jintianxingqiwu` 这样的读音,词库里没有对应的整词,以前候选栏给的是「今天心情」这类只借了前半段的词,现在先给「今天星期五」(今天+星期五)。词库里真有这个词的读音不变(`pengke` 仍是「朋克」在前)。尾音还没打完的拼音(如 `jintianxingqiw`)不出整句,仍按已打全的音节给词。参考实现本来就是每条读音都过一遍整句转换、候选栏以整句打头。 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index c9b977ab..26ed5a03 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -265,7 +265,19 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can mode: KeyboardMode, composition: String, fuzzy: Boolean, - ): List = candidatePipeline.candidatesFor(mode, composition, fuzzy) + ): List { + val normal = candidatePipeline.candidatesFor(mode, composition, fuzzy) + if (mode != KeyboardMode.PINYIN_26) return normal + // The reference's first conversion stage is the user's own dictionary, + // ahead of the system one and of every other source it consults + // (`ConvertByUserDict`, the first call of the 26-key pipeline; + // `doubaoime-teardown/ENGINE-SPEC.md` §3.1). With the built-in engine + // owning the 26-key order, librime's userdb can only ever append behind + // that order — so the words the user picked for this reading before are + // what has to lead it. + val learned = learnedPinyinCandidates(composition) + return if (learned.isEmpty()) normal else (learned + normal).distinct().take(MAX_CANDIDATES) + } override fun resolveNineKey( digits: String, @@ -1634,11 +1646,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can composition: String, mode: KeyboardMode, ): List { - val normal = candidatePipeline.candidatesFor( - mode, - composition, - state.fuzzyPinyinEnabled, - ) + val normal = candidatesFor(mode, composition, state.fuzzyPinyinEnabled) if (mode == KeyboardMode.STROKE) { // Characters the user picked for these strokes before come first. val learned = strokeLearningKey(composition) @@ -1659,6 +1667,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return (quickPhrases + normal).distinct().take(MAX_CANDIDATES) } + /** The words the user picked for this reading before ([candidatesFor] leads with them). */ + private fun learnedPinyinCandidates(composition: String): List = + if (allowsPersonalizedLearning()) UserPhraseRepository.candidatesFor(composition) else emptyList() + /** The extra learner is only a repeated-choice fallback while Rime is unavailable. */ private fun immediateCandidates(composition: String, fallback: List): List { val learned = if (!rime.isReady) { @@ -1933,14 +1945,16 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private fun finishCandidateCommit(composition: String, committed: String) { if (committed.isEmpty()) return - // librime owns normal learning through its userdb. Keep the old local - // repository only as an offline fallback; never run two unconditional - // ranking systems over the same successful native selection. + // librime owns learning wherever its own order is the visible one, and + // there the local repository stays the offline fallback; never run two + // unconditional ranking systems over the same successful selection. + // The 26-key engine order is the exception: librime can only append + // behind it, so the local learner is the one that ranks there. if (state.keyboardMode == KeyboardMode.STROKE) { // Stroke input has no librime session, so it always learns locally. strokeLearningKey(composition)?.takeIf { allowsPersonalizedLearning() } ?.let { UserPhraseRepository.record(it, committed) } - } else if (!rime.isReady && allowsPersonalizedLearning()) { + } else if (allowsPersonalizedLearning() && (!rime.isReady || engineOwnsPinyinOrder())) { UserPhraseRepository.record(composition, committed) } // An English word is followed by a space, as space and a suggestion @@ -1966,6 +1980,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private fun allowsPersonalizedLearning(): Boolean = personalizedLearningAllowed(state.passwordField, state.editorInfo?.imeOptions) + /** True when the built-in engine's own order is what the 26-key bar shows. */ + private fun engineOwnsPinyinOrder(): Boolean = + state.keyboardMode == KeyboardMode.PINYIN_26 && candidatePipeline.usesPinyinEngine + /** Remove only voice-owned composing text before a manual edit takes ownership. */ private fun prepareForManualInput() { keyboardView?.cancelVoiceForManualInput() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1fb6bb6e..4d7a1190 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -311,3 +311,6 @@ Add new entries at the end. - **A syllable is also spelled the way the reference's trie spells it.** The reference's syllable trie carries the ü the 26 keys have no letter for as `v`: `jv`, `jvan`, `jve`, `jvn` stand at `ju`, `juan`, `jue`, `jun`, `qv`/`xvan`/`yvn` and the rest of the q/x/y families do the same, and `lve`/`nve` join `lue`/`nue` (`doubaoime-teardown/f_syllable-variants.tsv`, the rows with `A=3` whose spelling differs from their `s1_name` and both masks zero). Those rows are *always* matchable — `C=0, D=0` is outside both the variant layer (`cfg[124]`) and the classic fuzzy layer (`cfg[120]`), so no setting gates them, unlike the fuzzy pairs of §1.5. This port's catalogue is the map's canonical names only (`syllable-id-map.tsv`: `ju`, `lue`, `lv`), so `jv` had no syllable edge and no word spells `j` + `v`: `candidates("jv")` came back empty, and with it `xv`, `xvan`, `jve`, `qvn`, `yvn`. `PinyinSegmenter` now derives the `v` spelling of every syllable the reference has one for and adds it as an ordinary (non-fuzzy) edge, in the lattice and in the trailing-fragment scan alike. *Reason:* v is how a user without an ü key writes these syllables, and the reference answers them with the canonical syllable's own words rather than with a fuzzy guess, so the port must too — the edge carries no fuzzy flag and takes no score penalty. The derivation follows the reference's own list exactly: the `u` after j/q/x/y is that ü (`ju`, `juan`, `jue`, `jun` — every j/q/x/y syllable has one), and `lue`/`nue` are the l/n pair, which the extracted table confirms to the row. All 16 j/q/x/y spellings now return the canonical spelling's own candidate list (`PinyinEngineDataTest.theVSpellingOfUReachesTheSameWords`), and the 50,000-reading comparison is unchanged at 50,000/50,000. +- **The word the user picked for a reading leads that reading's candidates.** + The reference's first conversion stage is the user's own dictionary: `ConvertByUserDict` is the first `bl` of the 26-key pipeline and the condition column of its table is empty — nothing consults a setting or a mode first — with the system dictionary, the extension and enhanced dictionaries, contacts, the name-after-@ list, cloud hot words, the field dictionary, the big dictionary, the sentence pass, prediction and the common phrases all called after it (`doubaoime-teardown/ENGINE-SPEC.md` §3.1, rows 1-3 and 11-18 of the `PinYinConvert` 0x46bd00 call list). This port kept the local learner quiet wherever librime answers — "librime owns normal learning through its userdb" — which held while librime's own order was the visible one. On 26-key the built-in engine owns that order now: the view renders the resolver's list and the service merges librime's answer *behind* it, so a word librime learned, and a word recorded in `UserPhraseRepository`, both sat below the engine's own ranking forever — picking 数理 for `shuli` any number of times left 梳理 first. `LocalVoiceImeService.candidatesFor` now leads the 26-key list with the words recorded for that reading, and a 26-key commit records into the same learner. + *Reason:* the reference puts the user's own choices ahead of every dictionary it has, and on this key the engine's frequency order is the only thing that could have outranked them — the two ordering systems are not in competition here, since librime's own answer is appended after the engine list and can only fill the tail. The learner's existing policy is kept as it stands: three recorded picks before a word surfaces, nothing recorded in a password field or where personalization is off, and the store stays local to the APK. Verified live on the emulator (`build/volume-data/probe_learning.py`): `shuli` before the picks is 梳理/树立/书里/数理, and 数理 comes back first once it has been picked three times. From 33dc757388ca17aec85a002f1f9dfaea6d95fc06 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:24:08 +0800 Subject: [PATCH 63/94] Let one initial and one final fuzzy pair apply together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference applies the whole enabled mask to a syllable (ENGINE-SPEC §1.5: zuan + C8 + D2 is read from the input zhuang), and its syllable table bounds that at one pair from each class: the two-bit rows of f_syllable-variants.tsv are always one initial + one final rule (ch_c+eng_ong, n_l+in_ing, z_zh+an_ang ...), never two of a class and never three bits. This port applied each rule on its own, so a spelling two substitutions away was unreachable: with z = zh and an = ang on, zhang gave 脏 (zang) but not 赞 (zan), and ning gave nothing spelled lin. fuzzyVariants composes one initial pair with one final pair for the lattice and the fragment scan alike. For the 11 switches this port has, the generated spellings now cover every pair the reference has (360 of them, plus the six bare-letter pairs of this catalogue). First-candidate agreement stays 50000/50000. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/engine/PinyinSegmenter.kt | 42 +++++++++++++++++-- .../slacker/openime/PinyinEngineDataTest.kt | 42 +++++++++++++++++++ docs/DECISIONS.md | 3 ++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ab8a62..d499bed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。 - 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 - 26 键上把 ü 写成 v 的拼法现在能打出对应的音节:参考实现的音节表把 `jv`/`jvan`/`jve`/`jvn` 当作 `ju`/`juan`/`jue`/`jun` 自己的拼写(`q`、`x`、`y` 三族同理,另有 `lve`/`nve` 对 `lue`/`nue`),这几条与模糊音设置无关(参考里它们的两个掩码字节都是 0),而我们的音节表只有规范拼写,于是打 `jv`、`xv`、`xvan`、`yvn`、`jve`、`qvn` 一个候选都没有——26 键上没有 ü 这个字母,v 就是它惯用的写法。现在这些拼法与规范拼法给出同一列候选。 - 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index 79a592c6..44b35b16 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -50,9 +50,9 @@ internal class PinyinSegmenter( if (spelling !in nameToId) add(spelling, id, isFuzzy = false) } if (fuzzyRules.isNotEmpty()) { + val rules = fuzzyRules.toList() syllables.forEachIndexed { id, name -> - for (rule in fuzzyRules) { - val variant = rule.variant(name) ?: continue + for (variant in fuzzyVariants(name, rules)) { if (variant in nameToId) add(variant, id, isFuzzy = true) } } @@ -82,9 +82,9 @@ internal class PinyinSegmenter( if (spelling !in nameToId) scan += Triple(spelling, id, false) } if (fuzzyRules.isNotEmpty()) { + val rules = fuzzyRules.toList() syllables.forEachIndexed { id, name -> - for (rule in fuzzyRules) { - val variant = rule.variant(name) ?: continue + for (variant in fuzzyVariants(name, rules)) { if (variant in nameToId) scan += Triple(variant, id, true) } } @@ -202,6 +202,40 @@ internal class PinyinSegmenter( const val MAX_SYLLABLE_LENGTH = 6 const val MAX_SYLLABLES = 10 + /** + * Every spelling of [syllable] the enabled [rules] produce. + * + * One initial pair and one final pair apply together, the way the + * reference's masks do: a row of its syllable table holds two classic + * bits exactly when one is an initial pair and the other a final one + * (`ch_c+eng_ong`, `n_l+in_ing`, `f_h+an_ang`, ... — every two-bit + * combination in `f_syllable-variants.tsv` under the 15 classic pairs), + * never two initials or two finals. So with `z = zh` and `an = ang` + * both on, 脏 `zang` answers to `zhang` and `zhan`, and the reference + * says the same: the typed spelling is the canonical syllable with its + * whole mask applied (`doubaoime-teardown/ENGINE-SPEC.md` §1.5). + */ + private fun fuzzyVariants(syllable: String, rules: List): List { + val out = LinkedHashSet() + val initials = ArrayList(2) + for (rule in rules) { + if (!rule.initial) continue + val variant = rule.variant(syllable) ?: continue + initials += variant + out += variant + } + for (rule in rules) { + if (rule.initial) continue + rule.variant(syllable)?.let { out += it } + // The same rule reaches the syllable the initial pair made: + // `ning` is `lin` with n/l and in/ing both on, not one of them. + for (initial in initials) { + rule.variant(initial)?.let { out += it } + } + } + return out.toList() + } + /** * The `v` spelling of [syllable], or null when it has none. * diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index 59c0fd59..8b4359d4 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -193,6 +193,48 @@ class PinyinEngineDataTest { } } + @Test + fun twoFuzzyRulesApplyToTheSameSyllable() { + val engine = loadEngine() + // The reference's syllable table carries a spelling with two classic + // mask bits when one is an initial pair and the other a final one + // (ch_c+eng_ong, n_l+in_ing, z_zh+an_ang ...) and never otherwise, so + // a spelling one substitution away needs both switches: cheng is 岑 + // cen (c→ch, then en→eng) and ning is 林 lin (l→n, then in→ing). + // One rule at a time answered neither. + val cCheng = setOf(FuzzyRule.C_CH, FuzzyRule.EN_ENG) + assertTrue( + "岑 missing from ${engine.candidates("cheng", fuzzyRules = cCheng).take(8)}", + engine.candidates("cheng", fuzzyRules = cCheng).contains("岑"), + ) + assertTrue( + "岑 offered from cheng with only ch = c on", + engine.candidates("cheng", fuzzyRules = setOf(FuzzyRule.C_CH)).none { it == "岑" }, + ) + + val nLing = setOf(FuzzyRule.N_L, FuzzyRule.IN_ING) + assertTrue( + "林 missing from ${engine.candidates("ning", fuzzyRules = nLing).take(8)}", + engine.candidates("ning", fuzzyRules = nLing).contains("林"), + ) + assertTrue( + "林 offered from ning with only n = l on", + engine.candidates("ning", fuzzyRules = setOf(FuzzyRule.N_L)).none { it == "林" }, + ) + + // The final pair applies to the syllable the initial pair made: zhang + // is 赞 zan with z = zh and an = ang on. + val zAng = setOf(FuzzyRule.Z_ZH, FuzzyRule.AN_ANG) + assertTrue( + "赞 missing from ${engine.candidates("zhang", fuzzyRules = zAng).take(8)}", + engine.candidates("zhang", fuzzyRules = zAng).contains("赞"), + ) + assertTrue( + "赞 offered from zhang with only z = zh on", + engine.candidates("zhang", fuzzyRules = setOf(FuzzyRule.Z_ZH)).none { it == "赞" }, + ) + } + @Test fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { val engine = loadEngine() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 4d7a1190..307b855b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -314,3 +314,6 @@ Add new entries at the end. - **The word the user picked for a reading leads that reading's candidates.** The reference's first conversion stage is the user's own dictionary: `ConvertByUserDict` is the first `bl` of the 26-key pipeline and the condition column of its table is empty — nothing consults a setting or a mode first — with the system dictionary, the extension and enhanced dictionaries, contacts, the name-after-@ list, cloud hot words, the field dictionary, the big dictionary, the sentence pass, prediction and the common phrases all called after it (`doubaoime-teardown/ENGINE-SPEC.md` §3.1, rows 1-3 and 11-18 of the `PinYinConvert` 0x46bd00 call list). This port kept the local learner quiet wherever librime answers — "librime owns normal learning through its userdb" — which held while librime's own order was the visible one. On 26-key the built-in engine owns that order now: the view renders the resolver's list and the service merges librime's answer *behind* it, so a word librime learned, and a word recorded in `UserPhraseRepository`, both sat below the engine's own ranking forever — picking 数理 for `shuli` any number of times left 梳理 first. `LocalVoiceImeService.candidatesFor` now leads the 26-key list with the words recorded for that reading, and a 26-key commit records into the same learner. *Reason:* the reference puts the user's own choices ahead of every dictionary it has, and on this key the engine's frequency order is the only thing that could have outranked them — the two ordering systems are not in competition here, since librime's own answer is appended after the engine list and can only fill the tail. The learner's existing policy is kept as it stands: three recorded picks before a word surfaces, nothing recorded in a password field or where personalization is off, and the store stays local to the APK. Verified live on the emulator (`build/volume-data/probe_learning.py`): `shuli` before the picks is 梳理/树立/书里/数理, and 数理 comes back first once it has been picked three times. +- **Two fuzzy pairs apply to the same syllable, one from each class.** + The reference's fuzzy matching is not one substitution at a time: the typed spelling is the canonical syllable with the *whole* enabled mask applied (`doubaoime-teardown/ENGINE-SPEC.md` §1.5, whose own example is `zuan` + C8 (zh↔z) + D2 (uan↔uang) read as the input `zhuang`). Its syllable table shows how far that goes: of the 1,924 rows of `f_syllable-variants.tsv`, the ones carrying two classic mask bits are exactly the pairs of one initial rule and one final rule — `ch_c+eng_ong` (12 rows), `n_l+in_ing` (8), `z_zh+an_ang` (8), and every other combination of the two classes — with no row combining two initials or two finals, and no row carrying three. This port applied each enabled rule to the syllable on its own, so a spelling one substitution away from a syllable was only ever reached from that syllable's own form: with `z = zh` and `an = ang` both on, `zhang` reached 脏 (zang, one substitution) but not 赞 (zan, two), and `ning` reached nothing spelled lin. `PinyinSegmenter.fuzzyVariants` now composes one initial pair with one final pair, the second applied to the result of the first, for the lattice and the fragment scan alike. + *Reason:* the user who turns on `z = zh` turns on `an = ang` because they confuse both, and a spelling that mixes the two is what they type; the reference answers it and this port did not. The composition is bounded where the reference bounds it — one pair from each class, no chains of two finals (`ceng` → `cong` → `cun` is not a spelling of 岑, and the reference has no such row) — which the table fixes to the row. Generating every spelling for every syllable the reference generates for the 11 switches this port has leaves nothing missing: 360 reference pairs against our 366, the six extra all being the bare letters this port's catalogue calls syllables (`f`/`h`, `l`/`n`, `l`/`r`), which the reference's trie holds as letter entries rather than syllables. The 50,000-reading comparison is unchanged at 50,000/50,000 first candidates. From c20409116227e34497207cc74a4298bab81c6fdf Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:26:50 +0800 Subject: [PATCH 64/94] Let a fuzzy spelling be one the catalogue has no syllable for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's syllable trie holds spellings that are no syllables of its table: 床 chuang answers to cuang with ch = c on, 编 bian to biang, 端 duan to duang (f_syllable-variants.tsv, the rows whose mask bytes are C9/D9). PinyinSegmenter required `variant in nameToId` before adding an edge, so 154 of the reference's rows under the eleven switches this port has were dropped. The edge is now added for any spelling the rules produce. The reference-only count for the eleven switches drops from 154 to 0; the nine spellings still ours alone are the bare-letter pairs (b = p and the like), which the reference spells through its letter entries. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- .../llc/slacker/openime/engine/PinyinSegmenter.kt | 11 +++++------ .../llc/slacker/openime/PinyinEngineDataTest.kt | 15 +++++++++++++++ docs/DECISIONS.md | 1 + 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d499bed0..dfa5f114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。 +- 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 - 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 - 26 键上把 ü 写成 v 的拼法现在能打出对应的音节:参考实现的音节表把 `jv`/`jvan`/`jve`/`jvn` 当作 `ju`/`juan`/`jue`/`jun` 自己的拼写(`q`、`x`、`y` 三族同理,另有 `lve`/`nve` 对 `lue`/`nue`),这几条与模糊音设置无关(参考里它们的两个掩码字节都是 0),而我们的音节表只有规范拼写,于是打 `jv`、`xv`、`xvan`、`yvn`、`jve`、`qvn` 一个候选都没有——26 键上没有 ü 这个字母,v 就是它惯用的写法。现在这些拼法与规范拼法给出同一列候选。 - 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 diff --git a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt index 44b35b16..43edd330 100644 --- a/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt +++ b/app/src/main/java/llc/slacker/openime/engine/PinyinSegmenter.kt @@ -52,9 +52,10 @@ internal class PinyinSegmenter( if (fuzzyRules.isNotEmpty()) { val rules = fuzzyRules.toList() syllables.forEachIndexed { id, name -> - for (variant in fuzzyVariants(name, rules)) { - if (variant in nameToId) add(variant, id, isFuzzy = true) - } + // A fuzzy spelling need not be a syllable of the catalogue: + // 闯 chuang is typed cuang, and the reference's trie holds that + // path for the syllable alone. + for (variant in fuzzyVariants(name, rules)) add(variant, id, isFuzzy = true) } } edgeIds = HashMap(ids.size * 2) @@ -84,9 +85,7 @@ internal class PinyinSegmenter( if (fuzzyRules.isNotEmpty()) { val rules = fuzzyRules.toList() syllables.forEachIndexed { id, name -> - for (variant in fuzzyVariants(name, rules)) { - if (variant in nameToId) scan += Triple(variant, id, true) - } + for (variant in fuzzyVariants(name, rules)) scan += Triple(variant, id, true) } } scanEdges = scan diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index 8b4359d4..1a457d59 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -235,6 +235,21 @@ class PinyinEngineDataTest { ) } + @Test + fun aFuzzySpellingNeedNotBeASyllable() { + val engine = loadEngine() + // cuang is no syllable of the catalogue, but it is what the reference's + // trie holds for 床 chuang under ch = c (f_syllable-variants.tsv row + // chuang -> cuang, mask C9). Requiring the variant to be a syllable of + // its own dropped it, and with it every such spelling. + val chC = setOf(FuzzyRule.C_CH) + val chuang = engine.candidates("cuang", fuzzyRules = chC) + assertTrue("no candidates for cuang: $chuang", chuang.isNotEmpty()) + assertTrue("床 missing from $chuang", chuang.contains("床")) + // The exact spelling keeps the lead. + assertEquals(engine.candidates("chuang").first(), engine.candidates("chuang", fuzzyRules = chC).first()) + } + @Test fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { val engine = loadEngine() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 307b855b..cc69fc84 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -317,3 +317,4 @@ Add new entries at the end. - **Two fuzzy pairs apply to the same syllable, one from each class.** The reference's fuzzy matching is not one substitution at a time: the typed spelling is the canonical syllable with the *whole* enabled mask applied (`doubaoime-teardown/ENGINE-SPEC.md` §1.5, whose own example is `zuan` + C8 (zh↔z) + D2 (uan↔uang) read as the input `zhuang`). Its syllable table shows how far that goes: of the 1,924 rows of `f_syllable-variants.tsv`, the ones carrying two classic mask bits are exactly the pairs of one initial rule and one final rule — `ch_c+eng_ong` (12 rows), `n_l+in_ing` (8), `z_zh+an_ang` (8), and every other combination of the two classes — with no row combining two initials or two finals, and no row carrying three. This port applied each enabled rule to the syllable on its own, so a spelling one substitution away from a syllable was only ever reached from that syllable's own form: with `z = zh` and `an = ang` both on, `zhang` reached 脏 (zang, one substitution) but not 赞 (zan, two), and `ning` reached nothing spelled lin. `PinyinSegmenter.fuzzyVariants` now composes one initial pair with one final pair, the second applied to the result of the first, for the lattice and the fragment scan alike. *Reason:* the user who turns on `z = zh` turns on `an = ang` because they confuse both, and a spelling that mixes the two is what they type; the reference answers it and this port did not. The composition is bounded where the reference bounds it — one pair from each class, no chains of two finals (`ceng` → `cong` → `cun` is not a spelling of 岑, and the reference has no such row) — which the table fixes to the row. Generating every spelling for every syllable the reference generates for the 11 switches this port has leaves nothing missing: 360 reference pairs against our 366, the six extra all being the bare letters this port's catalogue calls syllables (`f`/`h`, `l`/`n`, `l`/`r`), which the reference's trie holds as letter entries rather than syllables. The 50,000-reading comparison is unchanged at 50,000/50,000 first candidates. + The variant no longer has to be a syllable of the catalogue either: a fuzzy spelling is a path of the reference's trie, not an entry of its syllable table, and 154 of the reference's rows under the switches this port has spell a syllable that is no syllable itself — 床 chuang typed `cuang`, 编 bian typed `biang`, 端 duan typed `duang`. `PinyinSegmenter` required `variant in nameToId` before adding the edge, which dropped every one of them; the edge is now added for any spelling the rules produce. From 39b674dca7b738e4c7762e307ffabd2bd01bb5fc Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:32:40 +0800 Subject: [PATCH 65/94] Carry the four fuzzy pairs the reference has beyond the eleven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's classic fuzzy layer is fifteen pairs, one per bit of cfg[120]: six initials and nine finals (ENGINE-SPEC.md §1.5). This port shipped the eleven mainstream IMEs offer, so typing a spelling that needs one of the other four returned nothing at all. Each of the four is generated the way the reference spells it, checked row by row against f_syllable-variants.tsv: - un = ong and eng = ong are final swaps over the syllable table, 35 and 37 reference rows matched with none left and none extra (chun/chong, deng/dong, and the j/q/x/y and bare-letter forms the reference closes over: jiong/jiun, yong/yun, yeng, weng/wong). - ei = ui is not a final swap but the single pair fei/hui (2 rows). The reference reaches hei only where f = h is on as well -- its hui -> hei row carries both bits -- and the port composes them the same way. - huang = wang is the whole-syllable pair the reference has (2 rows), not an h/w initial rule its mask does not contain. With the four in, the reference's pure-bit rows under our fifteen pairs come out at ref-only 0. All four default off like the rest. librime takes their derive rules (verified on the emulator: with every pair on, dong offers 灯, chun 冲, wang 黄, hui 飞), and the engine reaches the words of the syllable each pair points at (zongjing -> 尊敬, dongguang -> 灯光, huiji -> 飞机, wangse -> 黄色). Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/FuzzyRulesInstrumentedTest.kt | 6 +++++ .../llc/slacker/openime/core/FuzzyRule.kt | 21 ++++++++++++--- .../openime/panel/SettingsPanelController.kt | 4 +++ .../java/llc/slacker/openime/FuzzyRuleTest.kt | 26 +++++++++++++++++-- .../slacker/openime/PinyinEngineDataTest.kt | 25 ++++++++++++++++++ docs/DECISIONS.md | 3 +++ 7 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa5f114..09675278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 +- 模糊音补上参考实现有、我们没有的四组:`un = ong`、`ei = ui`、`huang = wang`、`eng = ong`。参考的音节表按 15 组经典模糊音判定(`f_syllable-variants.tsv` 掩码的 D3–D6 位),我们只做了常见的前 11 组,这几组打开后一个候选都不会变。四组的口径都按参考自己的表逐条核对:`un`/`ong`(`chun` → `chong`,含 `jiong`/`jiun`、`yong`/`yun` 这类)与参考的 35 条行、`eng`/`ong`(`deng` → `dong`,含 `yeng`、`weng`/`wong`)与参考的 37 条行完全一致;`ei = ui` 在参考里只覆盖 `fei`/`hui` 一对(`hei` 要同时打开「f = h」才跟着进来,参考自己那两行也是两个掩码位一起写的);`huang = wang` 是整对音节。设置页新增这四组开关,仍默认关闭。 - 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 - 26 键上把 ü 写成 v 的拼法现在能打出对应的音节:参考实现的音节表把 `jv`/`jvan`/`jve`/`jvn` 当作 `ju`/`juan`/`jue`/`jun` 自己的拼写(`q`、`x`、`y` 三族同理,另有 `lve`/`nve` 对 `lue`/`nue`),这几条与模糊音设置无关(参考里它们的两个掩码字节都是 0),而我们的音节表只有规范拼写,于是打 `jv`、`xv`、`xvan`、`yvn`、`jve`、`qvn` 一个候选都没有——26 键上没有 ü 这个字母,v 就是它惯用的写法。现在这些拼法与规范拼法给出同一列候选。 - 整句转换改为按参考实现的方式读词:参考对每个可达位置各自从音节表走一遍,每个位置只出这一处的音节边;我们原来是按整条切分路径读词,同一条读音会被跨过它的每条路径各查一次(`wohenhaoxiexie` 这类句子的切分路径有上千条)。现在从每个起始位置沿音节格走,一次音节边一次查词,走到音节格走不动为止。整句结果不变(14 个用例仍全部首位,随机 5 万个读音首选词一致仍为 50000/50000),一次查询 43–171µs,走整句时 758µs–1.38ms(一帧 16ms)。 diff --git a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt index 9ab17e94..8b021443 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt @@ -64,6 +64,12 @@ class FuzzyRulesInstrumentedTest { redeploy(rime) assertTrue("zi offers 知", "知" in rime.candidates("zi")) assertTrue("shan offers 上", "上" in rime.candidates("shan")) + // The four pairs the reference carries beyond the eleven: librime + // has to take their rules too, not only the Kotlin fallback. + assertTrue("eng = ong: dong offers 灯", "灯" in rime.candidates("dong").take(40)) + assertTrue("un = ong: chun offers 冲", "冲" in rime.candidates("chun").take(40)) + assertTrue("huang = wang: wang offers 黄", "黄" in rime.candidates("wang").take(40)) + assertTrue("ei = ui: hui offers 飞", "飞" in rime.candidates("hui").take(40)) // Back to the shipped defaults: nothing fuzzy even with 模糊音 on. ImeSettingsRepository.saveFuzzyRules(context, FuzzyRule.DEFAULTS) diff --git a/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt b/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt index d2aca272..69522e68 100644 --- a/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt +++ b/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt @@ -3,9 +3,8 @@ package llc.slacker.openime.core /** * One fuzzy-pinyin pair the user can switch on or off on its own. * - * The eleven pairs are the ones mainstream IMEs offer (Sogou, Baidu, iFlytek, - * Microsoft Pinyin): six initials and five finals. All start off - * ([defaultOn]); the user turns on the ones their accent needs. + * Six initials and nine finals. All start off ([defaultOn]); the user turns on + * the ones their accent needs. * * Each pair works both ways and is defined twice, kept equal by tests: [rime] * is the librime speller algebra (written to openime_fuzzy.yaml), [variants] @@ -29,6 +28,10 @@ enum class FuzzyRule( IN_ING("in_ing", "in = ing", false, false, listOf("derive/in\$/ing/", "derive/ing\$/in/")), IAN_IANG("ian_iang", "ian = iang", false, false, listOf("derive/ian\$/iang/", "derive/iang\$/ian/")), UAN_UANG("uan_uang", "uan = uang", false, false, listOf("derive/uan\$/uang/", "derive/uang\$/uan/")), + UN_ONG("un_ong", "un = ong", false, false, listOf("derive/ong\$/un/", "derive/un\$/ong/")), + EI_UI("ei_ui", "ei = ui", false, false, listOf("derive/^fei\$/hui/", "derive/^hui\$/fei/")), + HUANG_WANG("huang_wang", "huang = wang", false, false, listOf("derive/^huang\$/wang/", "derive/^wang\$/huang/")), + ENG_ONG("eng_ong", "eng = ong", false, false, listOf("derive/ong\$/eng/", "derive/eng\$/ong/")), ; /** The other spelling of [syllable] under this pair, or null when it does not apply. */ @@ -48,6 +51,18 @@ enum class FuzzyRule( IN_ING -> swapFinal(syllable, "ing", "in") IAN_IANG -> swapFinal(syllable, "iang", "ian") UAN_UANG -> swapFinal(syllable, "uang", "uan") + UN_ONG -> swapFinal(syllable, "ong", "un") + // ei = ui is the one pair the reference spells over whole syllables and + // not over a final: its mask rows are fei <-> hui alone, and hei joins + // them only where f = h is on as well (f_syllable-variants.tsv, the + // D4 rows; the huei rows are hui under the uei variant). + EI_UI -> if (syllable == "fei") "hui" else if (syllable == "hui") "fei" else null + HUANG_WANG -> when (syllable) { + "huang" -> "wang" + "wang" -> "huang" + else -> null + } + ENG_ONG -> swapFinal(syllable, "ong", "eng") } companion object { 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 0443f3a9..c04cbc2a 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -1115,6 +1115,10 @@ internal class SettingsPanelController( FuzzyRule.IN_ING to "心 xin · 星 xing", FuzzyRule.IAN_IANG to "先 xian · 香 xiang", FuzzyRule.UAN_UANG to "关 guan · 光 guang", + FuzzyRule.UN_ONG to "春 chun · 冲 chong", + FuzzyRule.EI_UI to "飞 fei · 灰 hui", + FuzzyRule.HUANG_WANG to "黄 huang · 王 wang", + FuzzyRule.ENG_ONG to "灯 deng · 东 dong", ) } } diff --git a/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt b/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt index 4749bdbd..b5be964a 100644 --- a/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt +++ b/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt @@ -36,8 +36,8 @@ class FuzzyRuleTest { } @Test - fun elevenPairsAllStartOff() { - assertEquals(11, FuzzyRule.entries.size) + fun fifteenPairsAllStartOff() { + assertEquals(15, FuzzyRule.entries.size) assertEquals(6, FuzzyRule.entries.count { it.initial }) assertTrue(FuzzyRule.DEFAULTS.isEmpty()) } @@ -73,6 +73,28 @@ class FuzzyRuleTest { assertEquals(null, FuzzyRule.F_H.variant("zhi")) } + @Test + fun theFourFinalPairsTheReferenceCarriesBeyondOurs() { + // un = ong and eng = ong are final swaps the reference spells over the + // whole syllable table (f_syllable-variants.tsv bits D3 and D6). + assertEquals("chong", FuzzyRule.UN_ONG.variant("chun")) + assertEquals("chun", FuzzyRule.UN_ONG.variant("chong")) + assertEquals("yun", FuzzyRule.UN_ONG.variant("yong")) + assertEquals("jiun", FuzzyRule.UN_ONG.variant("jiong")) + assertEquals("dong", FuzzyRule.ENG_ONG.variant("deng")) + assertEquals("deng", FuzzyRule.ENG_ONG.variant("dong")) + assertEquals("yeng", FuzzyRule.ENG_ONG.variant("yong")) + // The other two are whole-syllable pairs in the reference too. + assertEquals("hui", FuzzyRule.EI_UI.variant("fei")) + assertEquals("fei", FuzzyRule.EI_UI.variant("hui")) + // hei is not one of the pair: it reaches hui only through f = h, the + // way the reference gates its own hui <-> hei row on both bits. + assertEquals(null, FuzzyRule.EI_UI.variant("hei")) + assertEquals("wang", FuzzyRule.HUANG_WANG.variant("huang")) + assertEquals("huang", FuzzyRule.HUANG_WANG.variant("wang")) + assertEquals(null, FuzzyRule.HUANG_WANG.variant("zhuang")) + } + @Test fun onlyTheSwitchedOnPairsWidenTheLookup() { val onlyZ = pinyinFuzzyVariants("zi", setOf(FuzzyRule.Z_ZH)) diff --git a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt index 1a457d59..6294524c 100644 --- a/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt +++ b/app/src/test/java/llc/slacker/openime/PinyinEngineDataTest.kt @@ -250,6 +250,31 @@ class PinyinEngineDataTest { assertEquals(engine.candidates("chuang").first(), engine.candidates("chuang", fuzzyRules = chC).first()) } + @Test + fun theFourPairsTheReferenceHasBeyondOurs() { + val engine = loadEngine() + // The reference's classic layer is fifteen pairs; ours was eleven. The + // four it has and we did not are un/ong, ei/ui, huang/wang and eng/ong + // (f_syllable-variants.tsv bits D3-D6), and each one reaches the words + // of the syllable it points at. + for ((reading, rules, fuzzyWord, word) in listOf( + Quad("zongjing", setOf(FuzzyRule.UN_ONG), "尊敬", "zunjing"), + Quad("dongguang", setOf(FuzzyRule.ENG_ONG), "灯光", "dengguang"), + Quad("huiji", setOf(FuzzyRule.EI_UI), "飞机", "feiji"), + Quad("wangse", setOf(FuzzyRule.HUANG_WANG), "黄色", "huangse"), + )) { + val withRule = engine.candidates(reading, fuzzyRules = rules) + println("$reading -> ${withRule.take(6)}") + assertTrue("$fuzzyWord ($word) missing from $reading: ${withRule.take(8)}", withRule.contains(fuzzyWord)) + assertTrue( + "$fuzzyWord offered without the rule", + engine.candidates(reading).none { it == fuzzyWord }, + ) + } + } + + private data class Quad(val reading: String, val rules: Set, val fuzzyWord: String, val word: String) + @Test fun fuzzyRulesExpandCandidatesAndKeepExactReadingsFirst() { val engine = loadEngine() diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cc69fc84..cb45703c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -318,3 +318,6 @@ Add new entries at the end. The reference's fuzzy matching is not one substitution at a time: the typed spelling is the canonical syllable with the *whole* enabled mask applied (`doubaoime-teardown/ENGINE-SPEC.md` §1.5, whose own example is `zuan` + C8 (zh↔z) + D2 (uan↔uang) read as the input `zhuang`). Its syllable table shows how far that goes: of the 1,924 rows of `f_syllable-variants.tsv`, the ones carrying two classic mask bits are exactly the pairs of one initial rule and one final rule — `ch_c+eng_ong` (12 rows), `n_l+in_ing` (8), `z_zh+an_ang` (8), and every other combination of the two classes — with no row combining two initials or two finals, and no row carrying three. This port applied each enabled rule to the syllable on its own, so a spelling one substitution away from a syllable was only ever reached from that syllable's own form: with `z = zh` and `an = ang` both on, `zhang` reached 脏 (zang, one substitution) but not 赞 (zan, two), and `ning` reached nothing spelled lin. `PinyinSegmenter.fuzzyVariants` now composes one initial pair with one final pair, the second applied to the result of the first, for the lattice and the fragment scan alike. *Reason:* the user who turns on `z = zh` turns on `an = ang` because they confuse both, and a spelling that mixes the two is what they type; the reference answers it and this port did not. The composition is bounded where the reference bounds it — one pair from each class, no chains of two finals (`ceng` → `cong` → `cun` is not a spelling of 岑, and the reference has no such row) — which the table fixes to the row. Generating every spelling for every syllable the reference generates for the 11 switches this port has leaves nothing missing: 360 reference pairs against our 366, the six extra all being the bare letters this port's catalogue calls syllables (`f`/`h`, `l`/`n`, `l`/`r`), which the reference's trie holds as letter entries rather than syllables. The 50,000-reading comparison is unchanged at 50,000/50,000 first candidates. The variant no longer has to be a syllable of the catalogue either: a fuzzy spelling is a path of the reference's trie, not an entry of its syllable table, and 154 of the reference's rows under the switches this port has spell a syllable that is no syllable itself — 床 chuang typed `cuang`, 编 bian typed `biang`, 端 duan typed `duang`. `PinyinSegmenter` required `variant in nameToId` before adding the edge, which dropped every one of them; the edge is now added for any spelling the rules produce. +- **The fuzzy switches are the reference's fifteen, not the mainstream eleven.** + The reference's classic fuzzy layer is fifteen pairs, one per bit of `cfg[120]`: six initials (`zh↔z` … `r↔l`) and nine finals, and its own table names the last four — `D3 un↔ong`, `D4 ei↔ui`, `D5 huang↔wang`, `D6 eng↔ong` (`doubaoime-teardown/ENGINE-SPEC.md` §1.5, the bit table; the same four are the only rule names of `fuzzy-pinyin-rules.tsv` that fall outside the eleven mainstream IMEs offer). This port shipped eleven, so an input the reference answers came back empty here whenever one of those four was the pair involved. + *Reason:* the switch set is the surface where the port is supposed to match the reference, and a pair missing from it is a reading the user cannot type at all rather than a ranking difference. Each of the four is generated the way the reference spells it, checked row by row against `f_syllable-variants.tsv`: `un↔ong` and `eng↔ong` are final swaps over the whole syllable table (35 and 37 rows, matched with none left and none extra — `chun`↔`chong`, `deng`↔`dong`, and the j/q/x/y and bare-letter forms the reference closes over, `jiong`↔`jiun`, `yong`↔`yun`, `yeng`, `weng`↔`wong`); `ei↔ui` is *not* a final swap but the single pair `fei`↔`hui` (2 rows, matched exactly) — the reference reaches `hei` only where `f↔h` is on as well, its `hui`→`hei` row carrying both bits, and the port composes them the same way; `huang↔wang` is the whole-syllable pair the reference has (2 rows, matched exactly), not an `h↔w` initial rule the mask does not contain. With the four in, the reference's pure-bit rows under our fifteen pairs come out at ref-only 0. All four default off like the rest, librime takes their `derive` rules (verified on the emulator: `dong`→灯, `chun`→冲, `wang`→黄, `hui`→飞 with every pair on, `FuzzyRulesInstrumentedTest`), and the engine reaches the words of the syllable each points at (`zongjing`→尊敬, `dongguang`→灯光, `huiji`→飞机, `wangse`→黄色, `PinyinEngineDataTest.theFourPairsTheReferenceHasBeyondOurs`). From 3f42ad86655edd935305b237fcff4594ae3487e0 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:49:43 +0800 Subject: [PATCH 66/94] Show a committed word the emoji the reference gives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The association row showed nothing for words the reference associates (火, 好, 上班, 下雨): the port's table was 502 hand-written keywords. Generate it from follow_dict.dat instead — 5,508 words and 5,616 symbols, decoded out of the APK rather than from the teardown's TSV, whose '|' separator splits rows the file stores whole (one row's only symbol is '|' itself, another is a kaomoji containing one). The curated table still loads first and keeps its emoji for the keywords both carry; items are tab separated so a kaomoji with a space survives. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + app/src/main/assets/emoji/follow-keywords.tsv | 5511 +++++++++++++++++ .../slacker/openime/LocalVoiceImeService.kt | 4 +- .../candidate/EmojiAssociationIndex.kt | 37 +- .../openime/EmojiAssociationIndexTest.kt | 35 + docs/DECISIONS.md | 4 + scripts/import_doubao_data.py | 102 +- 7 files changed, 5685 insertions(+), 9 deletions(-) create mode 100644 app/src/main/assets/emoji/follow-keywords.tsv diff --git a/CHANGELOG.md b/CHANGELOG.md index 09675278..010274ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。 - 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 - 模糊音补上参考实现有、我们没有的四组:`un = ong`、`ei = ui`、`huang = wang`、`eng = ong`。参考的音节表按 15 组经典模糊音判定(`f_syllable-variants.tsv` 掩码的 D3–D6 位),我们只做了常见的前 11 组,这几组打开后一个候选都不会变。四组的口径都按参考自己的表逐条核对:`un`/`ong`(`chun` → `chong`,含 `jiong`/`jiun`、`yong`/`yun` 这类)与参考的 35 条行、`eng`/`ong`(`deng` → `dong`,含 `yeng`、`weng`/`wong`)与参考的 37 条行完全一致;`ei = ui` 在参考里只覆盖 `fei`/`hui` 一对(`hei` 要同时打开「f = h」才跟着进来,参考自己那两行也是两个掩码位一起写的);`huang = wang` 是整对音节。设置页新增这四组开关,仍默认关闭。 - 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 diff --git a/app/src/main/assets/emoji/follow-keywords.tsv b/app/src/main/assets/emoji/follow-keywords.tsv new file mode 100644 index 00000000..4573bc43 --- /dev/null +++ b/app/src/main/assets/emoji/follow-keywords.tsv @@ -0,0 +1,5511 @@ +# Association symbols per committed word, from the reference's follow_dict.dat +# (local build data). First column is the word, the rest are its symbols, +# tab separated so a symbol may contain a space. See DECODE-BATCH-C.md §3. +A 🅰️ +AB 🆎 +ABCD 🔠 +AB型 🆎 +AB型血 🆎 +AB血型 🆎 +ATM 🏧 +A型 🅰️ +A型血 🅰️ +A血型 🅰️ +B 🅱️ +B型 🅱️ +B型血 🅱️ +B血型 🅱️ +CD 💿 +CL按钮 🆑 +Copyright © +DNA 🧬 +DVD 📀 +Doge 🐶 +ID 🆔 +ID按钮 🆔 +NG 🆖 +NG按钮 🆖 +O 🅾️ +OK 👌 +OK手势 🙆 +OK按钮 🆗 +ON箭头 🔛 +O型 🅾️ +O型血 🅾️ +O血型 🅾️ +Python 🐍 +Registered ® +SOS 🆘 +SUV 🚙 +TM ™ +TV 📺 +T恤 👕 +T恤衫 👕 +UFO 🛸 +UP 🆙 +VS 🆚 +VS按钮 🆚 +WC 🚾 +WiFi 🛜 +X光 🩻 +X光片 🩻 +X射线 🩻 +ZZZ 💤 +abc 🔤 +abcd 🔡 +action 🎬 +alpha α +and & +angry 😡 +at @ +atom ⚛️ +baby 👶 +back 🔙 +beta β +boom 💣 +bug 🐛 +chi χ +clear 🆑 +cloud ☁️ +command ⌘ +cookie 🍪 +cookies 🍪 +cool 🆒 +delta δ +email 📧 +end 🔚 +epsilon ε +eta η +fight ⚔️ +free 🆓 +gamma γ +hi Hi~ o(* ̄▽ ̄*)ブ +info ℹ️ +information ℹ️ +iota ι +joker 🃏 +kappa κ +kiss 💋 +lambda λ +link 🔗 +love 💓 +money 💰 +mu μ +new 🆕 +nu ν +omega ω +omicron ο +on 🔛 +panda 🐼 +parking 🅿️ +party 🥳 +peace ☮️ +phi φ +psi ψ +respect 🫡 +rho ρ +rocket 🚀 +sigma σ +soon 🔜 +strong 💪 +tau τ +theta θ +top 🔝 +unbelievable 🫢 +up 🆙 +upsilon υ +up按钮 🆙 +wireless 🛜 +xi ξ +yo (^U^)ノ~YO +zeta ζ +一 1️⃣ ① +一个 ☝️ +一二三四 🔢 +一只眼 👁 +一只眼睛 👁 +一壶 🫖 +一壶茶 🫖 +一孩家庭 🧑‍🧑‍🧒 +一家三口 👨‍👩‍👦 +一家人 👨‍👩‍👦 +一家四口 👨‍👩‍👧‍👦 +一幅画 🖼 +一支烟 🚬 +一条蛇 🐍 +一条龙 🐉 +一杯 🥃 +一杯奶 🥛 +一杯奶茶 🧋 +一杯酒 🥃 +一次方 ¹ +一滴 💧 +一滴水 💧 +一点 🕐 +一点儿 🤏 +一点半 🕜 +一点点 🤏 +一点钟 🕐 +一百 💯 +一百分 💯 +一碗面 🍜 +一箭穿心 💘 +一脸不悦 🙁 +一锅食物 🍲 +七 7️⃣ ⑦ +七分之一 ⅐ +七夕 🎋 +七夕树 🎋 +七夕节 🎋 +七星瓢虫 🐞 +七次方 ⁷ +七点 🕖 +七点半 🕢 +七点钟 🕖 +万分之 ‱ +万分号 ‱ +万圣节 🎃 +三 3️⃣ ③ +三分之一 ⅓ +三分之二 ⅔ +三十 ㉚ +三十一 ㉛ +三十七 ㊲ +三十三 ㉝ +三十九 ㊴ +三十二 ㉜ +三十五 ㉟ +三十八 ㊳ +三十六 ㊱ +三十四 ㉞ +三叉戟 🔱 +三叉戟标志 🔱 +三口之家 👨‍👩‍👦 +三叶草 ☘️ +三拐 巛 +三撇 彡 +三明治 🥪 +三次方 ³ +三点 🕒 +三点半 🕞 +三点式 👙 +三点水 氵 +三点钟 🕒 +三角 🔺 +三角尺 📐 +三角形 △ +三角旗 🚩 +三角板 📐 +三角裤 🩲 +三蹦子 🛺 +三轮摩托车 🛺 +三轮车 🛺 +上 ⬆️ ↑ +上一个 ⏮️ +上一曲 ⏮️ +上一首 ⏮️ +上三角 🔼 +上下 ↕️ +上下点头 🙂‍↕️ +上下箭头 ↕️ +上升 📈 +上弦月 🌓 +上映 📽 +上涨 📈 +上班 💼 +上箭头 ⬆️ ↑ +上衣 👕 +上边 👆 +上锁 🔒 +上面 👆 +下 ⬇️ ↓ +下一个 ⏭️ +下一曲 ⏭️ +下一首 ⏭️ +下三角 🔽 +下划线 _ +下厨 👨‍🍳 +下大雨 ⛈️ +下弦月 🌗 +下横杠 _ +下水道 🕳 +下箭头 ⬇️ ↓ +下跌 📉 +下跪者 🧎 +下边 👇 +下降 📉 +下雨 🌧️ +下雪 🌨️ +下面 👇 +不 🙅 +不丹 🇧🇹 +不丹王国 🇧🇹 +不关心 🤷‍♂️ +不准 🚫 +不可以 🙅‍♂️ +不同意 🙅‍♂️ +不听 🙉 +不大于 ≯ +不好 👎 +不好意思 🙇‍♂️ +不小于 ≮ +不屑 😕 +不属于 ∉ +不开心 🙁 +不强 👎 +不悦 🙁 +不敢相信 🫢 +不敢看 🫣 +不明白 🤔 +不明飞行物 🛸 +不棒 👎 +不满 ☹️ +不理解 🤔 +不看 🙈 +不知道 🤷‍♂️ +不确定 🫤 +不等于 ≠ +不能说 🤐 +不行 🙅‍♂️ +不要 (>﹏<) +不要说 🤐 +不许 🚫 +不说 🤭 +不错 😌 +不高兴 🙁 +与 & +与我无关 🤷‍♂️ +丑小鸭 🦢 +专名号 ____ +世界和平 ☮️ +世界地图 🗺️ +丘比特 💘 +东京塔 🗼 +东帝汶 🇹🇱 +东帝汶民主共和国 🇹🇱 +东正教 ☦️ +东萨摩亚 🇦🇸 +丝带 🎗 +丢垃圾 🚮 +两个 ✌️ +两个女人 👭 +两个牵手的女人 👭 +两个男人 👬 +两点 🕑 +两点半 🕝 +两点水 冫 +两点钟 🕑 +两颗心 💕 +严禁 🚫 +丧尸 🧟‍♂️ +个人电脑 💻 +中 🀄 +中东蔬菜球 🧆 +中医药 💊 +中医院 🏥 +中华人民共和国 🇨🇳 +中国 🇨🇳 +中国国旗 🇨🇳 +中国帽 👲 +中国澳门 🇲🇴 +中国澳门特别行政区 🇲🇴 +中国香港 🇭🇰 +中国香港特别行政区 🇭🇰 +中国龙 🐲 +中弱记号 mp +中强记号 mf +中括号 [] +中指 🖕 (╬ ̄皿 ̄)凸 +中方块 ◼ +中正方形 ◼ +中秋 🎑 +中秋佳节 🎑 +中秋节 🎑 +中等方块 ◼ +中等正方形 ◼ +中等音量 🔉 +中等音量的扬声器 🔉 +中药 🌿 +中非 🇨🇫 +中非共和国 🇨🇫 +中音谱号 𝄡 +丰收饼 🥮 +串串 🍢 +丹麦 🇩🇰 +为什么 😭 +举中指 🖕 +举双手 🙌 +举手 🙋‍♂️ +举白旗 🏳️ +举起手 ✋ +举起拳头 ✊ +举起的手 ✋ +举起的拳头 ✊ +举重 🏋️‍♂️ +举重运动员 🏋️‍♂️ +举黑旗 🏴 +么么 💋 +么么哒 💋 +义体 🦾 +义肢 🦾 +乌云蔽日 ⛅ +乌克兰 🇺🇦 +乌兹别克斯坦 🇺🇿 +乌兹别克斯坦共和国 🇺🇿 +乌干达 🇺🇬 +乌干达共和国 🇺🇬 +乌拉圭 🇺🇾 +乌拉圭东岸共和国 🇺🇾 +乌索普 🤥 +乌贼 🦑 +乌鸦 🐦‍⬛ +乌龟 🐢 +乍得 🇹🇩 +乍得共和国 🇹🇩 +乐谱 🎼 +乒乓球 🏓 +乒乓球拍 🏓 +乖宝 👶 +乖宝宝 👶 +乘 ✖️ × +乘以 ✖️ +乘号 ✖️ × +乘雪橇 🛷 +九 9️⃣ ⑨ +九分之一 ⅑ +九次方 ⁹ +九点 🕘 +九点半 🕤 +九点钟 🕘 +也门 🇾🇪 +也门共和国 🇾🇪 +书 📚 +书写 ✍️ +书包 🎒 +书名号 《》 +书呆子 🤓 +书呆子脸 🤓 +书本 📖 +书签 🔖 +书签标签 📑 +书籍 📚 +乱讲 🤥 +乳液 🧴 +乳液瓶 🧴 +二 2️⃣ ② +二分之一 ½ +二分音符 ♪ +二十 ⑳ +二十一 ㉑ +二十七 ㉗ +二十三 ㉓ +二十九 ㉙ +二十二 ㉒ +二十五 ㉕ +二十八 ㉘ +二十六 ㉖ +二十四 ㉔ +二头肌 💪 +二孩家庭 🧑‍🧑‍🧒‍🧒 +二次方 ² +亏凸月 🌖 +云 ☁️ +云中 😶‍🌫️ +云中的脸 😶‍🌫️ +云彩 ☁️ +云朵 ☁️ +五 5️⃣ ⑤ +五分之一 ⅕ +五分之三 ⅗ +五分之二 ⅖ +五分之四 ⅘ +五十 ㊿ +五彩纸屑球 🎊 +五星 ☆ +五星红旗 🇨🇳 +五次方 ⁵ +五点 🕔 +五点半 🕠 +五点钟 🕔 +五线谱 🎼 +五菱 🚐 +五菱宏光 🚐 +五角星 ⭐ +井号 #️⃣ # +井号键 #️⃣ +亚洲 🌏 +亚洲地球仪 🌏 +亚洲澳洲 🌏 +亚澳地球仪 🌏 +亚美尼亚 🇦🇲 +亚美尼亚共和国 🇦🇲 +交叉旗 🎌 +交叉的手指 🤞 +交叉箭头 🔀 +交通信号灯 🚥 +交通灯 🚥 +交集 ∩ +亨利 H +京字头 亠 +亮度 🔅 +亮度低 🔅 +亮度高 🔆 +亲 💋 (づ ̄3 ̄)づ╭♥~ +亲一下 😘 +亲一个 😘 +亲亲 😘 ( ˘ ³˘)♥ +亲亲猫 😽 +亲亲猫脸 😽 +亲亲的猫 😽 +亲吻 💏 +亲嘴 💋 +人上人 仌 +人像 👤 +人字拖 🩴 +人民币 ¥ +人物剪影 👤 +人猿 🦧 +人的拥抱 🫂 +人鱼 🧜‍♂️ +什么 🤔 o_O??? +仓鼠 🐹 +仓鼠头 🐹 +仓鼠脸 🐹 +仙人 🧚‍♂️ +仙人掌 🌵 +仙女 🧚‍♀️ +仙桃 🍑 +以上 ⬆️ +以色列 🇮🇱 +以色列国 🇮🇱 +价钱 💲 +企鹅 🐧 +企鹅头 🐧 +伊塔 η +伊拉克 🇮🇶 +伊拉克共和国 🇮🇶 +伊普西龙 ε +伊朗 🇮🇷 +伊朗伊斯兰共和国 🇮🇷 +伏特 V +休息 ♨️ +休达 🇪🇦 +休达及梅利利亚 🇪🇦 +休达和梅利利亚 🇪🇦 +休闲车 🚙 +休闲鞋 👟 +伞 🌂 +伞状 ☂️ +传呼机 📟 +传真 📠 +传真机 📠 +伤心 💔 +伦琴 R +伪装 🥸 +伪装的脸 🥸 +伯利兹 🇧🇿 +伸手 🫴 +伽马 γ +低亮度 🔅 +低头忧伤 😔 +低电量 🪫 +低音谱号 𝄢 +住宅楼 🏠 +佐料 🧂 +佐料瓶 🧂 +体温计 🤒 +体育场 🏟 +体育馆 🏟️ +佛寺 🛕 +佛得角 🇨🇻 +佛得角共和国 🇨🇻 +你好 👋 ヾ(=^▽^=)ノ +你妈死了 🤬 +你自己 🫵 +侦探 🕵️ +侧手翻 🤸 +便便 💩 +便利商店 🏪 +便利店 🏪 +便当 🍱 +便当盒 🍱 +便笺 📝 +俄国 🇷🇺 +俄罗斯 🇷🇺 +俄罗斯套娃 🪆 +俄罗斯联邦 🇷🇺 +保加利亚 🇧🇬 +保加利亚共和国 🇧🇬 +保密 🤐 +保持音记号 — +保龄球 🎳 +保龄球瓶 🎳 +信件 ✉️ +信号 📶 +信号强度 📶 +信号强度条 📶 +信号灯 🚥 +信封 ✉️ +信息 ℹ️ +信用卡 💳 +信箱 📫 +俩叹号 ‼️ +修理工 👨‍🔧 +倒三角 🔻 ▽ +倒下 _(:3 」∠)_ +倒垃圾 🚮 +倒脸 🙃 +倒退 ◀️ +倒退按钮 ◀️ +倒霉 😓 +借记卡 💳 +倾倒 🫗 +倾倒液体 🫗 +做成心形的双手 🫶 +做指甲 💅 +做美甲 💅 +做造型 💇‍♂️ +做饭 👨‍🍳 +停止 ⏹️ +停止按钮 ⏹️ +停止标志 🛑 +停车 🅿️ +停车场 🅿️ +停车按钮 🅿️ +健身 💪 +偷偷看 🫣 +偷偷瞧 🫣 +偷看 🫣 +偷瞧 🫣 +傍晚 🌆 +傲慢 😕 +傻瓜 🤪 +傻瓜脸 🤪 +傻笑 😆 +傻逼 🤬 +僵尸 🧟‍♂️ +儿子 👦 +儿童 🧒 +儿童过街 🚸 +元宵 ⚪ +兄弟 👬 +兆瓦 MW +兆赫 MHz +光头 👨‍🦲 +光头男 👨‍🦲 +光年 ly +光明节 🕎 +光环 😇 +光盘 💽 +光碟 💽 +克 g +克利珀顿岛 🇨🇵 +克尔白 🕋 +克罗地亚 🇭🇷 +克罗地亚共和国 🇭🇷 +克西 ξ +免费 🆓 +兑换 💱 +兔 🐇 +兔先生 👯‍♂️ +兔头 🐰 +兔女郎 👯‍♀️ +兔子 🐇 +兔子头 🐰 +兔子脸 🐰 +兔耳朵 👯‍♂️ +兔脸 🐰 +入场券 🎟️ +全新 🆕 +全等 ≌ +全等于 ≡ +全莲花坐 🧘‍♂️ +全音符 ♩ +八 8️⃣ ⑧ +八分之一 ⅛ +八分之七 ⅞ +八分之三 ⅜ +八分之五 ⅝ +八分音符 ♬ +八次方 ⁸ +八点 🕗 +八点半 🕣 +八点钟 🕗 +八角星 ✴️ +八边形 🛑 +公主 👸 +公事包 💼 +公交站 🚏 +公交车 🚌 +公交车站 🚏 +公共广播 📢 +公共汽车 🚌 +公共汽车站 🚏 +公园 ⛲ +公文包 💼 +公斤 kg +公牛 🐂 +公羊 🐏 +公路 🛣 +公车 🚌 +公里 km +公顷 ha +公鸡 🐓 +六 6️⃣ ⑥ +六六六 🤙 +六分之一 ⅙ +六分之五 ⅚ +六次方 ⁶ +六点 🕕 +六点半 🕡 +六点钟 🕕 +六芒星 ✡️ +六角括号 〔〕 +六角星 🔯 +兰姆达 λ +兰布达 λ +兰花指 🤌 +关东煮 🍢 +关岛 🇬🇺 +关机 📴 +关注 ➕ +关闭手机 📴 +兵 ♟️ +内心活动气泡 💭 +内衣 👙 +内裤 🩲 +冈比亚 🇬🇲 +冈比亚共和国 🇬🇲 +再见 👋 ヾ(⌒∇⌒*)See You♪ +冒号 : +冒汗 😅 +冒汗焦虑 😰 +冒热气的碗 🍜 +写信 ✉️ +写字 ✍️ +写字板 📋 +写字楼 🏢 +军人 🪖 +军功章 🎖️ +军用头盔 🪖 +军盔 🪖 +农夫 👨‍🌾 +农妇 👩‍🌾 +农民 🧑‍🌾 +农民工 👷‍♂️ +冠军 🏆 +冥想 🧘‍♂️ +冰 🧊 +冰上溜石 🥌 +冰冷 🥶 +冰块 🧊 +冰壶 🥌 +冰岛 🇮🇸 +冰沙 🍧 +冰淇淋 🍨 +冰激凌 🍦 +冰球 🏒 +冰雪 ❄️ +冲浪 🏄‍♂️ +冲浪者 🏄‍♂️ +冷 🥶 +冷冰冰 🥶 +冷死 🥶 +冷死了 🥶 +冷汗 😰 +冷漠 😐 +冷笑 😏 +冷脸 🥶 +冷饮 🍹 +冻僵 🥶 +冻僵了 🥶 +冻死 🥶 +冻死了 🥶 +凉鞋 🩴 +凋谢 🥀 +减 ➖ - +减号 ➖ - +几内亚 🇬🇳 +几内亚共和国 🇬🇳 +几内亚比绍 🇬🇼 +几内亚比绍共和国 🇬🇼 +凤凰 🐦‍🔥 +凤尾蝶 🦋 +凤梨 🍍 +凭据 🧾 +凳子 🪑 +凸 (╬ ̄皿 ̄)凸 +出口成脏 🤬 +出国 💱 +出拳 👊 +出生 🐣 +出租车 🚕 +击剑 🤺 +击剑者 🤺 +击剑选手 🤺 +击掌 ✋ +刀 🔪 +刀叉 🍴 +分 ′ +分号 ; +分界符 § +分米 dm +分贝 dB +分钟 min +划船 🚣‍♂️ +划艇 🚣‍♂️ +列支敦士登 🇱🇮 +列支敦士登公国 🇱🇮 +列车 🚄 +刚果 🇨🇩 +刚果布 🇨🇬 +刚果金 🇨🇩 +创口贴 🩹 +创可贴 🩹 +刨冰 🍧 +利比亚 🇱🇾 +利比亚国 🇱🇾 +利比里亚 🇱🇷 +利比里亚共和国 🇱🇷 +别墅 🏡 +别说话 🤫 +别针 🧷 +刮胡刀 🪒 +刷牙 🪥 +刺猬 🦔 +剃刀 🪒 +剃须刀 🪒 +前台 💁 +前台女 💁‍♀️ +前台男 💁‍♂️ +前方施工 🚧 +剑斗 🤺 +剪刀 ✂️ +剪刀手 ✌️ +剪发 💇‍♂️ +剪头 💇‍♂️ +剪头发 💇‍♂️ +剪子 ✂️ +剪影 🗣 +剪贴板 📋 +割 🈹 +力量 💪 +办公室职员 👨‍💼 +办公楼 🏢 +加 ➕ + +加元 C$ +加冰 🧊 +加号 ➕ + +加拿大 🇨🇦 +加油 💪 ↖(^ω^)↗ +加油站 ⛽ +加纳 🇬🇭 +加纳共和国 🇬🇭 +加纳利群岛 🇮🇨 +加蓬 🇬🇦 +动物脚印 🐾 +动车 🚄 +助听器 🦻 +努力 💪 +勋章 🎖 +勒克斯 lx +勺子 🥄 +勾号 ✔️ +勾号按钮 ✅ +勾选 ☑️ +勾选框 ☑️ +勿丢垃圾 🚯 +勿扔垃圾 🚯 +包包 👜 +包字头 勹 +包装 🎁 +包装盒 🎁 +包裹 📦 +匈牙利 🇭🇺 +匕首 🗡️ +化学家 👨‍🔬 +北朝鲜 🇰🇵 +北极熊 🐻‍❄️ +北极熊头 🐻‍❄️ +北极熊脸 🐻‍❄️ +北韩 🇰🇵 +北马其顿 🇲🇰 +北马其顿共和国 🇲🇰 +北马里亚纳群岛 🇲🇵 +匙 🥄 +匙子 🥄 +匹诺曹 🤥 +医护人员 👨‍⚕️ +医生 👨‍⚕️ +医疗标志 ⚕️ +医疗符号 ⚕ +医药 💊 +医院 🏥 +十 🔟 ⑩ +十一 ⑪ +十一点 🕚 +十一点半 🕦 +十一点钟 🕚 +十七 ⑰ +十三 ⑬ +十九 ⑲ +十二 ⑫ +十二点 🕛 +十二点半 🕧 +十二点钟 🕛 +十五 ⑮ +十八 ⑱ +十八禁 🔞 +十六 ⑯ +十六分音符 𝄫 +十分之一 ⅒ +十四 ⑭ +十字架 ✝️ +十点 🕙 +十点半 🕥 +十点钟 🕙 +千兆赫 GHz +千克 kg +千克力 kgf +千分 ‰ +千分之 ‰ +千分号 ‰ +千卡 kcal +千瓦 kW +千米 km +千米每小时 km/h +千赫 kHz +升 L +升号 ♯ +升级 🆙 +升降机 🛗 +半夜 🌃 +半身像 👤 +华夫饼 🧇 +华氏度 ℉ +单人旁 亻 +单峰骆驼 🐪 +单引号 ‘’ +单曲循环 🔂 +单板 🏂 +单板滑雪 🏂 +单片眼镜 🧐 +单眼 👁 +单眼偷看 🫣 +单眼吐舌 😜 +单耳旁 卩 +单蛇之杖 ⚕ +单车 🚲 +单轨 🚝 +单轨铁路 🚝 +单选 ⊙ +单选按钮 🔘 +单选框 🔘 +卖萌 = ̄ω ̄= +南乔治亚和南桑威奇群岛 🇬🇸 +南朝鲜 🇰🇷 +南极洲 🇦🇶 +南瓜 🎃 +南瓜头 🎃 +南瓜灯 🎃 +南苏丹 🇸🇸 +南苏丹共和国 🇸🇸 +南非 🇿🇦 +南非共和国 🇿🇦 +南韩 🇰🇷 +博物馆 🏛 +博茨瓦纳 🇧🇼 +博茨瓦纳共和国 🇧🇼 +占卜 🔮 +卡塔尔 🇶🇦 +卡塔尔国 🇶🇦 +卡帕 κ +卡片存档盒 🗃 +卡片盒 🗃️ +卡片相机 📷 +卡片索引 📇 +卡片索引分隔卡 🗂 +卡牌索引 📇 +卡车 🚛 +卢布 ₽ +卢旺达 🇷🇼 +卢旺达共和国 🇷🇼 +卢森堡 🇱🇺 +卢森堡大公国 🇱🇺 +卧槽 😲 +卫兵 💂 +卫士 💂‍♂️ +卫星 🛰️ +卫星天线 📡 +卫生纸 🧻 +卫生间 🚻 +印刷机 🖨 +印尼 🇮🇩 +印度 🇮🇳 +印度共和国 🇮🇳 +印度卢比 ₹ +印度寺庙 🛕 +印度尼西亚 🇮🇩 +印度尼西亚共和国 🇮🇩 +印度庙 🛕 +印度油灯 🪔 +危地马拉 🇬🇹 +危楼 🏚 +危险 ⚠️ +卷发 🧑‍🦱 +卷曲环 ➰ +卷毛狗 🐩 +卷烟 🚬 +卷纸 🧻 +卷起的报纸 🗞 +卷轴 📜 +卷边的纸 📃 +卷饼 🌮 +厄瓜多尔 🇪🇨 +厄瓜多尔共和国 🇪🇨 +厄立特里亚 🇪🇷 +厄立特里亚国 🇪🇷 +厉害 👍 +厕所 🚻 +厘泊 cP +厘米 cm +原子 ⚛️ +原子符号 ⚛️ +原木 🪵 +厨师 🧑‍🍳 +叉 ❌ +叉叉 ❌ +叉号 ❌ +叉号按钮 ❎ +叉梳 🪮 +双人 👥 +双人像 👥 +双人旁 彳 +双剑 ⚔️ +双剑交叉 ⚔️ +双卷曲环 ➿ +双叹号 ‼️ +双喜 囍 +双子座 ♊ +双峰骆驼 🐫 +双引号 “” +双惊叹号 ‼️ +双感叹号 ‼️ +双手 🙌 +双手合十 🙏 +双手张开 👐 +双手手掌向上 🤲 +双眼 👀 +双竖线 ‖ +双耳旁 阝 +双耳瓶 🏺 +双肩包 🎒 +双鱼座 ♓ +反对 👎 +反引号 ` +反文 攵 +反文旁 攵 +反派 🦹‍♂️ +反犬旁 犭 +反胃 🤢 +反问号 ¿ +发件 📤 +发件箱 📤 +发传真 📠 +发信 📤 +发夹 🪮 +发展 💪 +发火 😡 +发烧 🤒 +发热 🥵 +发票 🧾 +发红包 🧧 +发芽 🌱 +发财 🤑 +发邮件 📤 +发飙 😡 +取款机 🏧 +受伤 🤕 +受伤的心 ❤️‍🩹 +变形虫 🦠 +变性 ⚧ +叙利亚 🇸🇾 +叙利亚共和国 🇸🇾 +口 👄 +口字旁 口 +口水 🤤 +口红 💄 +口罩 😷 +古典建筑 🏛️ +古巴 🇨🇺 +古巴共和国 🇨🇺 +古建筑 🏛 +古老的钥匙 🗝 +句号 。 +可 🉑 +可以 😌 +可怕 😨 +可怜 🥺 +可怜兮兮 🥺 +可爱 🥰 (*╹▽╹*) +可能 🤔 +台式电脑 🖥️ +台球 🎱 +台钟 🕰 +台风 🌀 +右 ➡️ → +右上 ↗️ ↗ +右上箭头 ↗️ +右下 ↘️ ↘ +右下箭头 ↘️ +右书名号 》 +右单引号 ’ +右双引号 ” +右向拳 🤜 +右引号 ” +右手 ✋ +右括号 ) +右拳 🤛 +右推 🫸 +右箭头 ➡️ → +右耳旁 阝 +右边 👉 +右钩拳 🤛 +右面 👉 +叶子 🍃 +叹号 ❗ +叹号问号 ⁉️ +叹气 😮‍💨 +叹问号 ⁉️ +吃惊 😲 +吃比萨 🍕 +吃比萨饼 🍕 +吃瓜 🍉 +吃糖 🍬 +吃肉 🍖 +吃面 🍜 +合 🈴 +合上的锁 🔒 +合作 🤝 +合作愉快 🤝 +合十 🙏 +合掌 🙏 +合格 🈴 +吉他 🎸 +吉尔吉斯共和国 🇰🇬 +吉尔吉斯斯坦 🇰🇬 +吉布提 🇩🇯 +吉布提共和国 🇩🇯 +吉瓦 GW +吊牌 🏷 +同性恋 👨‍❤️‍👨 +同意 🙆 +吐 🤮 +吐了 🤮 +吐出来 🤮 +吐舌 😛 +吐舌头 😛 +向上 👆 +向上指 👆 +向上箭头 ⬆️ +向下 👇 +向下指 👇 +向下箭头 ⬇️ +向右 👉 +向右伸的手 🫱 +向右指 👉 +向右推 🫸 +向右推手 🫸 +向右推的手 🫸 +向右的手 🫱 +向右箭头 ➡️ +向左 👈 +向左伸的手 🫲 +向左指 👈 +向左推 🫷 +向左推手 🫷 +向左推的手 🫷 +向左的手 🫲 +向左箭头 ⬅️ +向日葵 🌻 +向阳花 🌻 +吓人 😱 +吓死 😱 +吓死了 😱 +君主 🫅 +吨 t +吨公里 t·km +含泪 🥹 +含泪的笑 🥲 +含泪的笑脸 🥲 +含泪笑脸 🥲 +听 👂 +听力障碍 🦻 +听筒 📞 +听诊器 🩺 +吸把 🪠 +吸气 🫁 +吸烟 🚬 +吸管杯 🥤 +吸血鬼 🧛‍♂️ +吸铁石 🧲 +吹口气 🌬 +吹拂 🌬 +吹风 🌬 +吻 💋 +吻痕 💋 +呃 🤔 (⊙﹏⊙) +呃呃 😅 +呆 ( ̄△ ̄;) +呕吐 🤮 +呜呜 🥹 ε(┬┬﹏┬┬)3 +呵呵 🙂 +呵欠 🥱 +呼吸 👃 +呼吸作用 🫁 +呼噜 (-.-)..zzZZ +呼机 📟 +呼气 😮‍💨 +命中 🎯 +和 & +和尚 👨‍🦲 +和平 ☮️ +和平手势 ✌️ +和平标志 ☮️ +和平符号 ☮️ +和平象征 🕊 +和平鸽 🕊 +和服 👘 +咒骂 🤬 +咖啡 ☕ +咖啡杯 ☕ +咖喱 🍛 +咖喱饭 🍛 +咦 😲 +咧嘴笑 😁 +咬住嘴唇 🫦 +咬嘴唇 🫦 +咬牙 😬 +咬牙切齿 😬 +哇 🤩 +哈 😄 +哈哈 😃 +哈哈哈 😁 +哈密瓜 🍈 +哈欠 🥱 +哈萨克 🇰🇿 +哈萨克斯坦 🇰🇿 +哈萨克斯坦共和国 🇰🇿 +哈雷 ☄️ +哈雷彗星 ☄️ +响指 🫰 +响铃关闭 🔕 +哟 (^U^)ノ~YO +哥伦比亚 🇨🇴 +哥伦比亚共和国 🇨🇴 +哥哥 🧍‍♂️ +哥斯达黎加 🇨🇷 +哥斯达黎加共和国 🇨🇷 +哦 😮 +哦耶 ✌️ +哭 😢 o(╥﹏╥)o +哭了 😢 +哭泣 😢 +哭泣猫 😿 +哭泣猫脸 😿 +哭泣的猫 😿 +哭笑 😂 +哭脸 😭 +哺乳 🤱 +哺乳的人 🧑‍🍼 +哼 😤 (。◕ˇε ˇ◕。) +哼哼 😤 +唇 💋 +唇印 💋 +唇膏 💄 +唉 😮‍💨 +唱歌 ♪(´∇`*) +商场 🏬 +商店 🏪 +商标 ™️ +商标符号 ™ +商超 🏬 +啊 😲 +啤酒 🍺 +啤酒肚 🫃 +啪啪啪 👏 +啵一个 😘 +啵儿一个 😘 +啵啵 (*  ̄3)(ε ̄ *) +喀麦隆 🇨🇲 +喀麦隆共和国 🇨🇲 +喂 (。・o・。)ノ +喂奶 🤱 +喇叭 📢 +喇叭筒 📣 +喜极而泣 😹 +喜欢 ❤️ (≧∇≦)ノ +喜笑颜开 🥰 +喝咖啡 ☕ +喝多 🥴 +喝多了 🥴 +喝奶 🍼 +喝的 🍹 +喝茶 🍵 +喝醉 🥴 +喝醉了 🥴 +喵 🐱 +喵喵 🐱 +喵喵叫 🐱 +喵喵喵 🐱 +喷嚏 🤧 +喷头 🚿 +喷水 🐳 +喷水的鲸 🐳 +喷水的鲸鱼 🐳 +喷泉 ⛲ +嗅 👃 +嗨起来 ヽ(○´∀`)ノ♪ +嗯嗯 🤗 +嘎嘎 🦆 +嘘 🤫 +嘟嘟车 🛺 +嘲笑 😝 +嘴 👄 +嘴唇 👄 +嘴巴 👄 +嘻嘻 😁 (*∩_∩*)~ +嘿嘿 😁 +嘿嘿嘿 😝 +噘嘴 😗 (・ε・`) +噘嘴猫 😾 +噘嘴猫脸 😾 +噘嘴的人 🙎‍♂️ +噘嘴的猫 😾 +四 4️⃣ ④ +四分之一 ¼ +四分之三 ¾ +四分音符 ♫ +四十 ㊵ +四十一 ㊶ +四十七 ㊼ +四十三 ㊸ +四十九 ㊾ +四十二 ㊷ +四十五 ㊺ +四十八 ㊽ +四十六 ㊻ +四十四 ㊹ +四口之家 👨‍👩‍👧‍👦 +四叶草 🍀 +四字头 罒 +四方帽 🎓 +四次方 ⁴ +四点 🕓 +四点半 🕟 +四点底 灬 +四点钟 🕓 +四角裤 🩳 +四轮滑冰鞋 🛼 +回力棒 🪃 +回力镖 🪃 +回家 🏠 +回形针 📎 +回收 ♻️ +回收标志 ♻️ +回旋镖 🪃 +回电话 🤙 +回车 ↩️ +回飞棒 🪃 +因为 ∵ +团圆饼 🥮 +团子 🍡 +囧 (´Д`) +困 😴 +困乏 😪 +困了 😪 +困倦 😪 +困困 😪 +困惑 😖 +困扰 😕 +围巾 🧣 +围棋 ⚫ +固定电话 ☎️ +固话 ☎️ +国宝 🐼 +国家公园 🏞️ +国王 🤴 +国际象棋 ♟ +国际象棋兵 ♟ +图书 📖 +图瓦卢 🇹🇻 +图表 📊 +图钉 📌 +圆周率 π +圆图钉 📍 +圆圈 ⭕ +圆形 ⚫ +圆形花饰 🏵️ +圆括号 () +圆月 🌕 +圆桶 🪣 +圆珠笔 🖊 +圆白菜 🥬 +圆筒冰淇淋 🍦 +圆筒冰激凌 🍦 +土字旁 土 +土库曼斯坦 🇹🇲 +土星 🪐 +土耳其 🇹🇷 +土耳其共和国 🇹🇷 +土豆 🥔 +圣卢西亚 🇱🇨 +圣基茨和尼维斯 🇰🇳 +圣堂 ⛪ +圣多美和普林西比 🇸🇹 +圣多美和普林西比民主共和国 🇸🇹 +圣巴泰勒米 🇧🇱 +圣文森特和格林纳丁斯 🇻🇨 +圣甲虫 🪲 +圣皮埃尔和密克隆群岛 🇵🇲 +圣诞 🎄 +圣诞人 🧑‍🎄 +圣诞夫人 🤶 +圣诞奶奶 🤶 +圣诞岛 🇨🇽 +圣诞树 🎄 +圣诞老人 🎅 +圣赫勒拿 🇸🇭 +圣马丁 🇸🇽 +圣马力诺 🇸🇲 +圣马力诺共和国 🇸🇲 +在家 🏠 +圭亚那 🇬🇾 +圭亚那合作共和国 🇬🇾 +地下铁路 🚇 +地主 👲 +地球 🌏 +地球仪 🌍 +地瓜 🍠 +地铁 🚇 +场记 🎬 +场记板 🎬 +坎德拉 cd +坎达 🪯 +坎达符号 🪯 +坏人 ~( TロT)σ +坏笑 😈 +坏蛋 🦹‍♂️ +坐雪橇 🛷 +坑 🕳 +坟墓 🪦 +坠入爱河 🥰 +坦桑尼亚 🇹🇿 +坦桑尼亚联合共和国 🇹🇿 +垂涎三尺 🤤 +垂直 ⊥ +垃圾 🗑 +垃圾丢弃处 🚮 +垃圾入篓 🚮 +垃圾入篓标志 🚮 +垃圾桶 🗑️ +垃圾篓 🗑 +垒球 🥎 +埃 Å +埃及 🇪🇬 +埃塞俄比亚 🇪🇹 +埃塞俄比亚联邦民主共和国 🇪🇹 +城堡 🏯 +城市 🏙 +城市景观 🏙 +城市风光 🏙️ +城市黄昏 🌆 +培养皿 🧫 +培根 🥓 +基友 👬 +基因 🧬 +基督 ✝️ +基督教 ✝️ +基里巴斯 🇰🇮 +基里巴斯共和国 🇰🇮 +堆雪人 ⛄ +堵上耳朵 🙉 +堵住耳朵 🙉 +堵着耳朵 🙉 +堵耳 🙉 +堵耳朵 🙉 +塔吉克斯坦 🇹🇯 +塔吉克斯坦共和国 🇹🇯 +塞内加尔 🇸🇳 +塞内加尔共和国 🇸🇳 +塞尔维亚 🇷🇸 +塞尔维亚共和国 🇷🇸 +塞拉利昂 🇸🇱 +塞拉利昂共和国 🇸🇱 +塞浦路斯 🇨🇾 +塞浦路斯共和国 🇨🇾 +塞舌尔 🇸🇨 +塞舌尔共和国 🇸🇨 +墓碑 🪦 +墨西哥 🇲🇽 +墨西哥卷 🌮 +墨西哥卷饼 🌮 +墨西哥合众国 🇲🇽 +墨西哥粽 🫔 +墨西哥粽子 🫔 +墨西哥薄饼卷 🌮 +墨镜 😎 +墨镜笑脸 😎 +墨鱼 🦑 +壁炉钟 🕰 +壁虎 🦎 +处女座 ♍ +备忘 📝 +备忘录 📝 +复古钥匙 🗝 +复活岛 🗿 +复活节岛 🗿 +复活节岛石像 🗿 +复选 ☑️ +复选按钮 ☑️ +复选框 ☑️ +复附点二分音符 ♪.. +复附点全音符 ♩.. +复附点八分音符 ♬.. +复附点十六分音符 𝄫.. +复附点四分音符 ♫.. +夕阳 🌇 +外公 👴 +外卖 🍱 +外卖包装 🥡 +外卖桶 🥡 +外卖盒 🥡 +外套 🧥 +外婆 👵 +外带杯子 🥤 +外星人 👽 +外星怪物 👾 +外汇 💱 +多个音符 🎶 +多云 ⛅ +多哥 🇹🇬 +多哥共和国 🇹🇬 +多民族玻利维亚国 🇧🇴 +多米尼克 🇩🇲 +多米尼克国 🇩🇲 +多米尼加共和国 🇩🇴 +多谢 (ㅅ´ ˘ `)♡~ +夜幕下的桥 🌉 +夜晚 🌃 +夜间大桥 🌉 +夜间的大桥 🌉 +大不列颠及北爱尔兰联合王国 🇬🇧 +大于 > +大于等于 ≥ +大便 💩 +大公鸡 🐓 +大写 🔠 +大写一 壹 +大写七 柒 +大写三 叁 +大写九 玖 +大写二 贰 +大写五 伍 +大写八 捌 +大写六 陆 +大写十 拾 +大写四 肆 +大写字母 🔠 +大卡 kcal +大卡车 🚛 +大卫之星 ✡️ +大厦夕阳 🌇 +大厨 👨‍🍳 +大反派 🦹‍♂️ +大叔 🧔 +大哭 😭 +大喇叭 📣 +大夫 👨‍⚕️ +大头针 📍 +大学生 👨‍🎓 +大小王 🃏 +大小眼 o_O +大山日出 🌄 +大巴 🚌 +大拇指 👍 +大拇指向上 👍 +大拇指向下 👎 +大拇指朝上 👍 +大拇指朝下 👎 +大括号 {} +大方块 ◾ +大树 🌳 +大桥 🌉 +大正方形 ◾ +大母鸡 🐓 +大浪 🌊 +大熊猫 🐼 +大猩猩 🦍 +大王 🃏 +大白兔 🐇 +大白鲨 🦈 +大笑 😂 +大笑的猫 😺 +大米 🍚 +大米饭 🍚 +大红灯笼 🏮 +大红灯笼高高挂 🏮 +大肚子 🤰 +大脑 🧠 +大脑爆炸 🤯 +大腿 🦵 +大菱形 🔶 +大蒜 🧄 +大虾 🦐 +大象 🐘 +大野牛 🦬 +大雨 ⛈️ +大音量 🔊 +大风 🌬️ +大饼 🫓 +大鹅 🪿 +天使 👼 +天使宝宝 👼 +天使的翅膀 🪽 +天妇罗 🍤 +天平 ⚖️ +天文单位 AU +天气 ☁️ +天狗 👺 +天秤座 ♎ +天线 📡 +天蝎座 ♏ +天鹅 🦢 +太差 👎 +太差了 👎 +太差啦 👎 +太极 ☯️ +太棒 👍 +太棒了 👍 ㄟ(≧◇≦)ㄏ +太棒啦 👍 +太阳 ☀️ +太阳伞 🏖 +太阳花 🌻 +太阳镜 😎 +失恋 💔 ♡o(╥﹏╥)o ♡ +失望 😞 +失望但如释重负 😥 +失聪 🧏‍♂️ +失聪者 🧏 +失落 (◞‸◟ ) +头像 👤 +头巾 👳‍♂️ +头戴式耳机 🎧 +头昏眼花 🥴 +头晕 💫 +头晕目眩 😵 +头晕眼花 😵 +头爆炸 🤯 +头盔 🪖 +头秃 👨‍🦲 +头纱 👰‍♀️ +头脑 🧠 +头部剪影 🗣 +头骨 💀 +夹克 🧥 +夹克衫 🧥 +夹心饼 🥙 +夹趾凉鞋 🩴 +夹钳 🗜️ +奇 χ +奇异果 🥝 +奇怪 🤔 +奇怪了 🤔 +奉金瓮 ⚱️ +奋斗 💪 +奖杯 🏆 +奖牌 🏅 +奖金 💰 +套娃 🪆 +奥兰 🇦🇽 +奥兰群岛 🇦🇽 +奥地利 🇦🇹 +奥地利共和国 🇦🇹 +奥姆 🕉️ +奥密克戎 ο +女 ♀ +女丧尸 🧟‍♀️ +女举重运动员 🏋️‍♀️ +女人 👩 +女仔 🧍‍♀️ +女侦探 🕵️‍♀️ +女修理工 👩‍🔧 +女僵尸 🧟‍♀️ +女儿 👧 +女农民工 👷‍♀️ +女办公室职员 👩‍💼 +女包 👜 +女化学家 👩‍🔬 +女医护人员 👩‍⚕️ +女医生 👩‍⚕️ +女卫兵 💂‍♀️ +女卫士 💂‍♀️ +女厕 🚺 +女厕所 🚺 +女厨师 👩‍🍳 +女反派 🦹‍♀️ +女同 👩‍❤️‍👩 +女同性恋 👩‍❤️‍👩 +女吸血鬼 🧛‍♀️ +女坏蛋 🦹‍♀️ +女士 🚺 +女士标志 🚺 +女士符号 🚺 +女士衬衫 👚 +女士钱包 👛 +女大反派 🦹‍♀️ +女大夫 👩‍⚕️ +女妖怪 🧞‍♀️ +女字旁 女 +女学生 👩‍🎓 +女孩 👧 +女宇航员 👩‍🚀 +女小精灵 🧝‍♀️ +女工人 👩‍🏭 +女工厂工人 👩‍🏭 +女巫 🧙‍♀️ +女帽 👒 +女建筑工 👷‍♀️ +女建筑工人 👷‍♀️ +女开发人员 👩‍💻 +女式凉鞋 👡 +女性 ♀ +女性符号 ♀️ +女技工 👩‍🔧 +女技术员 👩‍💻 +女摇滚歌手 👩‍🎤 +女教师 👩‍🏫 +女新郎 🤵‍♀️ +女服务员 💁‍♀️ +女服务生 💁‍♀️ +女歌手 👩‍🎤 +女水管工 👩‍🔧 +女法官 👩‍⚖️ +女法师 🧙‍♀️ +女洗手间 🚺 +女消防员 👩‍🚒 +女漫画家 👩‍🎨 +女灯神 🧞‍♀️ +女王 👸 +女生举手 🙋‍♀️ +女生举重 🏋️‍♀️ +女生冲浪 🏄‍♀️ +女生划船 🚣‍♀️ +女生按摩 💆‍♀️ +女生捂脸 🤦‍♀️ +女生摔跤 🤼‍♀️ +女生游泳 🏊‍♀️ +女生玩球 ⛹️‍♀️ +女生理发 💇‍♀️ +女生耸肩 🤷‍♀️ +女生跑步 🏃‍♀️ +女生鞠躬 🙇‍♀️ +女电工 👩‍🔧 +女画家 👩‍🎨 +女白领 👩‍💼 +女皇 👸 +女码农 👩‍💻 +女码畜 👩‍💻 +女科学家 👩‍🔬 +女程序员 👩‍💻 +女精灵 🧝‍♀️ +女老师 👩‍🏫 +女职员 👩‍💼 +女艺术家 👩‍🎨 +女行人 🚶‍♀️ +女行尸走肉 🧟‍♀️ +女衬衫 👚 +女装 👚 +女警官 👮‍♀️ +女警察 👮‍♀️ +女超人 🦸‍♀️ +女超级大反派 🦹‍♀️ +女超级英雄 🦸‍♀️ +女靴 👢 +女鞋 👠 +女飞行员 👩‍✈️ +女魔术师 🧙‍♀️ +女魔法师 🧙‍♀️ +奶奶 👵 +奶妈 👩‍🍼 +奶爸 👨‍🍼 +奶牛 🐄 +奶牛头 🐮 +奶瓶 🍼 +奶茶 🧋 +奶酪 🧀 +奶酪火锅 🫕 +奶黄 🍮 +奸笑的猫 😼 +好 👌 +好兄弟 👬 +好冷 🥶 (╯﹏╰) +好可爱 (*^-^*) +好吃 😋 ლ(´ω`ლ) +好哒 👌 +好嘛 🥺 +好困 =_= +好基友 👬 +好奇 😯 +好好 🆗 +好崇拜 🤩 +好崇拜哦 🤩 +好强 👍 +好棒 👍 +好次 ლ(´ω`ლ) +好热 🥵 +好的 👌 +好笑 🤭 +好耶 😆 +好诶 ε(*′・∀・`)з゙ +如释重负 😌 +妈妈 👩 +妖怪 🧞 +妖精 🧚‍♂️ +妹妹 🧍‍♀️ +姐姐 🧍‍♀️ +姓名牌 📛 +委内瑞拉 🇻🇪 +委内瑞拉玻利瓦尔共和国 🇻🇪 +委屈 🥹 +姜 🫚 +姥姥 👵 +姥爷 👴 +威士忌 🥃 +威尔士 🏴󠁧󠁢󠁷󠁬󠁳󠁿 +娥眉月 🌒 +婚礼 💒 +婚纱 👰‍♀️ +婴儿 👶 +婴儿标志 🚼 +婴儿符号 🚼 +媚眼 😉 +媳妇 😘 +嫩苗 🌱 +子午线 🌐 +子字旁 子 +子弹头高速列车 🚅 +孔雀 🦚 +孕妇 🤰 +孕肚 🤰 +字母 🔤 +字符 🔣 +孟加拉 🇧🇩 +孟加拉国 🇧🇩 +学士帽 🎓 +学校 🏫 +学生 👨‍🎓 +学童过街 🚸 +孩子 🧒 +孵化小鸡 🐣 +宇普西龙 υ +宇航员 🧑‍🚀 +守密 🤐 +安全 🔐 +安全别针 🧷 +安全帽 ⛑️ +安全背心 🦺 +安哥拉 🇦🇴 +安哥拉共和国 🇦🇴 +安圭拉 🇦🇮 +安培 A +安巴 🇦🇬 +安提瓜和巴布达 🇦🇬 +安检 🛂 +安道尔 🇦🇩 +安道尔公国 🇦🇩 +安静 🤫 +安静的脸 🤫 +完成 ✅ +宗教场所 🛐 +定时器 ⏲️ +宝宝 👶 +宝盖 宀 +宝盖头 宀 +宝石 💎 +实心括号 【】 +实验室外衣 🥼 +客满 🈵 +客船 🛳 +客轮 🛳️ +害怕 😱 +害怕猫 🙀 +害怕猫脸 🙀 +害怕的猫 🙀 +害羞 😳 (✿◡‿◡) +家 🏠 +家庭 👪 +家禽的腿 🍗 +家禽腿 🍗 +家蝇 🪰 +容我三思 🤔 +宽檐帽 👒 +宾馆 🏨 +寄存行李 🛅 +寄生虫 🪱 +密克罗尼西亚 🇫🇲 +密钥 🔐 +富士山 🗻 +对 ✅ +对不起 🙇‍♂️ +对决 🆚 +对勾 ✅ ✓ +对号 ✅ +对战 🆚 +对抗 🆚 +对数 ㏒ +对虾 🦐 +对话框 💬 +对话气泡 💬 +对钩 ✅ +寻呼机 📟 +导盲犬 🦮 +寿司 🍣 +封口 🤐 +射手座 ♐ +将军肚 🫃 +将字旁 丬 +尊敬 🫡 +尊重 🫡 +小丑 🤡 +小丑牌 🃏 +小丑脸 🤡 +小于 < +小于等于 ≤ +小仙女 🧚‍♀️ +小仙子 🧚‍♂️ +小兔子 🐇 +小公主 👸 +小写 🔡 +小写字母 🔡 +小刀 🔪 +小号 🎺 +小商店 🏪 +小圆点 ⚪ +小圆点儿 ⚪ +小型摩托车 🛵 +小型飞机 🛩️ +小声 🤫 +小声点 🤫 +小声点儿 🤫 +小天使 👼 +小女孩 👧 +小妖精 👺 +小孩 🧒 +小孩儿 🧒 +小孩子 🧒 +小宝贝 👶 +小宝贝儿 👶 +小屋 🛖 +小巴 🚐 +小强 🪳 +小括号 () +小提琴 🎻 +小数点 . +小方块 ▪ +小方片 🔸 +小时 h +小朋友 🧒 +小正方形 ▪ +小汽车 🚗 +小浣熊 🦝 +小点儿声 🤫 +小点声 🤫 +小熊 🐻 (・(ェ)・) +小牛 🐮 +小狗 🐕 +小猪 🐷 +小猫 🐱 +小猫咪 🐱 +小王 🃏 +小王子 🤴 +小电驴 🛵 +小男孩 👦 +小白兔 🐇 +小白框 ⬜ +小白点 ⚪ +小白点儿 ⚪ +小白菜 🥬 +小白鼠 🐁 +小盆友 🧒 +小精灵 🧝 +小红书 📕 +小羊 🐑 +小老鼠 🐁 +小节线 𝄀 +小苹果 🍎 +小菱形 🔸 +小蘑菇 🍄 +小蛇 🐍 +小蜜蜂 🐝 +小轿车 🚗 +小马 🐎 +小驴 🫏 +小鱼 🐟 +小鱼儿 🐟 +小鸟 🐦 (`・Θ・´) +小鸡 🐤 +小鸡头 🐤 +小鸡破壳 🐣 +小鸡鸡 🐥 +小鸭子 🦆 +小黑框 ⬛ +小黑点 ⚫ +小黑点儿 ⚫ +小黑猫 🐈‍⬛ +小龙虾 🦞 +少云 🌤 +少许 🤏 +少量 🤏 +尖叫 😱 +尖括号 <> +就是你 🫵 +尴尬 😅 ⊙﹏⊙‖∣° +尸字旁 尸 +尺子 📏 +尼加拉瓜 🇳🇮 +尼加拉瓜共和国 🇳🇮 +尼姑 👩‍🦲 +尼德兰 🇳🇱 +尼日利亚 🇳🇬 +尼日利亚联邦 🇳🇬 +尼日利亚联邦共和国 🇳🇬 +尼日尔 🇳🇪 +尼日尔共和国 🇳🇪 +尼泊尔 🇳🇵 +尾气 💨 +居民区 🏘 +居酒屋 🏮 +居酒屋灯笼 🏮 +屎 💩 +属于 ∈ +山 ⛰️ +山中日出 🌄 +山区铁路 🚞 +山地自行车 🚵‍♂️ +山地车 🚴 +山字旁 山 +山峰 ⛰️ +山羊 🐐 +山脉 ⛰️ +山顶 ⛰️ +山顶日出 🌄 +岩石 🪨 +崇拜 🤩 +崩溃 o(≧口≦)o +工人 👷 +工作 💼 +工作犬 🐕‍🦺 +工具 🛠 +工具箱 🧰 +工厂 🏭 +工厂工人 👨‍🏭 +工资 💰 +左 ⬅️ ← +左上 ↖️ ↖ +左上箭头 ↖️ +左下 ↙️ ↙ +左下箭头 ↙️ +左书名号 《 +左单引号 ‘ +左双引号 “ +左右 ↔️ ↔ +左右摇头 🙂‍↔️ +左右箭头 ↔️ +左向拳 🤛 +左引号 “ +左手 🫲 +左括号 ( +左拳 🤜 +左推 🫷 +左箭头 ⬅️ ← +左耳旁 阝 +左边 👈 +左钩拳 🤜 +左面 👈 +巧克力 🍫 +巧克力棒 🍫 +巨怪 🧌 +巨蟹座 ♋ +巨龙 🐉 +差劲 👎 +已静音的扬声器 🔇 +巴 bar +巴勒斯坦 🇵🇸 +巴勒斯坦领土 🇵🇸 +巴哈马 🇧🇸 +巴哈马国 🇧🇸 +巴基斯坦 🇵🇰 +巴基斯坦伊斯兰共和国 🇵🇰 +巴士 🚌 +巴巴多斯 🇧🇧 +巴布亚新几内亚 🇵🇬 +巴布亚新几内亚独立国 🇵🇬 +巴拉圭 🇵🇾 +巴拉圭共和国 🇵🇾 +巴拿马 🇵🇦 +巴拿马共和国 🇵🇦 +巴林 🇧🇭 +巴林王国 🇧🇭 +巴西 🇧🇷 +巴西联邦 🇧🇷 +巴西联邦共和国 🇧🇷 +巴西雷亚尔 R$ +布丁 🍮 +布告 🪧 +布告牌 🪧 +布基纳法索 🇧🇫 +布隆迪 🇧🇮 +布隆迪共和国 🇧🇮 +布韦岛 🇧🇻 +帆船 ⛵ +希沃特 Sv +希腊 🇬🇷 +希腊共和国 🇬🇷 +帐篷 ⛺ +帕劳 🇵🇼 +帕劳共和国 🇵🇼 +帕斯卡 Pa +带卷边的纸 📃 +带吸管杯 🥤 +带墨水笔的锁 🔏 +带框的画 🖼️ +带画框的图片 🖼 +带花园的楼房 🏡 +带蝴蝶结的帽子 👒 +带骨肉 🍖 +常青树 🌲 +帽子 🎩 +干你 👊 +干得好 👍 +干杯 🍻 +干脆面 🦝 +干脆面君 🦝 +平底杯 🥃 +平底锅 🍳 +平底鞋 🥿 +平方 ² +平方公里 km² +平方分米 dm² +平方千米 km² +平方厘米 cm² +平方米 m² +平米 m² +平行 ∥ +平静 😐 +并集 ∪ +幸福 🥰 +幸运草 🍀 +幸运饼干 🥠 +幼苗 🌱 +幽灵 👻 +广字旁 广 +庆典 🎈 +庆祝 🎉 +床 🛏️ +床上休息 🛌 +库仑 C +库克群岛 🇨🇰 +库拉索 🇨🇼 +底裤 🩲 +庙宇 🛕 +废墟 🏚️ +废弃的房屋 🏚 +废纸篓 🗑 +度 ° +度假 🏖 +度数 ° +座位 💺 +座机 ☎️ +座钟 🕰️ +庵点 〽️ +康加鼓 🪘 +建之旁 廴 +建筑工 👷‍♂️ +建筑工人 👷 +建筑施工 🏗 +开party 🥳 +开发人员 👨‍💻 +开启 🔛 +开始 ▶️ +开始录像 ⏺️ +开始录音 ⏺️ +开始战斗 ⚔️ +开始播放 ▶️ +开尔文 K +开心 😄 ~\(≥▽≤)/~ +开心猫 😺 +开心猫脸 😺 +开心的猫 😺 +开战 ⚔️ +开曼群岛 🇰🇾 +开特 kat +开玩笑 😝 +开花 🌼 +开花了 🌼 +开趴 🥳 +开锁 🔓 +开门营业 🈺 +开闪光灯的相机 📸 +开香槟 🍾 +异形怪兽 👾 +弓和箭 🏹 +弓字旁 弓 +弓箭 🏹 +弟弟 🧍‍♂️ +张开双手 👐 +弯月 🌙 +弯钩 🪝 +弱记号 p +弹出 ⏏️ +弹出按钮 ⏏️ +强 👍 +强壮 💪 +强忍泪水 🥹 +强记号 f +当心儿童 🚸 +当然 😎 +录像 ⏺️ +录像带 📼 +录像按钮 ⏺️ +录制按钮 ⏺️ +录影带 📼 +录音 ⏺️ +录音室麦克风 🎙 +录音按钮 ⏺️ +录音麦克风 🎙 +彗星 ☄️ +彩椒 🫑 +彩罐 🪅 +彩色碎纸 🎊 +彩虹 🌈 +彩虹旗 🏳️‍🌈 +影片帧 🎞️ +往上 👆 +往上指 👆 +往下 👇 +往下指 👇 +往右 👉 +往右指 👉 +往左 👈 +往左指 👈 +很冷 🥶 +很好 👍 +很差 👎 +很弱记号 pp +很强记号 ff +很棒 👍 +很热 🥵 +很酷 🆒 +徒步 🚶‍♂️ +得 🉐 +得意 😏 +得意地笑 😏 +得意猫 😼 +得意猫脸 😼 +得意的猫 😼 +循环 ♻️ +循环再生 ♻️ +循环利用 ♻️ +循环播放 🔁 +微克 μg +微分 ∂ +微微不满 🙁 +微怒 😠 +微生物 🦠 +微秒 μs +微笑 ☺️ +微笑亲亲 😙 +微笑天使 😇 +微笑恶魔 😈 +微笑猫 😸 +微笑猫脸 😸 +微笑的上弦月 🌛 +微笑的下弦月 🌜 +微笑的太阳 🌞 +微笑的恶魔 😈 +微笑的新月 🌚 +微笑的月亮 🌝 +微笑的满月 🌝 +微笑的猫 😸 +微米 μm +微赫 μHz +微醺 🥴 +德古拉 🧛‍♂️ +德国 🇩🇪 +德尔塔 δ +德意志 🇩🇪 +德意志联邦 🇩🇪 +德意志联邦共和国 🇩🇪 +心 💓 +心中箭了 💘 +心事重重 😔 +心伤 ❤️‍🩹 +心动 💓 +心叹号 ❣️ +心在燃烧 ❤️‍🔥 +心在着火 ❤️‍🔥 +心型装饰 💟 +心字底 心 +心形 💟 +心形装饰 💟 +心情不好 🙁 +心火 ❤️‍🔥 +心碎 💔 +心脏 🫀 +心脏器官 🫀 +心跳 💓 +心连心 💞 +忍住泪水 🥹 +忍术 🥷 +忍者 🥷 +忍耐 😣 +忧伤 😔 +忧虑 😔 +快上 ⏫ +快下 ⏬ +快艇 🚤 +快跑 💨 +快进 ⏩ +快进按钮 ⏩ +快进符号 ⏩ +快退 ⏪ +快退按钮 ⏪ +快退符号 ⏪ +快递 📦 +快餐 🍱 +快餐盒 🍱 +念珠 📿 +怀了 🤰 +怀孕 🤰 +怀孕的人 🫄 +怎么 🤔 +怒 💢 +怒火中烧 😡 +怕 😨 +思考 🤔 +思考气泡 💭 +急救车 🚑 +怪兽 👾 +怪物 👹 +总体平均 μ +恋爱 👩‍❤️‍👨 +恐怖 😱 ┌(。Д。)┐ +恐惧 😨 +恐惧尖叫 😱 +恐惧猫 🙀 +恐惧猫脸 🙀 +恐惧的猫 🙀 +恐龙 🦖 +恤衫 👕 +恭喜 🎉 +恳求的脸 🥺 +恶心 🤢 +恶魔 😈 +恶魔之眼 🧿 +恶魔微笑 😈 +恶魔生气 👿 +恶魔的眼 🧿 +恶魔眼 🧿 +悠悠球 🪀 +情书 💌 +情人宾馆 🏩 +情人旅店 🏩 +情人旅馆 🏩 +情人酒店 🏩 +情侣 💑 +情侣宾馆 🏩 +情侣旅店 🏩 +情侣旅馆 🏩 +情侣酒店 🏩 +情调 🫦 +惊叹 ❗ +惊叹号 ❗ +惊呆 !(◎_◎;) +惊喜 ╰(*°▽°*)╯ +惊声尖叫 😱 +惊讶 😲 +惊问 ⁉️ +想一想 🤔 +想吐 🤢 +想想 🤔 +意大利 🇮🇹 +意大利共和国 🇮🇹 +意大利面 🍝 +意大利面条 🍝 +意粉 🍝 +意面 🍝 +感冒 😷 +感叹号 ❗ ! +感叹号问号 ⁉️ +感恩 🤟 +感谢 🙏 +愣住 😳 o_o .... +愤怒 😡 +愤怒对话框 🗯 +愤怒对话气泡 🗯 +慢吞吞 🦥 +憨憨 😄 +懒猪 🐷 +戈瑞 Gy +成人 🔞 +成功 ✌️ +成年女人 👩 +成年女性 👩 +成年男人 👨 +成年男性 👨 +成本 💰 +成比例 ∝ +我不听 🙉 +我不敢看 🫣 +我不看 🙈 +我再想想 🤔 +我怒了 🤬 +我想想 🤔 +我是证人 👁️‍🗨️ +我晕 😵 +我服了 🤦‍♂️ +我服了你 🤦‍♂️ +我服了你了 🤦‍♂️ +我没事 🥲 +我爱你 🤟 I Love You..〆(・∀・*) +我看下 🧐 +我看看 🧐 +我知道 🙋‍♂️ +我裂开了 🤬 +戒指 💍 +战斗 ⚔️ +戴口罩 😷 +戴头巾 👳‍♂️ +戴头巾的人 👳‍♂️ +戴头纱的人 👰‍♀️ +戴王冠的人 🫅 +户字旁 户 +房子 🏠 +房屋 🏠 +房屋建筑 🏘️ +房屋群 🏘 +房门 🚪 +所以 ∴ +所罗门群岛 🇸🇧 +扁面包 🫓 +扇子 🪭 +手 ✋ +手写 ✍️ +手动刮胡刀 🪒 +手动轮椅 🦽 +手套 🧤 +手字旁 手 +手拉手 👭 +手拉手的情侣 👫 +手拿包 👝 +手指 🤌 +手指交叉 🤞 +手指张开 🖐 +手指张开的手 🖐 +手指捏和 🤌 +手掌 🖐️ +手掌捂脸 🤦‍♂️ +手掌朝上 🫴 +手掌朝下 🫳 +手推车 🛒 +手提包 👜 +手提电脑 💻 +手撕日历 📆 +手机 📱 +手机信号 📶 +手机关机 📴 +手机来电 📲 +手杖 🦯 +手枪 🔫 +手柄 🎮 +手球 🤾 +手电 🔦 +手电筒 🔦 +手背 🤚 +手表 ⌚ +手袋 👝 +手锯 🪚 +手风琴 🪗 +扑克 ♠️ +扑克牌 🃏 +打call 🤟 +打伞 ☂️ +打你 👊 +打击 👊 +打包 📦 +打印机 🖨️ +打呵欠 🥱 +打呼 💤 +打咩 🙅‍♂️ +打哈欠 🥱 (~O~) +打响指 🫰 +打喷嚏 🤧 +打坐 🧘‍♂️ +打对钩 ☑️ +打开的书 📖 +打开的书本 📖 +打开的锁 🔓 +打手球 🤾‍♂️ +打折 🈹 +打板 🎬 +打滚 🤣 +打滚儿 🤣 +打电话 📲 +打电话给我 🤙 +打瞌睡 😪 +打算盘 🧮 +打篮球 ⛹️‍♂️ +打结 🪢 +打给我 🤙 +打针 💉 +打钩 ☑️ +打雷 🌩️ +打高尔夫 🏌️‍♂️ +打高尔夫的人 🏌️‍♂️ +打鼓 🥁 +扔垃圾 🚮 +托克劳 🇹🇰 +扣针 🧷 +执照 🪪 +扩音器 📢 +扫帚 🧹 +扫把 🧹 +扬声器 🔈 +扬声器关闭 🔇 +扬手欢呼 🙌 +扳手 🔧 +扶额 🤦‍♂️ +技工 👨‍🔧 +技术员 👨‍💻 +抓狂 😫 (≧0≦) +投票 🗳 +投票箱 🗳️ +投降 🏳 Ψ( ̄(エ) ̄)Ψ +折扇 🪭 +抛媚眼 😉 +抛接 🤹‍♂️ +抛接杂耍 🤹 +抠鼻 (* ̄rǒ ̄) +护照检查 🛂 +护照检查处 🛂 +护目镜 🥽 +护肤霜 🧴 +报纸 📰 +报纸卷 🗞️ +报警 🚔 +报警灯 🚨 +披萨 🍕 +抬起 🫴 +抱 🤗 +抱在一起 🫂 +抱抱 🤗 +抽烟 🚬 +担心 😟 +担忧 😟 +拇指 👍 +拇指向上 👍 +拇指向下 👎 +拇指朝上 👍 +拇指朝下 👎 +拉丁文 🔠 +拉上拉链 🤐 +拉便便 💩 +拉大便 💩 +拉屎 💩 +拉拉 👩‍❤️‍👩 +拉炮彩带 🎉 +拉粑粑 💩 +拉脱维亚 🇱🇻 +拉脱维亚共和国 🇱🇻 +拉面 🍜 +拍手 👏 +拍掌 👏 +拍摄 🎬 +拍照 📷 +拍片 🩻 +拍电影 🎬 +拐杖 🩼 +拒绝 🙅 +拖拉机 🚜 +拖车 🚛 +拖鞋 🩴 +拜托 🙏 +拜拜 👋 ヾ( ̄▽ ̄)Bye~ +拜金 🤑 +拥抱 🤗 +拥抱的人 🫂 +括号 () +拳击 🥊 +拳击手套 🥊 +拳头 👊 +拼图 🧩 +拼图块 🧩 +拼块 🧩 +拼块儿 🧩 +挂钩 🪝 +指 🈯 +指人 🫵 +指你 🫵 +指北针 🧭 +指南针 🧭 +指我 🫵 +指甲油 💅 +指着 🫵 +指着你 🫵 +按摩 💆‍♂️ +按钮 ☑️ +挎包 👜 +挑眉 🤨 +挥手 👋 +挪威 🇳🇴 +挪威王国 🇳🇴 +振动 📳 +振动模式 📳 +振动频率 ν +捂上嘴巴 🤭 +捂上耳朵 🙉 +捂住嘴巴 🤭 +捂住耳朵 🙉 +捂嘴 🤭 +捂嘴巴 🤭 +捂嘴笑 🤭 +捂着嘴巴 🤭 +捂着嘴笑 🤭 +捂着耳朵 🙉 +捂耳 🙉 +捂耳朵 🙉 +捂脸 🤦‍♂️ +捏 🤏 +捏合 🤌 +捏手指 🤌 +捕鼠器 🪤 +换气记号 𝄐 +换汇 💱 +捣蛋 😜 +捧着 🤲 +捧花 💐 +捷克 🇨🇿 +捷克共和国 🇨🇿 +掌印 🐾 +掌心向上托起 🤲 +掌心朝上 🫴 +掌心朝下 🫳 +排球 🏐 +排骨 🍖 +探究 🧐 +探索 🧐 +接收天线 📡 +控制旋钮 🎛️ +推出 ⏏️ +推出按钮 ⏏️ +推手 🫷 +揍你 👊 +提取行李 🛄 +提子 🍇 +提手旁 扌 +提款机 🏧 +提琴 🎻 +提示丝带 🎗️ +插头 🔌 +握手 🤝 +握拳 ✊ +握紧的拳头 👊 +搋子 🪠 +搏动的心 💗 +搞定 🤝 +搭梯子 🪜 +摄像机 📹 +摄录机 🎥 +摄影机 🎥 +摄氏度 ℃ +摆手 👋 +摇头晃脑 🫨 +摇杆 🕹 +摇滚 🤘 +摇滚女歌手 👩‍🎤 +摇滚手势 🤘 +摇滚歌手 👨‍🎤 +摇滚男歌手 👨‍🎤 +摊手 ╮(﹀_﹀”)╭ +摔跤 🤼‍♂️ +摔跤选手 🤼‍♂️ +摩埃 🗿 +摩天轮 🎡 +摩尔 mol +摩尔多瓦 🇲🇩 +摩尔多瓦共和国 🇲🇩 +摩托 🏍 +摩托艇 🛥️ +摩托车 🏍️ +摩洛哥 🇲🇦 +摩洛哥王国 🇲🇦 +摩纳哥 🇲🇨 +摩纳哥公国 🇲🇨 +摩羯座 ♑ +摩艾石像 🗿 +撅嘴 🙎 +撅嘴女 🙎‍♀️ +撅嘴男 🙎‍♂️ +撇 丿 +撇嘴 🫤 ( ´•︵•` ) +撑伞 ☂️ +撒娇 ლ(╹◡╹ლ) +撒花 🎉 +撒谎 🤥 +播放 ▶️ +播放按钮 ▶️ +播放暂停 ⏯️ +操你妈 🤬 +操控杆 🕹 +擤鼻子 🤧 +擤鼻涕 🤧 +攀山 🧗‍♂️ +攀岩 🧗 +攀岩者 🧗‍♂️ +攀爬的人 🧗 +攀爬的女人 🧗‍♀️ +攀爬的男人 🧗‍♂️ +支字旁 支 +支持 🤟 +收件 📥 +收件箱 📥 +收信 📥 +收到 ( 'ω' ) get! +收到来信 📨 +收到邮件 📨 +收据 🧾 +收费 🈶 +收起的伞 🌂 +收起的雨伞 🌂 +收邮件 📩 +收音机 📻 +放声大哭 😭 +放大镜 🔍 +放射性 ☢️ +放手 🫳 +放映机 📽 +放着勺的碗 🥣 +放风筝 🪁 +救命 🆘 ▌°Д °;)っ +救我 🆘 +救护车 🚑 +救救我 🆘 +救火车 🚒 +救生 🛟 +救生圈 🛟 +救生衣 🦺 +教堂 ⛪ +教学楼 🏫 +教师 👨‍🏫 +教科书 📗 +敬礼 🫡 (`・ω・´)ゞ +数字 🔢 +敲鼓 🥁 +文书 📄 +文件 📄 +文件夹 📁 +文件柜 🗄️ +文字旁 文 +文莱 🇧🇳 +文莱达鲁萨兰国 🇧🇳 +斐 φ +斐济 🇫🇯 +斐济共和国 🇫🇯 +斑马 🦓 +斗字旁 斗 +斜眼笑 😆 +斤字旁 斤 +斧头 🪓 +斧子 🪓 +断链 ⛓️‍💥 +斯威士兰 🇸🇿 +斯威士兰王国 🇸🇿 +斯波克 🖖 +斯洛伐克 🇸🇰 +斯洛伐克共和国 🇸🇰 +斯洛文尼亚 🇸🇮 +斯洛文尼亚共和国 🇸🇮 +斯瓦尔巴和扬马延 🇸🇯 +斯里兰卡 🇱🇰 +斯里兰卡民主社会主义共和国 🇱🇰 +新 🆕 +新加坡 🇸🇬 +新加坡共和国 🇸🇬 +新喀里多尼亚 🇳🇨 +新娘 👰 +新娘子 👰‍♀️ +新干线 🚄 +新建 🆕 +新月 🌑 +新月形面包 🥐 +新疆瓜 🍈 +新的 🆕 +新西兰 🇳🇿 +新邮件 📨 +新郎 🤵 +新闻 📰 +方向 🧭 +方块 ⬛ ♢ +方字旁 方 +方形 ⬛ +方形按钮 🔳 +方括号 [] +方板 ♦️ +方框 □ +方片 ♦️ +方程式赛车 🏎 +施工 🏗️ +施工中 🚧 +施工符号 🚧 +旅店 🏨 +旅行 ✈️ +旅馆 🏨 +旋转木马 🎠 +旋转的心 💞 +旋转角速度 ω +旋钮 🎛 +旋风 🌀 +旗子 🚩 +旗帜 🚩 +旗杆上的三角旗 🚩 +旗杆上的旗帜 🚩 +无 🈚 +无人岛 🏝 +无人荒岛 🏝️ +无叶树 🪾 +无奈 😮‍💨 ╮(╯▽╰)╭ +无尾熊 🐨 +无新信件 📭 +无穷 ♾️ +无线 🛜 +无线局域网 🛜 +无线电 📻 +无线网 🛜 +无线网络 🛜 +无聊 🥱 +无语 😑 (°ー°〃) +无轨电车 🚎 +日 ☀️ +日元 ¥ +日元钞票 💴 +日出 🌅 +日历 📅 +日字旁 日 +日式城堡 🏯 +日本 🇯🇵 +日本人形 🎎 +日本剪影 🗾 +日本国 🇯🇵 +日本地图 🗾 +日本城堡 🏯 +日本邮局 🏣 +日落 🌇 +日记本 📔 +旧钥匙 🗝 +早 ☀️ +早上 ☀️ +旭日 🌄 +旭日东升 🌄 +旱冰 🛼 +旱冰鞋 🛼 +时间常数 τ +旺柴 🐶 +旺财 🐶 +昆虫 🪲 +明光 ☀️ +昏昏沉沉 🥴 +星 🌟 +星光闪闪的夜晚 🌃 +星号 *️⃣ * +星号键 *️⃣ +星星 🌟 +星星眼 ✨ (☆_☆) +星月 ☪️ +星条旗 🇺🇸 +星球 🪐 +星空 🌌 +星钻 💍 +星际迷航 🖖 +显微镜 🔬 +显示器 🖥 +显示屏 🖥 +晃脑 🫨 +晕 😵 +晕了 😵 +晕头 😵 +晕头转向 😵 +晚上 🌃 +晚安 🌙 +普西 ψ +晴偶有云 🌤️ +晴天 ☀️ +晴转雨 🌦️ +智利 🇨🇱 +智利共和国 🇨🇱 +智能手机 📱 +暂停 ⏸️ +暂停按钮 ⏸️ +暮色 🌆 +暮色中的城市 🌆 +暴涨 📈 +暴跌 📉 +暴龙 🦖 +暹罗 🇹🇭 +曲别针 📎 +曲奇 🍪 +曲奇饼干 🍪 +曲棍球 🏑 +月 🈷️ +月亮 🌙 +月团 🥮 +月字旁 月 +月牙 🌙 +月球 🌕 +月食 🌒 +月饼 🥮 +有 🈶 +有料 🈶 +有新信件 📬 +有环行星 🪐 +有眼袋 🫩 +有空 🈳 +有蛋的巢 🪺 +有蛋的鸟巢 🪺 +有轨电车 🚋 +有雾 🌁 +朋克 👨‍🎤 +朋友 👭 +服了 🤦‍♂️ +服了你了 🤦‍♂️ +服务员 💁‍♂️ +服务犬 🐕‍🦺 +服务生 💁‍♂️ +服务费 🈂️ +服务铃 🛎️ +朔月 🌑 +望远镜 🔭 +朝上 🫴 +朝下 🫳 +朝右 🫱 +朝右的拳头 🤜 +朝左 🫲 +朝左的对话气泡 🗨 +朝左的拳头 🤛 +朝左的话语气泡 🗨️ +朝鲜 🇰🇵 +木头 🪵 +木字旁 木 +木工锯 🪚 +木材 🪵 +木槿 🌺 +木槿花 🌺 +未成年 🔞 +未成年不宜 🔞 +本科生 👨‍🎓 +朱古力 🍫 +朱古力棒 🍫 +机器人 🤖 +机械手臂 🦾 +机械腿 🦿 +机械臂 🦾 +杂耍 🤹 +条形图 📊 +来一根 🚬 +来一根儿 🚬 +来了 ~(~ ̄▽ ̄)~ +来信 📨 +来电 📲 +杯子 🥛 +松了一口气 😌 +松了口气 😌 +松口气 😌 +松树 🌲 +松鼠 🐿️ +板儿砖 🧱 +板儿鞋 👟 +板栗 🌰 +板球 🏏 +板球拍 🏏 +板砖 🧱 +板鞋 👟 +极度痛苦 😧 +果汁 🧃 +果汁盒 🧃 +果饮 🍹 +枫叶 🍁 +枫树叶 🍁 +枯萎 🥀 +枯萎的玫瑰 🥀 +枯萎的花 🥀 +柑橘 🍊 +柔 ρ +柔道服 🥋 +柠檬 🍋 +柬埔寨 🇰🇭 +柬埔寨王国 🇰🇭 +柱形图 📊 +柱状图 📊 +标准大气压 atm +标准差 σ +标签 🏷️ +标签页 📑 +标语 🪧 +标语牌 🪧 +树 🌳 +树叶 🍃 +树懒 🦥 +树懒先生 🦥 +树木 🌳 +树袋熊 🐨 +栗子 🌰 +株式会社 ㈱ +根号 √ +根西岛 🇬🇬 +格子旗 🏁 +格子饼 🧇 +格斗 🤼‍♂️ +格林纳达 🇬🇩 +格陵兰 🇬🇱 +格鲁吉亚 🇬🇪 +桃 🍑 +桃子 🍑 +桑拿 🧖 +桥 🌉 +桶 🪣 +梁龙 🦕 +梅利利亚 🇪🇦 +梅花 ♣️ ♧ +梨 🍐 +梨子 🍐 +梯子 🪜 +梳子 🪮 +梵蒂冈 🇻🇦 +梵蒂冈城国 🇻🇦 +检查护照 🛂 +棒 👍 +棒啊 👍 +棒棒 👍 +棒棒哒 👍 +棒棒的 👍 +棒棒糖 🍭 +棒球 ⚾ +棒球帽 🧢 +棕心 🤎 +棕榈 🌴 +棕榈树 🌴 +棕色 🟤 +棕色圆 🟤 +棕色心 🤎 +棕色方块 🟫 +棕色的心 🤎 +棺材 ⚰️ +椅子 🪑 +椒盐卷饼 🥨 +椰子 🥥 +椰子果 🥥 +榛子 🌰 +横折 𠃍 +横折钩 𠃌 +樱桃 🍒 +樱花 🌸 +橄榄 🫒 +橄榄球 🏈 +橘子 🍊 +橘心 🧡 +橘猫 🐈 +橘色 🟠 +橘色书本 📙 +橘色大菱形 🔶 +橘色小菱形 🔸 +橘色心 🧡 +橘色的书 📙 +橘色的心 🧡 +橘色菱形 🔶 +橘黄色 🟠 +橘黄色大菱形 🔶 +橘黄色小菱形 🔸 +橘黄色菱形 🔶 +橙子 🍊 +橙心 🧡 +橙色 🟠 +橙色书本 📙 +橙色圆 🟠 +橙色大菱形 🔶 +橙色小菱形 🔸 +橙色心 🧡 +橙色方块 🟧 +橙色的书 📙 +橙色的心 🧡 +橙色菱形 🔶 +次方 ^ +欢呼 🎉 \(@^0^@)/ +欢迎 👏 ( ^∀^)/欢迎~ +欣慰 🥲 +欧元 💶 € +欧元钞票 💶 +欧姆 Ω +欧式城堡 🏰 +欧洲 🌍 +欧洲地球仪 🌍 +欧洲城堡 🏰 +欧洲联盟 🇪🇺 +欧洲邮局 🏤 +欧洲非洲 🌍 +欧洲非洲地球仪 🌍 +欧盟 🇪🇺 +欧米伽 ω +欧非地球仪 🌍 +歌手 👨‍🎤 +歌记号 〽️ +正三角 🔺 +正中靶心的飞镖 🎯 +正方形 ⬛ +正无穷 +∞ +正确 ⭕ +正负 ± +正负号 ± +武术 🥋 +武术服 🥋 +死亡 💀 +残月 🌘 +残疾人 🦽 +母乳 🤱 +母乳喂养 🤱 +母亲 👩 +母女 👩‍👧 +母婴室 🚼 +母子 👩‍👦 +母羊 🐑 +母鸡 🐓 +比 🆚 +比中指 🖕 +比利时 🇧🇪 +比利时王国 🇧🇪 +比基尼 👙 +比心 🫰 (。・ω・。)ノ♡ +比心手势 🫰 +比萨 🍕 +比萨饼 🍕 +比赛 🆚 +毕业 👨‍🎓 +毕业帽 🎓 +毛毛虫 🐛 +毛线 🧶 +毛线球 🧶 +毛虫 🐛 +毛里塔尼亚 🇲🇷 +毛里塔尼亚伊斯兰共和国 🇲🇷 +毛里求斯 🇲🇺 +毛里求斯共和国 🇲🇺 +毫克 mg +毫升 mL +毫秒 ms +毫米 mm +毫米汞柱 mmHg +毫赫 mHz +气人 😡 +气旋 🌀 +气死我了 🤬 +气泡 🫧 +气炸了 🤬 +气球 🎈 +水字旁 水 +水拔子 🪠 +水晶球 🔮 +水果刀 🔪 +水果蛋糕 🍰 +水果饮料 🍹 +水枪 🔫 +水桶 🪣 +水母 🪼 +水滴 💧 +水牛 🐃 +水獭 🦦 +水珠 💦 +水球 🤽 +水瓶座 ♒ +水稻 🌾 +水管工 👨‍🔧 +水饺 🥟 +水龙头 🚰 +求你了 🥺 +求和 ∑ +求婚戒 💍 +求帮忙 (✿✪‿✪。)ノ +求救 🆘 +求救信号 🆘 +求救讯号 🆘 +求求你 🥺 +求求你了 🥺 +汇率 💱 +汉堡 🍔 +汉堡包 🍔 +汗 😓 (─.─||) +汗滴 💧 +汗蒸 🧖 +汗衫 👕 +汤 🥣 +汤加 🇹🇴 +汤加王国 🇹🇴 +汤勺 🥄 +汤匙 🥄 +汤圆 ⚪ +汤碗 🥣 +汽车 🚗 +汽车尾气 💨 +沉思 😔 +沉默 😶 +沐浴露 🧴 +沙发 🛋 +沙发和灯 🛋️ +沙拉 🥗 +沙滩伞 🏖️ +沙滩凉鞋 🩴 +沙漏 ⌛ +沙漠 🏜️ +沙特 🇸🇦 +沙特阿拉伯 🇸🇦 +沙特阿拉伯王国 🇸🇦 +沙球 🪇 +沙锤 🪇 +没嘴 😶 +没有 🈚️ +没有嘴 😶 +没有嘴巴 😶 +没有空位 🈵 +没电了 🪫 +没问题 👌 +沮丧 ( ´•︵•` ) +河狸 🦫 +河豚 🐡 +河马 🦛 +油桶 🛢 +油泵 ⛽ +油灯 🪔 +油炸面圈 🍩 +油炸鹰嘴豆饼 🧆 +油酥点心 🥧 +泊 P +泊车 🅿️ +法兰西 🇫🇷 +法兰西共和国 🇫🇷 +法国 🇫🇷 +法官 🧑‍⚖️ +法属南部领地 🇹🇫 +法属圣马丁 🇲🇫 +法属圭亚那 🇬🇫 +法属波利尼西亚 🇵🇫 +法师 🧙 +法式长棍面包 🥖 +法拉 F +法拉费 🧆 +法棍 🥖 +法罗群岛 🇫🇴 +法蒂玛 🪬 +法蒂玛之手 🪬 +泛舟 🚣‍♂️ +泡泡 🫧 +泡澡 🛀 +泡澡的人 🛀 +泡茶 🍵 +波兰 🇵🇱 +波兰共和国 🇵🇱 +波利尼西亚 🇵🇫 +波多黎各 🇵🇷 +波斯尼亚和黑塞哥维那 🇧🇦 +波斯菊 🌼 +波浪 🌊 +波浪号 ~ +波浪型破折号 〰️ +波浪线 〰️ +波霸奶茶 🧋 +波黑 🇧🇦 +注册 ®️ +注册商标 ® +注册商标符号 ® +注册标记 ® +注射器 💉 +注意 ⚠️ +注意儿童 🚸 +注视 👀 +泪奔 ~o(>_<)o ~ +泰国 🇹🇭 +泰王国 🇹🇭 +泰迪 🧸 +泰迪熊 🧸 +泰铢 ฿ +泳圈 🛟 +泳客 🏊‍♂️ +泳衣 🩱 +泳装 🩱 +泳镜 🥽 +泼溅 🫟 +泽塔 ζ +泽西 🇯🇪 +泽西岛 🇯🇪 +洋芋 🥔 +洋葱 🧅 +洋蒜 🧅 +洗发水 🧴 +洗发露 🧴 +洗手间 🚻 +洗澡 🛀 +洗澡的人 🛀 +洞 🕳️ +津巴布韦 🇿🇼 +津巴布韦共和国 🇿🇼 +津津有味 😋 +洪都拉斯 🇭🇳 +洪都拉斯共和国 🇭🇳 +活塞 🪠 +派 π +派对 🥳 +派对礼宾花 🎉 +派对脸 🥳 +流口水 🤤 +流明 lm +流星 🌠 +流汗 😓 +流泪 😢 ╥﹏╥ +流眼泪 😢 +流通 💱 +浅肤色 🏻 +浣熊 🦝 +浪 🌊 +浪花 🌊 +浴缸 🛁 +浴衣 👘 +海关 🛃 +海关检查 🛃 +海地 🇭🇹 +海地共和国 🇭🇹 +海狮 🦭 +海狸 🦫 +海獭 🦦 +海王 🧜‍♂️ +海盗 🏴‍☠️ +海盗旗 🏴‍☠️ +海绵 🧽 +海蜇 🪼 +海螺 🐚 +海豚 🐬 +海豹 🦭 +海贼 🏴‍☠️ +海贼旗 🏴‍☠️ +海运 🚢 +海里 nmi +海龟 🐢 +涂指甲油 💅 +消失 🫠 +消防员 🧑‍🚒 +消防车 🚒 +淋浴 🚿 +淡蓝心 🩵 +淡蓝色心 🩵 +淡蓝色的心 🩵 +深夜 🌃 +深思 🤔 +深思的脸 🤔 +深潜 🤿 +清洗 🧽 +清理 🆑 +清真 🕌 +清真寺 🕌 +清酒 🍶 +清除 🆑 +渐弱记号 > +渐强记号 < +渔竿 🎣 +渡渡鸟 🦤 +渡船 ⛴ +渡轮 ⛴️ +温度 ℃ +温度计 🌡️ +温泉 ♨️ +游乐园 🎠 +游戏 🎮 +游戏手柄 🎮 +游戏摇杆 🕹 +游戏操控杆 🕹 +游泳 🏊‍♂️ +游泳圈 🛟 +游泳者 🏊‍♂️ +游泳镜 🥽 +溜冰 ⛸ +溜冰鞋 ⛸ +溜溜球 🪀 +溜石 🥌 +溶解 🫠 +滋水枪 🔫 +滑冰 ⛸️ +滑冰鞋 🛼 +滑块 🎚 +滑旱冰 🛼 +滑板 🛹 +滑板车 🛴 +滑梯 🛝 +滑滑梯 🛝 +滑稽 🤪 +滑雪 ⛷️ +滑雪板 🏂 +滑雪橇 🎿 +滑雪的人 ⛷️ +滑雪装备 🎿 +滑音记号 ⌒ +滚 😡 +满 🈵 +满了 🈵 +满分 💯 +满客 🈵 +满座 🈵 +满月 🌕 +满面寒霜 🥶 +滴血 🩸 +漩涡 🌀 +漫画家 👨‍🎨 +潜水 🤿 +潜水面罩 🤿 +澳亚地球仪 🌏 +澳元 A$ +澳大利亚 🇦🇺 +澳大利亚联邦 🇦🇺 +澳洲 🌏 +澳洲亚洲 🌏 +澳洲地球仪 🌏 +澳門 🇲🇴 +澳门 🇲🇴 +澳门特别行政区 🇲🇴 +火 🔥 +火上之心 ❤️‍🔥 +火了 🔥 +火大 💢 (`へ´≠) +火字旁 火 +火山 🌋 +火山喷发 🌋 +火山爆发 🌋 +火烈鸟 🦩 +火焰 🔥 +火箭 🚀 +火花 ❇️ +火车 🚂 +火车站 🚉 +火锅 🍲 +火鸡 🦃 +灭火器 🧯 +灯 💡 +灯泡 💡 +灯神 🧞‍♂️ +灯笼 🏮 +灯笼椒 🫑 +灰心 🩶 +灰色心 🩶 +灰色的心 🩶 +灵感 💡 +灵柩 ⚰️ +炸弹 💣 +炸药 🧨 +炸虾 🍤 +炸豆丸子 🧆 +点 . +点心 🥧 +点烟 🚬 +点燃 🔥 +点赞 👍 +烈酒 🥃 +烘焙 🥯 +烙饼 🥞 +烛光 🕯 +烛台 🕎 +烛火 🕯 +烟 🚬 +烟肉 🥓 +烟花 🎆 +烤地瓜 🍠 +烤烟 🚬 +烤甘薯 🍠 +烤番薯 🍠 +烤红薯 🍠 +烦死 😡 +烦死了 😡 +烦死人 😡 +烦死啦 😡 +热 🥵 +热带水果饮料 🍹 +热带饮料 🍹 +热带鱼 🐠 +热死 🥵 +热死了 🥵 +热狗 🌭 +热茶 🍵 +热饮 ☕ +烹饪 🍳 +焦头烂额 😖 +焦耳 J +焦虑 😰 +焦躁 😩 +焰火 🎆 +然后 😂 +煎蛋 🍳 +煎饼 🥞 +煎饼馃子 🌯 +照明 🔦 +照相机 📷 +煮熟的饭 🍚 +熊 🐻 (● ̄(エ) ̄●) +熊头 🐻 +熊猫 🐼 +熊猫人 🐼 +熊猫头 🐼 +熊猫脸 🐼 +熊脸 🐻 +燃烧 🔥 +燃烧的心 ❤️‍🔥 +燕尾服 🤵‍♂️ +爆哭 ε(┬┬_┬┬)3 +爆头 🤯 +爆炸 💥 +爆炸头 🤯 +爆炸脑 🤯 +爆竹 🧨 +爆米花 🍿 +爆辣 🥵 +爪印 🐾 +爪字头 爫 +爬梯 🪜 +爬梯子 🪜 +爱 ♥️ +爱你 😘 ♡(*^ω^*)♡ +爱你手势 🤟 +爱你的手势 🤟 +爱尔兰 🇮🇪 +爱尔兰共和国 🇮🇪 +爱心 ❤️ +爱心环绕 🥰 +爱心眼睛 🥰 +爱情 💓 +爱情宾馆 🏩 +爱情旅店 🏩 +爱情旅馆 🏩 +爱情酒店 🏩 +爱慕 🥰 +爱沙尼亚 🇪🇪 +爱沙尼亚共和国 🇪🇪 +爱火 ❤️‍🔥 +爱的礼物 💝 +父亲 👨 +父女 👨‍👧 +父子 👨‍👦 +爷爷 👴 +爸爸 👨 +版权 © +版权符号 © +牙 🦷 +牙买加 🇯🇲 +牙刷 🪥 +牙牙 🦷 +牙齿 🦷 +牛 🐮 +牛仔 🤠 +牛仔帽 🤠 +牛仔帽脸 🤠 +牛仔裤 👖 +牛头 🐮 +牛奶 🥛 +牛奶杯 🥛 +牛字旁 牛 +牛排 🥩 +牛油果 🥑 +牛牛 🐮 +牛米 N·m +牛脸 🐮 +牛角包 🥐 +牛角面包 🥐 +牛逼 👍 +牛顿 N +牡蛎 🦪 +物体密度 ρ +物流 🚚 +物理 ⚛️ +牵手 🧑‍🤝‍🧑 +牵手的情侣 👫 +特克斯和凯科斯群岛 🇹🇨 +特别酷 🆒 +特斯拉 T +特棒 👍 +特立尼达和多巴哥 🇹🇹 +特立尼达和多巴哥共和国 🇹🇹 +特里斯坦达库尼亚群岛 🇹🇦 +犀牛 🦏 +犬 🐕 +犬字旁 犬 +犹太教会堂 🕍 +犹太教堂 🕍 +狐狸 🦊 +狐狸偷 🦊 +狐狸脸 🦊 +狗 🐶 +狗仔 🐶 +狗头 🐶 +狗子 🐕 +狗狗 🐕 +狗脸 🐶 +独木舟 🛶 +独角兽 🦄 +独角兽头 🦄 +独角兽脸 🦄 +狮 🦁 +狮头 🦁 +狮子 🦁 +狮子头 🦁 +狮子座 ♌ +狮子脸 🦁 +狮脸 🦁 +狼 🐺 +狼头 🐺 +狼脸 🐺 +猎豹 🐆 +猕猴桃 🥝 +猛摇头 🫨 +猛犸 🦣 +猛犸象 🦣 +猩猩 🦍 +猪 🐷 +猪头 🐷 +猪排 🥩 +猪猪 🐷 +猪脸 🐷 +猪鼻 🐽 +猪鼻子 🐽 (^(OO)^) +猫 🐈 +猫咪 🐱 +猫头 🐱 +猫头鹰 🦉 +猫熊 🐼 +猫脸 🐱 +猴 🐒 +猴头 🐵 +猴子 🐒 +猴脸 🐵 +獾 🦡 +玉米 🌽 +王冠 👑 +王子 🤴 +玩具枪 🔫 +玩手球 🤾‍♂️ +玩水球 🤽‍♂️ +玩球 ⛹️ +玩篮球 ⛹️‍♂️ +玫瑰 🌹 +玫瑰花 🌹 +玫瑰花结 🏵 +环保 ♻️ +玻利维亚 🇧🇴 +玻利维亚国 🇧🇴 +玻璃 👨‍❤️‍👨 +玻璃杯 🥃 +玻璃瓶 🫙 +珊瑚 🪸 +珊瑚礁 🪸 +珍珠奶茶 🧋 +班卓琴 🪕 +球拍 🏸 +球网 🥅 +球门 🥅 +球门网 🥅 +理发 💇‍♂️ +理发店 💈 +理发店招牌 💈 +理头 💇‍♂️ +理头发 💇‍♂️ +琴键 🎹 +瑙鲁 🇳🇷 +瑙鲁共和国 🇳🇷 +瑜伽 🧘‍♂️ +瑜伽女 🧘‍♀️ +瑜伽男 🧘‍♂️ +瑞典 🇸🇪 +瑞典王国 🇸🇪 +瑞士 🇨🇭 +瑞士联邦 🇨🇭 +瑞思拜 🫡 +瓜德罗普 🇬🇵 +瓜皮帽 👲 +瓢虫 🐞 +瓦利斯和富图纳 🇼🇫 +瓦努阿图 🇻🇺 +瓦努阿图共和国 🇻🇺 +瓦特 W +瓦肯 🖖 +瓦肯举手礼 🖖 +瓶子 🫙 +甘薯 🍠 +甜品 🍮 +甜椒 🫑 +甜点 🍮 +甜瓜 🍈 +甜甜圈 🍩 +甜筒 🍦 +生化危机 ☣️ +生日 🎂 Happy♪ヾ|。・∀・。|ノ"" Birthday♪ +生日蛋糕 🎂 +生气 😠 ヽ(`⌒´メ)ノ +生气恶魔 👿 +生气猫 😾 +生气猫脸 😾 +生气的恶魔 👿 +生气的猫 😾 +生气符号 💢 +生物危害 ☣️ +生生不息 🖖 +生病 😷 +生蚝 🦪 +甩头 🫨 +田字旁 田 +甲虫 🪲 +申 🈸 +申请 🈸 +电动车 🛵 +电动轮椅 🦼 +电台 📻 +电吉他 🎸 +电子伏特 eV +电子游戏手柄 🎮 +电子邮件 📧 +电工 👨‍🔧 +电平滑块 🎚️ +电影 🎬 +电影摄影机 🎥 +电影放映机 📽️ +电影胶片 🎞 +电影院 🎦 +电插头 🔌 +电梯 🛗 +电池 🔋 +电池不足 🪫 +电池电量不足 🪫 +电源 🔋 +电源插头 🔌 +电灯 💡 +电灯泡 💡 +电瓶车 🛵 +电筒 🔦 +电脑 💻 +电脑光盘 💽 +电脑键盘 ⌨️ +电脑鼠标 🖱️ +电视 📺 +电视机 📺 +电话 ☎️ +电话听筒 📞 +电话筒 📞 +电车 🚃 +电量 🔋 +电量不足 🪫 +电量低 🪫 +男 ♂ +男丧尸 🧟‍♂️ +男举重运动员 🏋️‍♂️ +男人 👨 +男人鱼 🧜‍♂️ +男仔 🧍‍♂️ +男侦探 🕵️‍♂️ +男修理工 👨‍🔧 +男僵尸 🧟‍♂️ +男农民工 👷‍♂️ +男办公室职员 👨‍💼 +男化学家 👨‍🔬 +男医护人员 👨‍⚕️ +男医生 👨‍⚕️ +男卫兵 💂‍♂️ +男卫士 💂‍♂️ +男厕 🚹 +男厕所 🚹 +男厨师 👨‍🍳 +男反派 🦹‍♂️ +男同 👨‍❤️‍👨 +男同性恋 👨‍❤️‍👨 +男吸血鬼 🧛‍♂️ +男坏蛋 🦹‍♂️ +男士 🚹 +男士标志 🚹 +男士符号 🚹 +男士衬衫 👕 +男大反派 🦹‍♂️ +男大夫 👨‍⚕️ +男妖怪 🧞‍♂️ +男学生 👨‍🎓 +男孩 👦 +男宇航员 👨‍🚀 +男小精灵 🧝‍♂️ +男工人 👨‍🏭 +男工厂工人 👨‍🏭 +男巫 🧙‍♂️ +男建筑工 👷‍♂️ +男建筑工人 👷‍♂️ +男开发人员 👨‍💻 +男性 ♂ +男性符号 ♂️ +男技工 👨‍🔧 +男技术员 👨‍💻 +男摇滚歌手 👨‍🎤 +男教师 👨‍🏫 +男新娘 👰‍♂️ +男服务员 💁‍♂️ +男服务生 💁‍♂️ +男歌手 👨‍🎤 +男水管工 👨‍🔧 +男法官 👨‍⚖️ +男法师 🧙‍♂️ +男洗手间 🚹 +男消防员 👨‍🚒 +男漫画家 👨‍🎨 +男灯神 🧞‍♂️ +男生举手 🙋‍♂️ +男生举重 🏋️‍♂️ +男生冲浪 🏄‍♂️ +男生划船 🚣‍♂️ +男生按摩 💆‍♂️ +男生捂脸 🤦‍♂️ +男生摔跤 🤼‍♂️ +男生游泳 🏊‍♂️ +男生玩球 ⛹️‍♂️ +男生理发 💇‍♂️ +男生耸肩 🤷‍♂️ +男生跑步 🏃‍♂️ +男生鞠躬 🙇‍♂️ +男电工 👨‍🔧 +男画家 👨‍🎨 +男白领 👨‍💼 +男码农 👨‍💻 +男码畜 👨‍💻 +男科学家 👨‍🔬 +男程序员 👨‍💻 +男精灵 🧝‍♂️ +男老师 👨‍🏫 +男职员 👨‍💼 +男艺术家 👨‍🎨 +男行人 🚶‍♂️ +男行尸走肉 🧟‍♂️ +男衬衫 👕 +男警官 👮‍♂️ +男警察 👮‍♂️ +男超人 🦸‍♂️ +男超级大反派 🦹‍♂️ +男超级英雄 🦸‍♂️ +男鞋 👞 +男飞行员 👨‍✈️ +男魔术师 🧙‍♂️ +男魔法师 🧙‍♂️ +画叉 ❌ +画家 👨‍🎨 +画框 🖼 +画画 🎨 +画笔 🖌️ +画面太美我不敢看 🫣 +留尼汪 🇷🇪 +略略略 😛 +番茄 🍅 +番薯 🍠 +疑惑 🤔 +疑问 🤔 (‧_‧?) +疑问惊叹 ⁉️ +疑问惊叹号 ⁉️ +疲倦 😩 +疲倦的猫 🙀 +疲劳 😩 +疲惫 😫 +病字头 疒 +病毒 🦠 +痛哭 😭 +痛苦 😣 +瘟疫公司 ☣️ +登字头 癶 +登山 🧗‍♂️ +登山者 🧗‍♂️ +登山鞋 🥾 +白俄 🇧🇾 +白俄罗斯 🇧🇾 +白兔 🐇 +白发 🧑‍🦳 +白叹号 ❕ +白大褂 🥼 +白大褂儿 🥼 +白天鹅 🦢 +白子 ⚪ +白心 🤍 +白惊叹号 ❕ +白感叹号 ❕ +白手杖 🦯 +白旗 🏳 +白日小云 🌤 +白框 ⬜ +白棋 ⚪ +白点 ⚪ +白炽灯 💡 +白眼 🙄 +白眼儿 🙄 +白线方形按钮 🔳 +白线正方形按钮 🔳 +白羊座 ♈ +白色 ⚪ +白色中方块 ◼ +白色中等方块 ◼ +白色叹号 ❕ +白色圆 ⚪ +白色大方块 ◾ +白色小方块 ▪ +白色心 🤍 +白色惊叹号 ❕ +白色感叹号 ❕ +白色方形按钮 🔳 +白色旗子 🏳 +白色旗帜 🏳 +白色正方形按钮 🔳 +白色的心 🤍 +白色的花 💮 +白色问号 ❔ +白花 💮 +白菜 🥬 +白边线方形按钮 🔳 +白边线正方形按钮 🔳 +白问号 ❔ +白领 👨‍💼 +白鲨 🦈 +白鼠 🐁 +百分 % +百分之 % +百分号 % +百分百 100% +百合花 ⚜️ +百合花饰 ⚜️ +百吉圈 🥯 +百吉饼 🥯 +百慕大 🇧🇲 +百货商店 🏬 +皂 🧼 +的士 🚕 +皇冠 👑 +皇族 👑 +皮包 👝 +皮卡 🛻 +皮卡车 🛻 +皮塔三明治 🥙 +皮特凯恩群岛 🇵🇳 +皮秒 ps +皮纳塔 🪅 +皮老虎 🪠 +皮芽子 🧅 +皮赫 pHz +皮鞋 👞 +皱眉 🙍 ╮( •́ω•̀ )╭ +皱眉女 🙍‍♀️ +皱眉男 🙍‍♂️ +皱着眉头 🙍‍♂️ +皱着眉头的人 🙍‍♂️ +盆栽 🪴 +盆栽植物 🪴 +盈凸月 🌔 +盎司 oz +盐 🧂 +盒子 📦 +盒饭 🍱 +盘绕式炸鱼饼 🍥 +盘腿 🧘 +盘腿的人 🧘 +盘腿的女人 🧘‍♀️ +盘腿的男人 🧘‍♂️ +盥洗室 🚻 +目字旁 目 +目瞪口呆 😦 +盲杖 🦯 +直升机 🚁 +直升飞机 🚁 +直尺 📏 +直布罗陀 🇬🇮 +直饮水 🚰 +相似 ∽ +相机 📷 +相爱 👩‍❤️‍👨 +盾牌 🛡️ +省略号 …… +眉月 🌒 +眉毛上挑 🤨 +眉毛上挑的脸 🤨 +看 👀 +看看 👀 +真好 o(^▽^)o +真棒 👍 +眨眼 😉 ^_~ +眯眼 😝 +眯眼吐舌 😝 +眯眼笑 😆 +眼 👀 +眼冒星星 🤩 +眼睛 👁️ +眼睛对话框 👁️‍🗨️ +眼镜 👓 +着火 🔥 +着火了 🔥 +着重号 · +着陆 🛬 +睁眼捂嘴 🫢 +睡 😴 +睡了 😴 +睡眠 😴 +睡眠符号 💤 +睡着 💤 +睡着了 😴 +睡觉 💤 (~ o ~)~zZ +睡觉的人 🛌 +瞌睡 😪 +瞳孔 👁 +矢字旁 矢 +矩形 ⬛ +短刀 🗡 +短剑 🗡 +短袖 👕 +短袜 🧦 +短裤 🩳 +石头 🪨 +石字旁 石 +石油 ⛽ +石油桶 🛢️ +码 yd +码农 👨‍💻 +码畜 👨‍💻 +研究 🧐 +砖 🧱 +砖块 🧱 +砖头 🧱 +破壳 🐣 +破折号 —— +硬币 🪙 +确定 ✅ +碎花彩球 🎊 +碗 🥣 +碗勺 🥣 +碗面 🍜 +碰杯 🍻 +磁力 🧲 +磁带 📼 +磁石 🧲 +磁通量 Φ +磁铁 🧲 +磅 lb +示字旁 礻 +礼品 🎁 +礼品盒 🎁 +礼宾花 🎉 +礼帽 🎩 +礼服 🤵‍♂️ +礼物 🎁 +礼物盒 🎁 +礼花 🎆 +祈求 🙏 +祈祷 🙏 +祖母 👵 +祖父 👴 +祝 ㊗️ +祝福 ㊗️ +祝贺 🎉 +神社 ⛩️ +神龙 🐉 +票 🎫 +祷告 🛐 +禁 🈲 +禁丢垃圾 🚯 +禁止 🚫 +禁止丢垃圾 🚯 +禁止乱丢垃圾 🚯 +禁止乱扔垃圾 🚯 +禁止使用手机 📵 +禁止吸烟 🚭 +禁止响铃 🔕 +禁止手势 🙅 +禁止打电话 📵 +禁止扔垃圾 🚯 +禁止自行车 🚳 +禁止行人 🚷 +禁止行人通行 🚷 +禁止通行 ⛔ +禁止驶入 ⛔ +禁止骑行 🚳 +禁止骑车 🚳 +禁烟 🚭 +禁言 🤐 +福克兰群岛 🇫🇰 +秀肌肉 💪 +私家车 🚗 +秃头 👨‍🦲 +秃宝盖 冖 +秃顶 🧑‍🦲 +科威特 🇰🇼 +科威特国 🇰🇼 +科学上网 🪜 +科学家 👨‍🔬 +科摩罗 🇰🇲 +科摩罗联盟 🇰🇲 +科特迪瓦 🇨🇮 +科特迪瓦共和国 🇨🇮 +科科斯 🇨🇨 +科索沃 🇽🇰 +科索沃共和国 🇽🇰 +秒 ″ +秒差距 pc +秒表 ⏱️ +秘 ㊙️ +秘密 🤐 +秘鲁 🇵🇪 +秘鲁共和国 🇵🇪 +积分 ∫ +移动电话 📱 +程序员 👨‍💻 +稍等 (o´Д`o)ノ +稻子 🌾 +稻穗 🌾 +穆斯林 🧕 +穴居巨怪 🧌 +空 🈳 +空中缆车 🚡 +空中轨道列车 🚟 +空位 🈳 +空巢 🪹 +空手道服 🥋 +空轨 🚟 +空集 ∅ +空鸟巢 🪹 +突尼斯 🇹🇳 +突尼斯共和国 🇹🇳 +窗户 🪟 +立刀旁 刂 +立方 ³ +立方分米 dm³ +立方厘米 cm³ +立方米 m³ +立陶宛 🇱🇹 +立陶宛共和国 🇱🇹 +竖 丨 +竖中指 🖕 +竖弯钩 乚 +竖心 忄 +竖心旁 忄 +竖笛 🪈 +站台 🚉 +站着 🧍‍♂️ +站立 🧍‍♂️ +站立者 🧍 +章鱼 🐙 +竹子 🎋 +竹字头 ⺮ +竹篮 🧺 +笑 😁 +笑中带泪 😹 +笑出眼泪 🤣 +笑出眼泪的猫 😹 +笑哭 😂 +笑哭了 😂 +笑得打滚 🤣 +笑得打滚儿 🤣 +笑得满地打滚 🤣 +笑得满地打滚儿 🤣 +笑晕 😆 +笑死 😆 +笑脸 😀 +笔 🖊️ +笔电 💻 +笔记本 📓 +笔记本电脑 💻 +笛子 🪈 +笤帚 🧹 +符号 🔣 +第一 ☝️ +第一卷 📕 +第三卷 📘 +第二 ✌️ +第二卷 📗 +第四卷 📙 +等于 = +等于号 🟰 +等号 🟰 = +等比 : +筐 🧺 +答对 ⭕ +答对了 ⭕ +答错 ❌ +答错了 ❌ +筷子 🥢 +算盘 🧮 +管弦乐器 🎷 +箭头 → +箱子 📦 +篮子 🧺 +篮球 🏀 +篮球运动员 ⛹️‍♂️ +米字旁 米 +米果 🍘 +米每秒 m/s +米饭 🍚 +米饼 🍘 +粉心 🩷 +粉红心 🩷 +粉色心 🩷 +粉色的心 💕 +粑粑 💩 +粗等号 🟰 +粗美元符号 💲 +精灵 🧚 +精灵族 🧝‍♂️ +糖 🍬 +糖果 🍬 +糖果球 🎊 +糯米团 🍡 +索道 🚡 +索马里 🇸🇴 +索马里联邦 🇸🇴 +索马里联邦共和国 🇸🇴 +紧张 😰 +紫心 💜 +紫色 🟣 +紫色圆 🟣 +紫色心 💜 +紫色方块 🟪 +紫色的心 💜 +累 😮‍💨 +累死了 😩 +繁荣昌盛 🖖 +纠结 😖 +红中 🀄 +红包 🧧 +红发 🧑‍🦰 +红唇 💋 +红头发 👩‍🦰 +红心 ♥️ +红旗 🇨🇳 +红桃 ♥️ ♡ +红毛猩猩 🦧 +红灯笼 🏮 +红玫瑰 🌹 +红绿灯 🚥 +红色 🔴 +红色书本 📕 +红色倒三角 🔻 +红色圆 🔴 +红色方块 🟥 +红色旗帜 🚩 +红色正三角 🔺 +红色的书 📕 +红色问号 ❓ +红苹果 🍎 +红萝卜 🥕 +红薯 🍠 +红辣椒 🌶️ +约旦 🇯🇴 +约旦哈希姆王国 🇯🇴 +约等于 ≈ +纱丽 🥻 +纱线 🧶 +纳秒 ns +纳米 nm +纳米比亚 🇳🇦 +纳米比亚共和国 🇳🇦 +纳赫 nHz +纸 📄 +纸巾 🧻 +纸币 💵 +纸杯蛋糕 🧁 +纸烟 🚬 +纸箱 📦 +纸箱子 📦 +纸鸢 🪁 +纽 ν +纽埃 🇳🇺 +线 🧵 +线团 🧶 +线圈日历 🗓️ +线圈本 🗒️ +线圈笔记本 🗒 +线应变 ε +线轴 🧵 +练武服 🥋 +练腿 🦵 +细菌 🦠 +终止线 𝄂 +终点 🏁 +终点旗 🏁 +经纬线 🌐 +结 🪢 +结束 🔚 +结束箭头 🔚 +给我打电话 🤙 +络腮胡 🧔 +绝了 👍 +绝对值 | +绞丝旁 纟 +绣花针 🪡 +绳子 🪢 +绳结 🪢 +绵羊 🐑 +绷带 🤕 +绿叶蔬菜 🥬 +绿心 💚 +绿皮火车 🚆 +绿色 🟢 +绿色书本 📗 +绿色圆 🟢 +绿色心 💚 +绿色方块 🟩 +绿色沙拉 🥗 +绿色的书 📗 +绿色的心 💚 +绿花椰菜 🥦 +绿苹果 🍏 +绿茶 🍵 +绿菜花 🥦 +缄默 😯 +缅甸 🇲🇲 +缅甸联邦 🇲🇲 +缅甸联邦共和国 🇲🇲 +缆车 🚠 +缝合针 🪡 +缝纫针 🪡 +缠满绷带 🤕 +缠绷带 🤕 +缪 μ +罐 🫙 +罐头 🥫 +罐头食品 🥫 +罐子 🫙 +网球 🎾 +网球鞋 👟 +网络 🛜 +罗盘 🧭 +罗马尼亚 🇷🇴 +罗马数字一 Ⅰ +罗马数字七 Ⅶ +罗马数字三 Ⅲ +罗马数字九 Ⅸ +罗马数字二 Ⅱ +罗马数字二十 ⅩⅩ +罗马数字二十一 ⅩⅪ +罗马数字二十七 ⅩⅩⅦ +罗马数字二十三 ⅩⅩⅢ +罗马数字二十九 ⅩⅩⅨ +罗马数字二十二 ⅩⅫ +罗马数字二十五 ⅩⅩⅤ +罗马数字二十八 ⅩⅩⅧ +罗马数字二十六 ⅩⅩⅥ +罗马数字二十四 ⅩⅩⅣ +罗马数字五 Ⅴ +罗马数字八 Ⅷ +罗马数字六 Ⅵ +罗马数字十 Ⅹ +罗马数字十一 Ⅺ +罗马数字十七 ⅩⅦ +罗马数字十三 ⅩⅢ +罗马数字十九 ⅩⅨ +罗马数字十二 Ⅻ +罗马数字十五 ⅩⅤ +罗马数字十八 ⅩⅧ +罗马数字十六 ⅩⅥ +罗马数字十四 ⅩⅣ +罗马数字四 Ⅳ +置顶 🔝 +羊 🐑 +羊字旁 羊 +羊排 🥩 +羊羊 🐑 +羊角包 🥐 +羊角面包 🥐 +羊驼 🦙 +美人鱼 🧜‍♀️ +美元 💵 $ +美元钞票 💵 +美利坚 🇺🇸 +美利坚合众国 🇺🇸 +美味 😋 ლ(´ω`ლ) +美国 🇺🇸 +美国本土外小岛屿 🇺🇲 +美容 💆‍♂️ +美容美发 💇‍♂️ +美属维尔京群岛 🇻🇮 +美属萨摩亚 🇦🇸 +美式橄榄球 🏈 +美式足球 🏈 +美术 🎨 +美洲 🌎 +美洲地球仪 🌎 +美洲鸵 🦙 +美甲 💅 +美男鱼 🧜‍♂️ +美金 $ +羞涩 😳 +羞涩亲亲 😚 +羞涩微笑 😊 +羽毛 🪶 +羽毛球 🏸 +羽毛球拍 🏸 +羽翼 🪽 +翅膀 🪽 +翅膀钱 💸 +翻墙 🪜 +翻白眼 🙄 +翻白眼儿 🙄 +翻筋斗 🤸‍♂️ +翻跟头 🤸‍♂️ +老人 👴 +老太太 👵 +老头 👴 +老头儿 👴 +老奶奶 👵 +老妈 👩 +老师 👨‍🏫 +老年人 🧓 +老式钥匙 🗝️ +老挝 🇱🇦 +老挝人民民主共和国 🇱🇦 +老爷爷 👴 +老爹 👨 +老虎 🐅 +老虎头 🐯 +老虎机 🎰 +老虎脸 🐯 +老钥匙 🗝 +老鹰 🦅 +老鼠 🐁 +老鼠头 🐭 +老鼠脸 🐭 +考拉 🐨 +耗子 🐭 +耳 👂 +耳字旁 耳 +耳朵 👂 +耳机 🎧 +耳聋 🧏‍♂️ +耶 ✌️ v(。・ω・。) +耸肩 🤷‍♂️ +聋子 🧏‍♂️ +职员 👨‍💼 +联合国 🇺🇳 +聚会笑脸 🥳 +肉 🍖 +肉丸 🧆 +肉丸子 🧆 +肉块 🥩 +肉字旁 肉 +肉排 🥩 +肉肉 🍖 +肌肉 💪 +肤色 🏻 +肥皂 🧼 +肯尼亚 🇰🇪 +肯尼亚共和国 🇰🇪 +肱二头 💪 +肱二头肌 💪 +肺 🫁 +背包 🎒 +背囊 🎒 +背心 🎽 +胖达 🐼 +胜利 ✌️ \^o^/ +胜利手势 ✌️ +胡子 🧔 +胡萝卜 🥕 +胡说 🤥 +胡须 🧔 +胶卷 🎞 +胶囊 💊 +胶片 🎞 +胸片 🩻 +胸透 🩻 +能说脏话吗 🤬 +脏话 🤬 +脑 🧠 +脑子 🧠 +脑子爆炸 🤯 +脑爆炸 🤯 +脑花 🧠 +脑袋爆炸 🤯 +脚 🦶 +脚丫 🦶 +脚丫子 🦶 +脚印 👣 +脚踏车 🚲 +脱发 👨‍🦲 +脱氧核糖核酸 🧬 +脸发烧 🥵 +脸红 😳 (#^.^#) +腕龙 🦕 +腿 🦵 +腿儿 🦵 +膨胀的心 💗 +膨胀的爱心 💗 +自信 😎 +自动人力车 🛺 +自拍 🤳 +自然对数 ㏑ +自由 🆓 +自由女神 🗽 +自由女神像 🗽 +自行车 🚲 +臭鼬 🦨 +致敬 🫡 +舌 👅 +舌头 👅 +舌字旁 舌 +舞动的心 💞 +舞女 💃 +舞者 🕺 +舟字旁 舟 +航班起飞 🛫 +航班降落 🛬 +船 🚢 +船锚 ⚓ +良字旁 良 +色 😍 +色子 🎲 +艺术 🎨 +艺术家 🧑‍🎨 +艾欧塔 ι +艾特 @ +节 kn +芒果 🥭 +芙蓉 🌺 +芙蓉花 🌺 +芝士 🧀 +芝士火锅 🫕 +芬兰 🇫🇮 +芬兰共和国 🇫🇮 +芭蕾舞鞋 🩰 +花 🌸 +花扑克牌 🎴 +花括号 {} +花札 🎴 +花朵 🌼 +花束 💐 +花栗鼠 🐿 +花椰菜 🥦 +花生 🥜 +花痴 😍 (●♡◡♡●) +花痴猫 😻 +花痴猫脸 😻 +花痴的猫 😻 +花盆 🪴 +花结 🏵 +花钱 💸 +芽 🌱 +苍蝇 🪰 +苏丹 🇸🇩 +苏丹共和国 🇸🇩 +苏格兰 🏴󠁧󠁢󠁳󠁣󠁴󠁿 +苏里南 🇸🇷 +苏里南共和国 🇸🇷 +苞米 🌽 +苦笑 😅 +英伦 🇬🇧 +英国 🇬🇧 +英寸 in +英寸汞柱 inHg +英尺 ft +英尺磅 ft·lb +英属印度洋领地 🇮🇴 +英属维尔京群岛 🇻🇬 +英式橄榄球 🏉 +英式足球 ⚽ +英文 🔠 +英文大写 🔠 +英文小写 🔡 +英格兰 🏴󠁧󠁢󠁥󠁮󠁧󠁿 +英热单位 Btu +英语 🔠 +英里 mi +英镑 💷 £ +英镑钞票 💷 +苹果 🍎 +茄子 🍆 +茶 🍵 +茶壶 🫖 +茶杯 🍵 +茶水 🍵 +草 🌿 +草头 艹 +草字头 艹 +草屋 🛖 +草本植物 🌿 +草泥马 🦙 +草花 ♣️ +草药 🌿 +草莓 🍓 +荒宅 🏚 +荒岛 🏝 +荒漠 🏜 +药 💊 +药丸 💊 +药草 🌿 +荷兰 🇳🇱 +荷包 👝 +荷属加勒比区 🇧🇶 +荷属圣马丁 🇸🇽 +莫桑比克 🇲🇿 +莫桑比克共和国 🇲🇿 +莱索托 🇱🇸 +莱索托王国 🇱🇸 +莲花 🪷 +莲花坐 🧘‍♂️ +莲花坐的人 🧘‍♂️ +菜刀 🔪 +菜椒 🫑 +菜篮 🧺 +菜篮子 🧺 +菠萝 🍍 +菱形 🔶 +菲律宾 🇵🇭 +菲律宾共和国 🇵🇭 +菲林 🎞 +萝卜 🥕 +营 🈺 +营业 🈺 +萨克 🇨🇶 +萨克斯 🎷 +萨克斯管 🎷 +萨克斯风 🎷 +萨尔瓦多 🇸🇻 +萨尔瓦多共和国 🇸🇻 +萨摩亚 🇼🇸 +萨摩亚独立国 🇼🇸 +落叶 🍂 +落叶树 🌳 +落日 🌇 +落花生 🥜 +葡萄 🍇 +葡萄牙 🇵🇹 +葡萄牙共和国 🇵🇹 +葡萄酒 🍷 +葱头 🧅 +蒙住眼睛 🙈 +蒙古 🇲🇳 +蒙古包 🛖 +蒙古国 🇲🇳 +蒙特塞拉特 🇲🇸 +蒙眼 🙈 +蒙眼睛 🙈 +蒜 🧄 +蒜香 🧄 +蒸房 🧖‍♂️ +蒸桑拿 🧖‍♂️ +蒸汽火车 🚂 +蒸馏器 ⚗️ +蓄电池 🔋 +蓝心 💙 +蓝色 🔵 +蓝色书本 📘 +蓝色圆 🔵 +蓝色大菱形 🔷 +蓝色小菱形 🔹 +蓝色心 💙 +蓝色方块 🟦 +蓝色的书 📘 +蓝色的心 💙 +蓝色菱形 🔷 +蓝莓 🫐 +蔬菜 🥬 +蔬菜沙拉 🥗 +薄煎饼 🥞 +薯条 🍟 +蘑菇 🍄 +虎 🐅 +虎头 🐯 +虎字头 虍 +虎字旁 虎 +虎脸 🐯 +虚线脸 🫥 +虚线轮廓 🫥 +虫子 🐛 +虫字旁 虫 +虾 🦐 +蚂蚁 🐜 +蚊子 🦟 +蚊虻 🦟 +蚊蝇 🦟 +蚊香 🌀 +蚝蛎 🦪 +蚯蚓 🪱 +蚱蜢 🦗 +蛇 🐍 +蛇夫座 ⛎ +蛇杖 ⚕ +蛇蛇 🐍 +蛋 🥚 +蛋奶糕 🍮 +蛋糕 🎂 +蛋蛋 🥚 +蛋饼 🥞 +蛐蛐 🦗 +蛛网 🕸 +蛤 🐸 +蛤蛤 🐸 +蛤蟆 🐸 +蛾眉月 🌒 +蜂蜜 🍯 +蜗牛 🐌 +蜘蛛 🕷️ +蜘蛛网 🕸️ +蜜橘 🍊 +蜜獾 🦡 +蜜罐 🍯 +蜜蜂 🐝 +蜡烛 🕯️ +蜡笔 🖍️ +蜥蜴 🦎 +蜥蜴类 🦕 +蝎子 🦂 +蝙蝠 🦇 +蝴蝶 🦋 +蝴蝶结 🎀 +螃蟹 🦀 ︽⊙_⊙︽ +融化 🫠 +螺丝 🔩 +螺丝刀 🪛 +螺丝扳手 🔧 +螺母与螺栓 🔩 +螺母及螺栓 🔩 +螺母和螺栓 🔩 +螺母螺栓 🔩 +蟋蟀 🦗 +蟑螂 🪳 +蟹 🦀 +蟹老板 🦀 +蠕虫 🪱 +血 🩸 +血型 🆎 +血字旁 血 +血滴 🩸 +行人 🚶 +行尸走肉 🧟‍♂️ +行星 🪐 +行李 🧳 +行李寄存 🛅 +行李寄存处 🛅 +行李箱 🧳 +行李领取 🛄 +行李领取处 🛄 +行走 🚶‍♂️ +衣字旁 衤 +衣服 👔 +表扬 👍 +表演艺术 🎭 +衬衫 👕 +衬衫领带 👔 +衰变常数 λ +袋棍球 🥍 +袋鼠 🦘 +袜子 🧦 +裙子 👗 +裤子 👖 +褐色蘑菇 🍄‍🟫 +西兰花 🥦 +西塔 θ +西字头 覀 +西撒哈拉 🇪🇭 +西服 🤵‍♂️ +西格玛 σ +西班牙 🇪🇸 +西班牙王国 🇪🇸 +西瓜 🍉 +西红柿 🍅 +西装 🤵‍♂️ +西门子 S +要哭了 🥹 +见钱眼开 🤑 +观察 👀 +观月 🎑 +视频游戏手柄 🎮 +角 ∠ +解锁 🔓 +言字旁 讠 +警告 ⚠️ +警官 👮‍♂️ +警察 👮‍♂️ +警灯 🚨 +警车 🚓 +警车旋转灯 🚨 +警车灯 🚨 +计时器 ⏱ +计算机 🖥 +订婚戒 💍 +认输 🏳 +认输了 🏳 +讨价还价 🉐 +让我看看 🧐 +记事本 📔 +记录 📝 +讲话 🗣 +讲话者 🗣 +许愿 🌠 +证人 👁️‍🗨️ +证件 🪪 +证照 🪪 +试管 🧪 +话语气泡 💬 +诱惑 🫦 +说脏话 🤬 +说话 🗣️ +说谎 🤥 +请勿乱丢垃圾 🚯 +请勿乱扔垃圾 🚯 +请勿入内 ⛔ +请勿吸烟 🚭 +请勿饮用 🚱 +请勿驶入 ⛔ +诺福克岛 🇳🇫 +谁知道 🤷‍♂️ +谁知道呢 🤷‍♂️ +调低 🔽 +调味品 🧂 +调情 🫦 +调料罐 🧂 +调皮 😜 +调色板 🎨 +调色盘 🎨 +调高 🔼 +谈恋爱 👩‍❤️‍👨 +谢谢 🙏 ☆⌒(*^-゜)v THX!! +豆 🫘 +豆子 🫘 +豆类 🫘 +豆荚 🫛 +豌豆 🫛 +豌豆荚 🫛 +豹 🐆 +豹子 🐆 +贝可勒尔 Bq +贝塔 β +贝壳 🐚 +贝字旁 贝 +贝宁 🇧🇯 +贝宁共和国 🇧🇯 +贝尔 B +负伤 🤕 +负无穷 -∞ +财迷 🤑 +账单 🧾 +账本 📒 +账簿 📒 +货币 💲 +货币兑换 💱 +货车 🚚 +购物袋 🛍️ +购物车 🛒 +贵宾犬 🐩 +贷记卡 💳 +资讯 ℹ️ +赌博 🎰 +赌场 🎰 +赏月 🎑 +赛博朋克 🦾 +赛艇 🚤 +赛车 🏎️ +赛马 🏇 +赛马骑师 🏇 +赞 👍 +赞同 ヾ(❀^ω^)ノ゙ +赞比亚 🇿🇲 +赞比亚共和国 🇿🇲 +赢了 ✌️ +赢咯 ✌️ +赢啦 ✌️ +赤发 🦰 +赤道几内亚 🇬🇶 +赫兹 Hz +赫德岛和麦克唐纳群岛 🇭🇲 +走之底 辶 +走字底 辶 +走路 🚶‍♂️ +起司 🧀 +起司火锅 🫕 +起重机 🏗 +起飞 🛫 +超人 🦸‍♂️ +超市 🏬 +超度 🙏 +超级大反派 🦹‍♂️ +超级英雄 🦸 +超级辣 🥵 +超辣 🥵 +超酷 🆒 +越南 🇻🇳 +越南社会主义共和国 🇻🇳 +越野车 🚙 +足 🦶 +足字旁 足 +足尖鞋 🩰 +足球 ⚽ +足球场 🏟 +足迹 👣 +趾高气昂 😤 +跆拳道服 🥋 +跑步 🏃‍♂️ +跑步者 🏃‍♂️ +跑鞋 👟 +跨性别 ⚧ +跨性别旗 🏳️‍⚧️ +跪坐 🧎‍♂️ +跪求 🙇‍♂️ +路过 (^.^)/~~~ +路障 🚧 +路面电车 🚊 +跳伞 🪂 +跳舞 💃 +跳舞的女人 💃 +踢 🦶 +踩 🦶 +身份 🆔 +身份证 🆔 +身字旁 身 +躺下 _(:3 」∠)_ +躺在床上的人 🛌 +躺着 _(:3 」∠)_ +车 🚗 +车上 🚌 +车字旁 车 +车站 🚉 +车轮 🛞 +车轮子 🛞 +车轱辘 🛞 +轨迹球 🖲️ +轨道车 🚃 +轮子 🛞 +轮椅 🦽 +轮椅标志 ♿ +轮椅标识 ♿ +轮渡 ⛴ +轮滑 🛼 +轮滑鞋 🛼 +轮胎 🛞 +轮船 🚢 +软冰淇淋 🍦 +软冰激凌 🍦 +软盘 💾 +轱辘 🛞 +轻轨 🚈 +轻轨电车 🚈 +轿车 🚗 +辐射 ☢️ +输入数字 🔢 +输入符号 🔣 +辛字旁 辛 +辣 🥵 +辣椒 🌶 +辣死 🥵 +辣死了 🥵 +辰字旁 辰 +过山车 🎢 +运动会奖牌 🏅 +运动型多用途车 🚙 +运动背心 🎽 +运动鞋 👟 +近视 👓 +近视眼 👓 +返回 🔙 +返回箭头 🔙 +还原号 ♮ +这个我知道 🙋‍♂️ +这里 🈁 +连体泳衣 🩱 +连加 Σ +连接号 — +连接符 — +连衣裙 👗 +连音线 ⌒ +迪戈加西亚岛 🇩🇬 +迪斯科 🪩 +迷你光盘 💽 +迷你光碟 💽 +迷恋 🥰 +迷茫 😶‍🌫️ +追日花 🌻 +退 🤺 +退回 🔙 +送心 (。・ω・。)ノ♥ +送花 (^_^)∠※ +送货车 🚚 +逆时针 🔄 +逆时针垂直箭头 🔄 +逆时针方向 🔄 +选票 🗳 +逐日花 🌻 +逗号 , +通过 ✅ +通通乐 🪠 +道歉 🙇‍♂️ +遮阳伞 ⛱️ +那画面太美我不敢看 🫣 +邮件 📧 +邮号 📯 +邮局 🏣 +邮政喇叭 📯 +邮筒 📮 +邮箱 📮 +邮轮 🚢 +郁金香 🌷 +郁闷 🫤 +都市 🏙 +都市景观 🏙 +都市风光 🏙 +鄙视 🤨 (→_→) +酉字旁 酉 +酒 🍷 +酒店 🏨 +酒杯 🍷 +酒桌游戏 🎲 +酒醉 🥴 +酷 😎 +醉了 🥴 +释然 😌 +里字旁 里 +重复按钮 🔁 +重拍 🆖 +重拳出击 👊 +重新载入 🔃 +重金属 👨‍🎤 +重音记号 > +野牛 🦬 +野猪 🐗 +野营 🏕 +量体温 🤒 +金光闪闪 ✨ +金发 👱 +金发女 👱‍♀️ +金发男 👱‍♂️ +金发男人 👱‍♂️ +金发男子 👱‍♂️ +金塔 ⚱️ +金字旁 钅 +金属礼 🤘 +金毛 🦮 +金毛寻回犬 🦮 +金毛犬 🦮 +金牌 🥇 +金牛座 ♉ +金瓮 ⚱️ +金钱 💰 +金闪闪 ✨ +针 🪡 +钓竿 🎣 +钓鱼 🎣 +钓鱼竿 🎣 +钞票 💵 +钢琴 🎹 +钢琴按键 🎹 +钢琴键 🎹 +钢笔 ✒️ +钢笔尖 ✒️ +钢镚 🪙 +钢镚儿 🪙 +钥匙 🔑 +钥匙和锁 🔐 +钩子 🪝 +钱 💰 +钱包 👛 +钱币 🪙 +钱眼 🤑 +钱眼儿 🤑 +钱袋 💰 +钱飞了 💸 +钻戒 💍 +钻石 💎 +铁轨 🛤️ +铁钩 🪝 +铁链 ⛓️ +铁链子 ⛓️ +铁锤 🔨 +铁镐 ⛏️ +铃铛 🔔 +铅笔 ✏️ +铜牌 🥉 +银河 🌌 +银河系 🌌 +银牌 🥈 +银行 🏦 +银行卡 💳 +链子 ⛓️ +链接 🔗 +链条 ⛓️ +锁 🔒 +锁上 🔒 +锁住 🔒 +锁头 🔒 +锁链 ⛓️ +锄头 ⛏️ +错 ❌ +错号 ❌ +错误 ❌ +锚 ⚓ +锡克 🪯 +锡克教 🪯 +锤子 🔨 +锤子与扳手 🛠 +锤子与镐 ⚒️ +锤子和扳手 🛠 +锤子和镐 ⚒️ +锤子扳手 🛠 +键盘 ⌨️ +锯子 🪚 +镜子 🪞 +镜球 🪩 +镜面球 🪩 +长了翅膀的钱 💸 +长棍面包 🥖 +长笛 🪈 +长翅膀的钱 💸 +长裙 👗 +长面包 🥖 +长靴 👢 +长颈鹿 🦒 +长鼓 🪘 +长鼻 🤥 +长鼻子 🤥 +门 🚪 +门子 🚪 +门字旁 门 +门松 🎍 +门票 🎫 +门锁 🔒 +闪亮 ✨ +闪亮的心 💖 +闪亮的星星 🌟 +闪光 ✨ +闪光灯 📸 +闪光灯打开 📸 +闪电 ⚡ +闪耀 ✨ +闪耀的星星 🌟 +闭嘴 🤐 +问号 ❓ ? +问题 (‧_‧?) +间隔号 · +闹表 ⏰ +闹钟 ⏰ +闺蜜 👭 +闻 👃 +阳伞 ⛱️ +阴 ⛅ +阴天 ⛅ +阴阳 ☯️ +阵风 💨 +阿塞拜疆 🇦🇿 +阿塞拜疆共和国 🇦🇿 +阿富汗 🇦🇫 +阿尔及利亚 🇩🇿 +阿尔及利亚民主人民共和国 🇩🇿 +阿尔巴尼亚 🇦🇱 +阿尔巴尼亚共和国 🇦🇱 +阿尔法 α +阿弥陀佛 🙏 +阿拉丁 🧞‍♂️ +阿拉丁神灯 🧞‍♂️ +阿拉伯 🇦🇪 +阿拉伯叙利亚共和国 🇸🇾 +阿拉伯联合酋长国 🇦🇪 +阿斯克勒庇俄斯 ⚕ +阿曼 🇴🇲 +阿曼苏丹国 🇴🇲 +阿根廷 🇦🇷 +阿根廷共和国 🇦🇷 +阿森松 🇦🇨 +阿森松岛 🇦🇨 +阿秒 as +阿赫韦南马 🇦🇽 +阿鲁巴 🇦🇼 +附点二分音符 ♪. +附点全音符 ♩. +附点八分音符 ♬. +附点十六分音符 𝄫. +附点四分音符 ♫. +降号 ♭ +降落 🛬 +降落伞 🪂 +除 ➗ ÷ +除以 ➗ +除号 ➗ ÷ +陶 τ +陷入爱河 🥰 +随便 🫤 +随机 🔀 +随机播放 🔀 +隐形 🫥 +隐瞒身份 🥸 +隐秘 🔏 +隐藏 🫥 +隹字旁 隹 +难过 😞 +难过但释然 😥 +雏菊 🌼 +雨 🌧 +雨云 🌧 +雨伞 🌂 +雨字头 雨 +雨滴 🌧️ +雪 ❄️ +雪与雪人 ☃️ +雪云 🌨 +雪人 ⛄ +雪山 🏔️ +雪橇 🛷 +雪球 ⚪ +雪花 ❄️ +零 0️⃣ +零钱包 👛 +雷云 🌩 +雷电 ⚡ +雷阵雨 ⛈️ +雷龙 🦕 +雾 🌫️ +雾气 🌁 +雾气笼罩的桥 🌁 +雾蒙蒙 🌁 +雾都 🌁 +雾霾 🌫 +震动 📳 +震惊 😲 +露营 🏕️ +露齿笑 😃 +霸王龙 🦖 +霾 🌫 +青柠 🍋‍🟩 +青橄榄 🫒 +青瓜 🥒 +青筋 💢 +青花菜 🥦 +青苹果 🍏 +青蛙 🐸 +青蛙头 🐸 +青蛙脸 🐸 +静音 🔇 +静音铃铛 🔕 +非常棒 👍 +非洲 🌍 +非洲地球仪 🌍 +非洲欧洲 🌍 +非礼勿听 🙉 +非礼勿视 🙈 +非礼勿言 🙊 +非饮用水 🚱 +面具 🎭 +面包 🥯 +面包圈 🥯 +面包车 🚐 +面字旁 面 +面无表情 😑 +面条 🍜 +面饼 🫓 +革字旁 革 +靴子 👢 +靶心 🎯 +鞋 👟 +鞋子 👟 +鞠躬 🙇‍♂️ +鞠躬勋章 🎖 +鞭炮 🧨 +韦伯 Wb +韦字旁 韦 +韩元 ₩ +韩国 🇰🇷 +音乐 🎵 +音乐符 🎵 🎵 +音乐键盘 🎹 +音符 🎵 ♪ +页字旁 页 +页签 📑 +页面 📃 +顶 🆙 +顺时针 🔃 +顺时针垂直箭头 🔃 +顺时针方向 🔃 +顿号 、 +顿音记号 • +预警 ⚠️ +领取行李 🛄 +领带 👔 +额 😑 +颠倒 🙃 +颠倒的脸 🙃 +颤抖 🫨 +颤抖的脸 🫨 +风信子 🪻 +风吹叶落 🍃 +风吹拂 🌬 +风吹拂的脸 🌬 +风筝 🪁 +风铃 🎐 +风鸢 🪁 +飓风 🌀 +飞去来器 🪃 +飞吻 😘 ( ˘ ³˘)♥ +飞旋镖 🪃 +飞机 ✈️ +飞机着陆 🛬 +飞机起飞 🛫 +飞盘 🥏 +飞碟 🛸 +飞秒 fs +飞行员 👨‍✈️ +飞赫 fHz +飞镖 🎯 +食人魔 👹 +食字旁 饣 +食指 ☝️ +食指指向观察者 🫵 +食物 🥘 +食盐 🧂 +餐具 🍽️ +饭团 🍙 +饮料 🥤 +饮料盒 🧃 +饮水处 🚰 +饮用水 🚰 +饺子 🥟 +饼 🫓 +饼干 🍪 +香槟 🍾 +香港 🇭🇰 +香港特别行政区 🇭🇰 +香烟 🚬 +香皂 🧼 +香草 🌿 +香蕉 🍌 +马 🐎 +马上 🔜 +马儿 🐴 +马力 hp +马头 🐴 +马字旁 马 +马尔代夫 🇲🇻 +马尔代夫共和国 🇲🇻 +马尔维纳斯群岛 🇫🇰 +马恩岛 🇮🇲 +马戏 🎪 +马戏团 🎪 +马戏团帐篷 🎪 +马拉松 🏃‍♂️ +马拉维 🇲🇼 +马拉维共和国 🇲🇼 +马提尼克 🇲🇶 +马来西亚 🇲🇾 +马桶 🚽 +马桶吸 🪠 +马桶吸把 🪠 +马桶吸盘 🪠 +马桶塞 🪠 +马桶抽子 🪠 +马桶搋 🪠 +马桶搋子 🪠 +马桶疏通器 🪠 +马约特 🇾🇹 +马绍尔群岛 🇲🇭 +马耳他 🇲🇹 +马耳他共和国 🇲🇹 +马脸 🐴 +马达加斯加 🇲🇬 +马达加斯加共和国 🇲🇬 +马里 🇲🇱 +马里共和国 🇲🇱 +马铃薯 🥔 +马骝 🐒 +马鹿 🫎 +马黛茶 🧉 +驴 🫏 +驴儿 🫏 +驼鹿 🫎 +驼鹿头 🫎 +驼鹿脸 🫎 +驾照 🪪 +驾雪橇 🛷 +骂人 🤬 +骆驼 🐪 +骏马 🐎 +骑单车 🚴‍♂️ +骑山地车 🚵 +骑自行车 🚴 +骑车 🚴‍♂️ +骑马 🏇 +骗人 🤥 +骗人布 🤥 +骗子 🤥 +骨坛 ⚱️ +骨头 🦴 +骨字旁 骨 +骨灰盒 ⚱️ +骨灰缸 ⚱️ +骨瓮 ⚱️ +骨骼 🦴 +骰子 🎲 +骷髅 💀 +骷髅头 💀 +高亮度 🔆 +高兴 😄 (〃'▽'〃) +高压 ⚡ +高压电 ⚡ +高尔夫 ⛳ +高尔夫球手 🏌️‍♂️ +高尔夫球洞 ⛳ +高山 🏔 +高山索道 🚠 +高帽 🎩 +高斯 Gs +高架铁路 🚟 +高跟儿 👠 +高跟儿凉鞋 👡 +高跟儿靴 👢 +高跟儿鞋 👠 +高跟凉鞋 👡 +高跟靴 👢 +高跟鞋 👠 +高速 🛣 +高速公路 🛣️ +高速列车 🚄 +高速子弹头列车 🚅 +高铁 🚄 +高音 ∮ +高音谱号 𝄞 +高音量 🔊 +高音量的扬声器 🔊 +鬼 👻 +鬼牌 🃏 +鬼脸 😜 +鬼魂 👻 +魔术师 🧙‍♂️ +魔棒 🪄 +魔法上网 🪜 +魔法师 🧙‍♂️ +魔法棒 🪄 +魔法阵 🔯 +魔镜 🪞 +魔鬼 😈 +鱼 🐟 +鱼儿 🐟 +鱼字旁 鱼 +鱼板 🍥 +鱼饼 🍥 +鱿鱼 🦑 +鲜花 💐 +鲤鱼旗 🎏 +鲨鱼 🦈 +鲸 🐳 +鲸鱼 🐳 +鳄梨 🥑 +鳄鱼 🐊 +鸟 🐦 +鸟头 🐦 +鸟字旁 鸟 +鸟巢 🪹 +鸟窝 🪹 +鸡 🐔 +鸡头 🐔 +鸡尾酒 🍸 +鸡巴 🐔 +鸡腿 🍗 +鸡腿儿 🍗 +鸡蛋 🥚 +鸢尾花 ⚜️ +鸭 🦆 +鸭子 🦆 +鸭舌帽 🧢 +鸮 🦉 +鸽 🕊️ +鸽子 🕊 +鹅 🪿 +鹅鹅鹅 🪿 +鹤嘴锄 ⛏️ +鹦鹉 🦜 +鹰 🦅 +鹿 🦌 +鹿字旁 鹿 +鹿角 🫎 +麋鹿 🦌 +麒麟鹿 🦒 +麦克风 🎤 +麦字旁 麦 +麻将 🀄 +麻辣小龙虾 🦞 +黄丝带 🎗 +黄乳油 🧈 +黄心 💛 +黄昏 🌆 +黄榄 🫒 +黄油 🧈 +黄瓜 🥒 +黄色 🟡 +黄色圆 🟡 +黄色心 💛 +黄色方块 🟨 +黄色的心 💛 +黄金瓮 ⚱️ +黎巴嫩 🇱🇧 +黎巴嫩共和国 🇱🇧 +黑八 🎱 +黑子 ⚫ +黑山 🇲🇪 +黑心 🖤 +黑旗 🏴 +黑桃 ♠️ +黑框 ⬛ +黑棋 ⚫ +黑点 ⚫ +黑猫 🐈‍⬛ +黑白棋 ⚫ +黑线方形按钮 🔲 +黑线正方形按钮 🔲 +黑脸 🌚 +黑色 ⚫ +黑色中方块 ◻ +黑色中等方块 ◻ +黑色圆 ⚫ +黑色大方块 ◽ +黑色小方块 ▫ +黑色小鸟 🐦‍⬛ +黑色心 🖤 +黑色方形按钮 🔲 +黑色旗 🏴 +黑色旗子 🏴 +黑色旗帜 🏴 +黑色正方形按钮 🔲 +黑色的心 🖤 +黑色的鸟 🐦‍⬛ +黑边线方形按钮 🔲 +黑边线正方形按钮 🔲 +黑鸟 🐦‍⬛ +鼓 🥁 +鼓励 ↖(^ω^)↗ +鼓掌 👏 +鼠 🐭 +鼠头 🐭 +鼠标 🖱 +鼠脸 🐭 +鼬 🦨 +鼻 👃 +鼻子 👃 +鼻子冒气 😤 +鼻涕 🤧 +鼻炎 🤧 +齿字旁 齿 +齿轮 ⚙️ +龇牙 😬 +龇牙咧嘴 😬 +龙 🐉 +龙卷风 🌪️ +龙头 🐲 +龙字旁 龙 +龙王 🐲 +龙虾 🦞 +龟 🐢 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 26ed5a03..c33ab223 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -228,6 +228,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can runCatching { EnglishLexicon.preload(this) } // The emoji panel's full list checks every emoji against the font: do it here, not on first open. runCatching { EmojiCatalog.byCategory(this) } + // The association table is the reference's full one now; parse it here, not on the first commit. + runCatching { EmojiAssociationIndex.preload(this) } // Only someone who uses the 笔画 keyboard pays for its table; the // keyboard loads it on first use otherwise. if (ImeSettingsRepository.loadPreferredChineseMode(this) == KeyboardMode.STROKE) { @@ -1363,7 +1365,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } private val emojiAssociations: EmojiAssociationIndex by lazy { - EmojiAssociationIndex.load(this) + EmojiAssociationIndex.obtain(this) } /** Next-word suggestions, with the emoji that go with the committed text first. */ diff --git a/app/src/main/java/llc/slacker/openime/candidate/EmojiAssociationIndex.kt b/app/src/main/java/llc/slacker/openime/candidate/EmojiAssociationIndex.kt index fe5dda63..ba590a12 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/EmojiAssociationIndex.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/EmojiAssociationIndex.kt @@ -12,6 +12,11 @@ import android.content.Context * exactly so that ordinary words never sprout unrelated emoji. * * The table is a bundled text asset; nothing here touches the network. + * + * Two files feed it: the hand-written `emoji/keywords.tsv` first, then the + * generated `emoji/follow-keywords.tsv` (the reference's own association + * table, scripts/import_doubao_data.py). A keyword both carry keeps the + * curated emoji, the generated file adds the keywords the curated one lacks. */ class EmojiAssociationIndex internal constructor( private val byKeyword: Map>, @@ -34,10 +39,15 @@ class EmojiAssociationIndex internal constructor( const val MAX_EMOJI = 3 private const val MIN_SUFFIX_KEYWORD_LENGTH = 2 private const val ASSET = "emoji/keywords.tsv" + private const val FOLLOW_ASSET = "emoji/follow-keywords.tsv" val EMPTY = EmojiAssociationIndex(emptyMap()) - /** Lines are `keywordemoji emoji ...`; `#` starts a comment. */ + /** + * Lines are `keywordemoji ...`; `#` starts a comment. The emoji + * are separated by spaces, or by tabs when the line has them, so a + * kaomoji that itself contains a space stays one entry. + */ fun parse(lines: Sequence): EmojiAssociationIndex { val table = LinkedHashMap>() lines.forEach { raw -> @@ -47,7 +57,8 @@ class EmojiAssociationIndex internal constructor( if (tab <= 0) return@forEach val keyword = line.substring(0, tab).trim() val emoji = line.substring(tab + 1).trim() - .split(Regex("\\s+")) + .let { if (it.contains('\t')) it.split('\t') else it.split(Regex("\\s+")) } + .map { it.trim() } .filter { it.isNotEmpty() } .distinct() if (keyword.isNotEmpty() && emoji.isNotEmpty()) table.putIfAbsent(keyword, emoji) @@ -55,11 +66,27 @@ class EmojiAssociationIndex internal constructor( return EmojiAssociationIndex(table) } + /** Curated table first: it wins for any keyword the generated one also has. */ fun load(context: Context): EmojiAssociationIndex = runCatching { - context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).use { reader -> - parse(reader.lineSequence().toList().asSequence()) - } + parse(assetLines(context, ASSET) + assetLines(context, FOLLOW_ASSET)) }.getOrDefault(EMPTY) + + @Volatile + private var cached: EmojiAssociationIndex? = null + + /** The parsed tables, built once; [preload] fills this off the first commit. */ + fun obtain(context: Context): EmojiAssociationIndex = + cached ?: synchronized(this) { cached ?: load(context).also { cached = it } } + + /** Read the tables on a background thread so the first commit does not. */ + fun preload(context: Context) { + obtain(context) + } + + private fun assetLines(context: Context, name: String): Sequence = + runCatching { + context.assets.open(name).bufferedReader(Charsets.UTF_8).use { it.readLines() } + }.getOrDefault(emptyList()).asSequence() } } diff --git a/app/src/test/java/llc/slacker/openime/EmojiAssociationIndexTest.kt b/app/src/test/java/llc/slacker/openime/EmojiAssociationIndexTest.kt index 84531e7e..1a9e53a7 100644 --- a/app/src/test/java/llc/slacker/openime/EmojiAssociationIndexTest.kt +++ b/app/src/test/java/llc/slacker/openime/EmojiAssociationIndexTest.kt @@ -93,4 +93,39 @@ class EmojiAssociationIndexTest { assertEquals("😊", real.emojiFor("今天真的好开心").first()) assertTrue(real.emojiFor("我们").isEmpty()) } + + @Test + fun tabSeparatedItemsKeepASpaceInsideThem() { + val tabbed = EmojiAssociationIndex.parse( + sequenceOf("亲\t💋\t(づ ̄3 ̄)づ╭♥~", "绝对值\t|"), + ) + assertEquals(listOf("💋", "(づ ̄3 ̄)づ╭♥~"), tabbed.emojiFor("亲")) + assertEquals(listOf("|"), tabbed.emojiFor("绝对值")) + } + + @Test + fun theEarlierTableWinsForAKeywordBothCarry() { + val merged = EmojiAssociationIndex.parse(sequenceOf("开心\t😊 😄", "开心\t😁", "# end")) + assertEquals(listOf("😊", "😄"), merged.emojiFor("开心")) + } + + @Test + fun theGeneratedFollowTableCarriesTheReferenceAssociations() { + val file = sequenceOf(File("."), File("..")) + .map { File(it, "app/src/main/assets/emoji/follow-keywords.tsv") } + .first { it.isFile } + val lines = file.readLines(Charsets.UTF_8) + val rows = lines.filter { it.isNotBlank() && !it.startsWith("#") } + assertTrue("expected the reference's table, got ${rows.size}", rows.size >= 5_000) + + val index = EmojiAssociationIndex.parse(lines.asSequence()) + assertEquals(listOf("🇨🇳"), index.emojiFor("中国")) + assertEquals(listOf("🔥"), index.emojiFor("火")) + assertEquals(listOf("👌"), index.emojiFor("好")) + assertEquals(listOf("⬆️", "↑"), index.emojiFor("上")) + // A kaomoji with a space in it survives the tab separator. + assertEquals(listOf("💋", "(づ ̄3 ̄)づ╭♥~"), index.emojiFor("亲")) + // The one row whose only symbol is the pipe character. + assertEquals(listOf("|"), index.emojiFor("绝对值")) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cb45703c..9fd078ef 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -321,3 +321,7 @@ Add new entries at the end. - **The fuzzy switches are the reference's fifteen, not the mainstream eleven.** The reference's classic fuzzy layer is fifteen pairs, one per bit of `cfg[120]`: six initials (`zh↔z` … `r↔l`) and nine finals, and its own table names the last four — `D3 un↔ong`, `D4 ei↔ui`, `D5 huang↔wang`, `D6 eng↔ong` (`doubaoime-teardown/ENGINE-SPEC.md` §1.5, the bit table; the same four are the only rule names of `fuzzy-pinyin-rules.tsv` that fall outside the eleven mainstream IMEs offer). This port shipped eleven, so an input the reference answers came back empty here whenever one of those four was the pair involved. *Reason:* the switch set is the surface where the port is supposed to match the reference, and a pair missing from it is a reading the user cannot type at all rather than a ranking difference. Each of the four is generated the way the reference spells it, checked row by row against `f_syllable-variants.tsv`: `un↔ong` and `eng↔ong` are final swaps over the whole syllable table (35 and 37 rows, matched with none left and none extra — `chun`↔`chong`, `deng`↔`dong`, and the j/q/x/y and bare-letter forms the reference closes over, `jiong`↔`jiun`, `yong`↔`yun`, `yeng`, `weng`↔`wong`); `ei↔ui` is *not* a final swap but the single pair `fei`↔`hui` (2 rows, matched exactly) — the reference reaches `hei` only where `f↔h` is on as well, its `hui`→`hei` row carrying both bits, and the port composes them the same way; `huang↔wang` is the whole-syllable pair the reference has (2 rows, matched exactly), not an `h↔w` initial rule the mask does not contain. With the four in, the reference's pure-bit rows under our fifteen pairs come out at ref-only 0. All four default off like the rest, librime takes their `derive` rules (verified on the emulator: `dong`→灯, `chun`→冲, `wang`→黄, `hui`→飞 with every pair on, `FuzzyRulesInstrumentedTest`), and the engine reaches the words of the syllable each points at (`zongjing`→尊敬, `dongguang`→灯光, `huiji`→飞机, `wangse`→黄色, `PinyinEngineDataTest.theFourPairsTheReferenceHasBeyondOurs`). + +- **The association row carries the reference's own table, not our 502 words.** + The reference's `follow_dict.dat` (type 12037) is a UTF-16 breadth-first trie keyed by the text the user just committed, and each node's block is `[u32 hdr][u16 n1][n1 x [u16 len][utf-16le string]][u16 n2][...]` — the first list is the symbols that go with the word (`doubaoime-teardown/DECODE-BATCH-C.md` §3.1, the loader at `0x3fdef0`, the block parser at `0x3fe34c`; §3.2 lists the samples `中国` -> 🇨🇳, `上` -> ⬆️ ↑, `一` -> 1️⃣ ①, and the byte walk `48 + 102,350 + 247,848 == 350,246` is the whole file). It holds 10,233 keys, 5,508 of them with symbols. This port's `emoji/keywords.tsv` is 502 hand-written keywords, so a word the reference associates (火, 好, 上班, 下雨) showed no chip at all where the reference shows one. `scripts/import_doubao_data.py` now generates `emoji/follow-keywords.tsv` from that table — 5,508 words, 5,616 symbols — and `EmojiAssociationIndex` reads it after the curated file, which keeps its rows first for the 312 keywords both carry. + *Reason:* the association row is a surface where the port can be the reference exactly, and the table is decoded to the byte, so nothing had to be inferred. Two details of the file decided how it is read rather than how it is shown: the teardown's decoded TSV joins a node's items with `|`, which the format does not treat as a separator — one row's only symbol *is* the `|` character (绝对值) and another is a kaomoji that contains one — so the generator decodes the raw table out of the APK instead of the TSV; and 30 rows keep a space inside a symbol (亲's `(づ ̄3 ̄)づ╭♥~`), so the generated file separates items by tab and the parser splits on tabs when a line has them, on spaces otherwise. The matching policy is untouched (exact for one-character keywords, otherwise the longest keyword the committed text ends with), so only the table grew; parsing it now happens on the lexicon thread at service start (`EmojiAssociationIndex.preload`, next to the English list) so the first commit does not pay for it. diff --git a/scripts/import_doubao_data.py b/scripts/import_doubao_data.py index cd448eb8..d9b2e1f7 100644 --- a/scripts/import_doubao_data.py +++ b/scripts/import_doubao_data.py @@ -35,6 +35,11 @@ 7. ``english-words.tsv`` English completion word list from the decoded auxiliary table (lower / word / rank, sorted by lower; unranked rows are dropped). +8. ``emoji/follow-keywords.tsv`` + Association symbols per committed word from the reference's follow table + (word / symbol / symbol ..., tab separated). The curated + ``emoji/keywords.tsv`` stays first in the app's merge, so it is never + overwritten. Originals are backed up once to ``.local/doubao-import/originals/`` and a report is written to ``.local/doubao-import/report.json``. @@ -292,6 +297,92 @@ def build_english_asset(src: Path, assets: Path, report): report["english"] = {"entries": len(best), "source_rows": total} +def build_emoji_association_asset(src: Path, assets: Path, report): + """Generate emoji/follow-keywords.tsv: word symbol symbol ... + + follow_dict.dat holds the association lists the reference shows after a + word is committed: the key is the committed word, the first list is the + symbols that go with it (中国 -> 🇨🇳, 火 -> 🔥). The decoded TSV in the + teardown joins that list with '|', which the file itself does not treat as + a separator — one of its rows *is* the '|' symbol — so read the raw table + out of the APK instead (structure: DECODE-BATCH-C.md §3). + + Items are tab separated here so a kaomoji with a space in it survives as + one chip. The curated emoji/keywords.tsv stays the first table the app + reads, so a keyword both files carry keeps its curated emoji. + """ + apk = src / "base.apk" + if not apk.is_file(): + report["emoji_association"] = {"skipped": "base.apk not found"} + return + import struct + import zipfile + + with zipfile.ZipFile(apk) as archive: + blob = archive.read("assets/dict/84ac6a563f50") + head = struct.unpack("<16I", blob[:64]) + if head[0] != 12037 or head[2] != 0x30 or head[3] != len(blob): + report["emoji_association"] = {"skipped": "unexpected follow_dict header"} + return + count = head[4] + index = blob[head[5]:head[5] + head[6]] + data = blob[head[8]:head[8] + head[9]] + nodes = [struct.unpack("= end: + continue + for child in range(begin, min(end, count)): + keys[child] = parent + chr(nodes[child][0]) + + rows = {} + bad = 0 + for i in range(1, count): + word = keys.get(i) + if word is None: # the last index row is the trie's sentinel + continue + offset = nodes[i][2] + stop = nodes[i + 1][2] if i + 1 < count else len(data) + block = data[offset:stop] + if not block: + continue + cursor = 4 + symbols = [] + (n1,) = struct.unpack(" len(block): + bad += 1 + break + symbols.append(block[cursor - length:cursor].decode("utf-16-le")) + if not symbols: + continue + rows[word] = [item for item in symbols if item and "\t" not in item] + + out = assets / "emoji" / "follow-keywords.tsv" + tmp = out.with_suffix(".tsv.tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# Association symbols per committed word, from the reference's follow_dict.dat\n") + f.write("# (local build data). First column is the word, the rest are its symbols,\n") + f.write("# tab separated so a symbol may contain a space. See DECODE-BATCH-C.md §3.\n") + for word in sorted(rows): + f.write("%s\t%s\n" % (word, "\t".join(rows[word]))) + os.replace(tmp, out) + report["emoji_association"] = { + "entries": len(rows), + "symbols": sum(len(v) for v in rows.values()), + "bad_blocks": bad, + } + + def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--repo", default=str(Path(__file__).resolve().parent.parent)) @@ -458,12 +549,14 @@ def main(): f.write("%s\t%s\n" % (py, "".join(sorted(existing[py], key=ord)))) os.replace(tmp, chars_path) - print("[6/8] build wubi86 dictionary ...") + print("[6/9] build wubi86 dictionary ...") build_wubi_dict(src, rime, report) - print("[7/8] build handwriting stroke asset ...") + print("[7/9] build handwriting stroke asset ...") build_handwriting_asset(src, assets, doubao, report) - print("[8/8] build English word list ...") + print("[8/9] build English word list ...") build_english_asset(src, assets, report) + print("[9/9] build emoji association keywords ...") + build_emoji_association_asset(src, assets, report) sample = {} for probe in ("上海", "伤害", "你好", "我想", "微信"): @@ -489,6 +582,9 @@ def main(): % (report["handwriting"]["codes"], report["handwriting"]["entries"])) if "entries" in report.get("english", {}): print("english: %d words" % report["english"]["entries"]) + if "entries" in report.get("emoji_association", {}): + print("emoji association: %d words / %d symbols" + % (report["emoji_association"]["entries"], report["emoji_association"]["symbols"])) print("mapped sanity:", sample) print("report: %s" % report_path) if args.dry_run: From 6865cd4aa177eeb1380e648e65870719ccde2369 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:52:46 +0800 Subject: [PATCH 67/94] Record why the stroke candidates are not in the reference's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference lists a code's characters in code point order because its table is stored that way (T6 sorted by code point), so 十 follows 丁 for hs; the port sorts them by the reference's own lexicon scores. All 25,609 codes and 27,073 characters match; only the order within a code differs. Co-Authored-By: Claude Code --- docs/DECISIONS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9fd078ef..54f09a8f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -325,3 +325,7 @@ Add new entries at the end. - **The association row carries the reference's own table, not our 502 words.** The reference's `follow_dict.dat` (type 12037) is a UTF-16 breadth-first trie keyed by the text the user just committed, and each node's block is `[u32 hdr][u16 n1][n1 x [u16 len][utf-16le string]][u16 n2][...]` — the first list is the symbols that go with the word (`doubaoime-teardown/DECODE-BATCH-C.md` §3.1, the loader at `0x3fdef0`, the block parser at `0x3fe34c`; §3.2 lists the samples `中国` -> 🇨🇳, `上` -> ⬆️ ↑, `一` -> 1️⃣ ①, and the byte walk `48 + 102,350 + 247,848 == 350,246` is the whole file). It holds 10,233 keys, 5,508 of them with symbols. This port's `emoji/keywords.tsv` is 502 hand-written keywords, so a word the reference associates (火, 好, 上班, 下雨) showed no chip at all where the reference shows one. `scripts/import_doubao_data.py` now generates `emoji/follow-keywords.tsv` from that table — 5,508 words, 5,616 symbols — and `EmojiAssociationIndex` reads it after the curated file, which keeps its rows first for the 312 keywords both carry. *Reason:* the association row is a surface where the port can be the reference exactly, and the table is decoded to the byte, so nothing had to be inferred. Two details of the file decided how it is read rather than how it is shown: the teardown's decoded TSV joins a node's items with `|`, which the format does not treat as a separator — one row's only symbol *is* the `|` character (绝对值) and another is a kaomoji that contains one — so the generator decodes the raw table out of the APK instead of the TSV; and 30 rows keep a space inside a symbol (亲's `(づ ̄3 ̄)づ╭♥~`), so the generated file separates items by tab and the parser splits on tabs when a line has them, on spaces otherwise. The matching policy is untouched (exact for one-character keywords, otherwise the longest keyword the committed text ends with), so only the table grew; parsing it now happens on the lexicon thread at service start (`EmojiAssociationIndex.preload`, next to the English list) so the first commit does not pay for it. + +- **The stroke keyboard's candidates are ordered by frequency, not by the reference's code point order.** + The reference's stroke table lists each code's characters in ascending code point order — all 1,212 codes that have more than one character are ascending, none descending, none other (`doubaoime-teardown/DECODE-BATCH-C.md` §2.1 says why: `T6` is 27,073 entries "按码点排序", and a code's characters are indices into it), so `hs` reads 丁丅十 and `pn` reads 㐅乂人入八. The port's `handwriting-strokes.tsv` is generated from that same table (`scripts/import_doubao_data.py`, `build_handwriting_asset`) but sorts each code's characters by the single-character score of the reference's own lexicon, so all 1,212 multi-character codes come out in descending score instead: `hs` reads 十丁丅, `hp` reads 厂丆. + *Reason:* the reference's order is a property of how its table is stored, not a ranking — 丁 before 十 is not a claim that 丁 is the likelier character. Where the reference does rank (its lexicon scores), the port follows it; where the order is the storage order, the same scores give a better first candidate. Nothing else differs: the port's table carries all 25,609 codes and all 27,073 characters of the reference's, with none missing and none extra. From 483065874168c97c62607b140fd67fc67215af8d Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:56:48 +0800 Subject: [PATCH 68/94] Call the association setting what the reference calls it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's own settings page names the entry 智能联想 and describes it as 根据上文提供的文字、表情、符号等推荐 (resources.arsc). Our entry was 表情联想 with the description 先给出相关表情, which no longer describes the row: it carries next words, emoji and kaomoji alike. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- .../openime/SpaceCursorAndLetterHintsInstrumentedTest.kt | 2 +- .../main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt | 4 ++-- .../java/llc/slacker/openime/panel/SettingsPanelController.kt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 010274ba..d618849c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。 +- 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。这一栏现在的名字与参考实现一致,叫「智能联想」(参考设置页原文「智能联想」+「根据上文提供的文字、表情、符号等推荐」,`base.apk` 的 `resources.arsc`),说明也改成「选词后按上文给出接续词、表情和颜文字」——原来叫「表情联想」,但栏里早就同时有接续词和颜文字。 - 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 - 模糊音补上参考实现有、我们没有的四组:`un = ong`、`ei = ui`、`huang = wang`、`eng = ong`。参考的音节表按 15 组经典模糊音判定(`f_syllable-variants.tsv` 掩码的 D3–D6 位),我们只做了常见的前 11 组,这几组打开后一个候选都不会变。四组的口径都按参考自己的表逐条核对:`un`/`ong`(`chun` → `chong`,含 `jiong`/`jiun`、`yong`/`yun` 这类)与参考的 35 条行、`eng`/`ong`(`deng` → `dong`,含 `yeng`、`weng`/`wong`)与参考的 37 条行完全一致;`ei = ui` 在参考里只覆盖 `fei`/`hui` 一对(`hei` 要同时打开「f = h」才跟着进来,参考自己那两行也是两个掩码位一起写的);`huang = wang` 是整对音节。设置页新增这四组开关,仍默认关闭。 - 26 键上,自己反复选过的词现在排在该读音候选的最前面。参考实现的转换流水线第一步就是用户自己的词库(`ConvertByUserDict` 是 26 键流程的第一个调用点),排在系统词库、扩展词库、联系人、云端、整句和联想之前;我们的 26 键候选顺序由内置引擎给出,librime 的学习结果只能挂在引擎列表后面,于是反复选的词一直升不上来(打 shuli 选「数理」多少次,下一次首位仍是「梳理」)。现在 26 键把本地记录里该读音下选过的词放在候选栏最前,26 键上的一次选择也会记进这份记录;记录门槛仍是选够 3 次,密码框等不学习的位置不记也不显示。 diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index fe801e82..e04382a1 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -483,7 +483,7 @@ class SpaceCursorAndLetterHintsInstrumentedTest { harness.awaitMain { keyboard.showPanel(Panel.SETTINGS); true } val expected = mapOf( "数字和符号提示" to ("开启" to { ImeSettingsRepository.loadLetterHints(context) }), - "表情联想" to ("开启" to { ImeSettingsRepository.loadEmojiAssociation(context) }), + "智能联想" to ("开启" to { ImeSettingsRepository.loadEmojiAssociation(context) }), "语音去语气词" to ("开启" to { ImeSettingsRepository.loadVoiceStripFillers(context) }), "标点用空格代替" to ("关闭" to { ImeSettingsRepository.loadVoicePunctuationAsSpace(context) }), ) 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 4d8849b8..451cc7a7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -2761,7 +2761,7 @@ open class ImeKeyboardView( "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context) && context.checkSelfPermission(android.Manifest.permission.READ_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED "数字和符号提示" -> ImeSettingsRepository.loadLetterHints(context) - "表情联想" -> ImeSettingsRepository.loadEmojiAssociation(context) + "智能联想" -> ImeSettingsRepository.loadEmojiAssociation(context) "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) "标点用空格代替" -> ImeSettingsRepository.loadVoicePunctuationAsSpace(context) else -> true @@ -2810,7 +2810,7 @@ open class ImeKeyboardView( renderedMode = null if (panel == Panel.NONE) renderModeBody() } - "表情联想" -> ImeSettingsRepository.saveEmojiAssociation(context, enabled) + "智能联想" -> ImeSettingsRepository.saveEmojiAssociation(context, enabled) "语音去语气词" -> ImeSettingsRepository.saveVoiceStripFillers(context, enabled) "标点用空格代替" -> ImeSettingsRepository.saveVoicePunctuationAsSpace(context, enabled) } 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 c04cbc2a..4fd6d41f 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -226,7 +226,7 @@ internal class SettingsPanelController( content.addSection("智能输入") content.addCard( navigationRow("模糊音与智能纠错", "z/zh、c/ch、s/sh 等规则", R.drawable.ic_pref_fuzzy, onShowFuzzySettings), - toggleRow("表情联想", "选词后在联想栏先给出相关表情", R.drawable.ic_pref_emoji), + toggleRow("智能联想", "选词后按上文给出接续词、表情和颜文字", R.drawable.ic_pref_emoji), ) content.addSection("语音输入") content.addCard( @@ -278,7 +278,7 @@ internal class SettingsPanelController( toggleRow("上滑输入数字", null, 0), toggleRow("复制内容提示", null, 0), toggleRow("短信验证码", null, 0), - toggleRow("表情联想", null, 0), + toggleRow("智能联想", null, 0), navigationRow("模糊音与智能纠错", null, 0, onShowFuzzySettings), ) content.addSection("语音") From ac44805ca0b21f5c04bdbca845df06bc762bec46 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:58:47 +0800 Subject: [PATCH 69/94] Name the double-pinyin schemes the way the reference does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its resources.arsc carries exactly two scheme names, 小鹤双拼 and 自然码双拼, under the title 26键双拼方案; ours read 自然码 under 双拼方案. The schemes themselves are unchanged. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../main/java/llc/slacker/openime/core/DoublePinyinScheme.kt | 4 ++-- .../java/llc/slacker/openime/panel/SettingsPanelController.kt | 2 +- .../test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d618849c..f840c0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 双拼方案的名称按参考实现的原文对齐:设置里的选项行叫「26键双拼方案」,两项分别是「小鹤双拼」与「自然码双拼」(参考 `resources.arsc` 里双拼方案名只有这两条字符串,没有单叫「自然码」的),原来我们写的是「双拼方案」和「自然码」。双拼方案本身不变,仍是这两个。 - 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。这一栏现在的名字与参考实现一致,叫「智能联想」(参考设置页原文「智能联想」+「根据上文提供的文字、表情、符号等推荐」,`base.apk` 的 `resources.arsc`),说明也改成「选词后按上文给出接续词、表情和颜文字」——原来叫「表情联想」,但栏里早就同时有接续词和颜文字。 - 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 - 模糊音补上参考实现有、我们没有的四组:`un = ong`、`ei = ui`、`huang = wang`、`eng = ong`。参考的音节表按 15 组经典模糊音判定(`f_syllable-variants.tsv` 掩码的 D3–D6 位),我们只做了常见的前 11 组,这几组打开后一个候选都不会变。四组的口径都按参考自己的表逐条核对:`un`/`ong`(`chun` → `chong`,含 `jiong`/`jiun`、`yong`/`yun` 这类)与参考的 35 条行、`eng`/`ong`(`deng` → `dong`,含 `yeng`、`weng`/`wong`)与参考的 37 条行完全一致;`ei = ui` 在参考里只覆盖 `fei`/`hui` 一对(`hei` 要同时打开「f = h」才跟着进来,参考自己那两行也是两个掩码位一起写的);`huang = wang` 是整对音节。设置页新增这四组开关,仍默认关闭。 diff --git a/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt b/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt index 1b05a1be..aba54773 100644 --- a/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt +++ b/app/src/main/java/llc/slacker/openime/core/DoublePinyinScheme.kt @@ -1,9 +1,9 @@ package llc.slacker.openime.core -/** 双拼键位方案;参考键盘提供小鹤与自然码两种。 */ +/** 双拼键位方案;参考键盘的设置只列「小鹤双拼」「自然码双拼」两项。 */ enum class DoublePinyinScheme(val key: String, val label: String, val schemaId: String) { FLYPY("flypy", "小鹤双拼", "double_pinyin_flypy"), - NATURAL("natural", "自然码", "double_pinyin"), + NATURAL("natural", "自然码双拼", "double_pinyin"), ; companion object { 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 4fd6d41f..9b3bb072 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -597,7 +597,7 @@ internal class SettingsPanelController( } private fun doublePinyinSchemeRow(iconRes: Int): View = - choiceRow("双拼方案", iconRes, DoublePinyinScheme.entries.map { it.label }, currentDoublePinyinScheme().label) { label -> + choiceRow("26键双拼方案", iconRes, DoublePinyinScheme.entries.map { it.label }, currentDoublePinyinScheme().label) { label -> onDoublePinyinSchemeChanged(DoublePinyinScheme.entries.first { it.label == label }) } diff --git a/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt b/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt index 45baaf5a..c089aba8 100644 --- a/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt +++ b/app/src/test/java/llc/slacker/openime/DoublePinyinSchemaTest.kt @@ -43,7 +43,7 @@ class DoublePinyinSchemaTest { @Test fun schemeLabelsAndIdsMatchTheReferenceProduct() { assertEquals("小鹤双拼", DoublePinyinScheme.FLYPY.label) - assertEquals("自然码", DoublePinyinScheme.NATURAL.label) + assertEquals("自然码双拼", DoublePinyinScheme.NATURAL.label) assertEquals("double_pinyin_flypy", DoublePinyinScheme.FLYPY.schemaId) assertEquals("double_pinyin", DoublePinyinScheme.NATURAL.schemaId) assertEquals(DoublePinyinScheme.FLYPY, DoublePinyinScheme.fromKey(null)) From 12b51e2ec6b367fd49be94620a94cd5d0e1a0cb8 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:11:26 +0800 Subject: [PATCH 70/94] Turn on the wubi four-code commit the reference defaults to The reference's wubi page has four switches and its own preference XML carries their defaults: code hint, +pinyin mixing and the four-code commit are on, the fifth-key commit is off (res/cU.xml, R.xml. settings_preferences, read by SettingsConfigNext.). Only the four-code one has a counterpart here, and the schema did not set it: speller/auto_select is what commits a four-code input whose segment holds exactly one candidate (speller.cc:161, gated on auto_select_ and menu->Prepare(2) == 1), so a wubi word with no rival stayed on the bar. The reference's codes are at most four letters, so the gate cannot fire early. The fifth-key switch cannot be split from it -- AutoSelectAtMaxCodeLength (speller.cc:148) runs whenever max_code_length > 0 and ignores auto_select_ -- and the other two need a merged table and a candidate comment this schema does not produce. Recorded in DECISIONS.md. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../main/assets/rime-data/wubi86.schema.yaml | 4 +++ .../llc/slacker/openime/WubiSchemaTest.kt | 29 +++++++++++++++++++ docs/DECISIONS.md | 4 +++ 4 files changed, 38 insertions(+) create mode 100644 app/src/test/java/llc/slacker/openime/WubiSchemaTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f840c0cb..c306620c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 - 双拼方案的名称按参考实现的原文对齐:设置里的选项行叫「26键双拼方案」,两项分别是「小鹤双拼」与「自然码双拼」(参考 `resources.arsc` 里双拼方案名只有这两条字符串,没有单叫「自然码」的),原来我们写的是「双拼方案」和「自然码」。双拼方案本身不变,仍是这两个。 - 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。这一栏现在的名字与参考实现一致,叫「智能联想」(参考设置页原文「智能联想」+「根据上文提供的文字、表情、符号等推荐」,`base.apk` 的 `resources.arsc`),说明也改成「选词后按上文给出接续词、表情和颜文字」——原来叫「表情联想」,但栏里早就同时有接续词和颜文字。 - 模糊音的两条规则现在可以同时作用在同一个音节上。参考实现的音节表里,一条拼写带两个模糊位的行,恰好都是「一个声母组 + 一个韵母组」(`ch_c+eng_ong`、`n_l+in_ing`、`z_zh+an_ang` 等 15 组里的每一种组合),没有两个声母或两个韵母的组合;按参考自己的说法,输入的拼写就是「原音节套用整个掩码」的结果。也就是说同时打开「z = zh」和「an = ang」后,`zhang` 也应该能打出「赞」(zan),打开「n = l」和「in = ing」后,`ning` 应该能打出「林」(lin),打开「c = ch」和「en = eng」后,`cheng` 应该能打出「岑」(cen)。我们原来一条拼写只套用一条规则,这些打不出来。另外,模糊音的拼写本身不必是一个音节:打开「c = ch」后 `cuang` 应该是「床」(chuang),`cuang` 并不是一个音节,我们原来要求拼写必须是音节表里有的,这类拼法同样打不出来。 diff --git a/app/src/main/assets/rime-data/wubi86.schema.yaml b/app/src/main/assets/rime-data/wubi86.schema.yaml index 8453718b..7490f5b1 100644 --- a/app/src/main/assets/rime-data/wubi86.schema.yaml +++ b/app/src/main/assets/rime-data/wubi86.schema.yaml @@ -50,6 +50,10 @@ speller: alphabet: zyxwvutsrqponmlkjihgfedcba delimiter: " '" max_code_length: 4 + # 「四码唯一直接上屏」,参考键盘里默认打开:四码打完只剩一个候选时 + # 立即上屏。参考键盘的「第五码将首选词上屏」默认关闭,但 librime 的 + # speller 在 max_code_length > 0 时总会做这件事(见 DECISIONS.md)。 + auto_select: true translator: dictionary: wubi86 diff --git a/app/src/test/java/llc/slacker/openime/WubiSchemaTest.kt b/app/src/test/java/llc/slacker/openime/WubiSchemaTest.kt new file mode 100644 index 00000000..517156bc --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/WubiSchemaTest.kt @@ -0,0 +1,29 @@ +package llc.slacker.openime + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * 五笔方案的结构验收:参考键盘的五笔设置里「四码唯一直接上屏」默认打开, + * 对应 librime speller 的 auto_select;字根码最长四码,所以 max_code_length 是 4。 + */ +class WubiSchemaTest { + + private fun asset(relative: String): File = sequenceOf( + File("src/main/assets/$relative"), + File("app/src/main/assets/$relative"), + ).firstOrNull { it.isFile } ?: error("missing test asset: $relative") + + @Test + fun theWubiSchemaCommitsAUniqueFourCodeCandidate() { + val text = asset("rime-data/wubi86.schema.yaml").readText() + assertTrue("missing schema_id wubi86", text.contains("schema_id: wubi86")) + assertTrue("missing dictionary", text.contains("dictionary: wubi86")) + assertTrue("missing speller", text.contains("max_code_length: 4")) + // 「四码唯一直接上屏」:四码打完只剩一个候选时立即上屏。 + assertTrue("auto_select must be on", text.contains("auto_select: true")) + // 五笔是字词表,不做整句。 + assertTrue("wubi must not build sentences", text.contains("enable_sentence: false")) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 54f09a8f..6ef1251e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -329,3 +329,7 @@ Add new entries at the end. - **The stroke keyboard's candidates are ordered by frequency, not by the reference's code point order.** The reference's stroke table lists each code's characters in ascending code point order — all 1,212 codes that have more than one character are ascending, none descending, none other (`doubaoime-teardown/DECODE-BATCH-C.md` §2.1 says why: `T6` is 27,073 entries "按码点排序", and a code's characters are indices into it), so `hs` reads 丁丅十 and `pn` reads 㐅乂人入八. The port's `handwriting-strokes.tsv` is generated from that same table (`scripts/import_doubao_data.py`, `build_handwriting_asset`) but sorts each code's characters by the single-character score of the reference's own lexicon, so all 1,212 multi-character codes come out in descending score instead: `hs` reads 十丁丅, `hp` reads 厂丆. *Reason:* the reference's order is a property of how its table is stored, not a ranking — 丁 before 十 is not a claim that 丁 is the likelier character. Where the reference does rank (its lexicon scores), the port follows it; where the order is the storage order, the same scores give a better first candidate. Nothing else differs: the port's table carries all 25,609 codes and all 27,073 characters of the reference's, with none missing and none extra. + +- **The wubi keyboard commits a unique four-code candidate.** + The reference's 五笔 page carries four switches, and their defaults are in its own preference XML `res/cU.xml` (`R.xml.settings_preferences`, read by `SettingsConfigNext.`): 五笔编码提示 `wubi_code_hint_switch_state` on, 五笔拼音混输 `wubi_mixed_switch_state` on, 四码唯一直接上屏 `wubi_four_code_switch_state` on, 第五码将首选词上屏 `wubi_fifth_code_switch_state` **off**. Only the third has a counterpart in this port's Rime schema, and `wubi86.schema.yaml` did not set it: `speller/auto_select` is what commits a four-code input whose segment holds exactly one candidate (`librime 1.17.0`, `app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`, gated on `auto_select_` and on `menu->Prepare(2) == 1`, `src/rime/menu.cc:26`), so a wubi word with no rival stayed on the bar until the next key. The schema now sets `auto_select: true`, and the code's longest form is four letters in the reference's own table — 25 codes of one letter, 617 of two, 4,809 of three, 143,749 of four, none longer (`doubaoime-teardown/wubi86-decoded.tsv`) — so the gate cannot fire early. + *Reason:* it is the reference's default on a keyboard the port already ships, and the switch is one line of schema. The other two cannot be ported as they stand: `AutoSelectAtMaxCodeLength` (`speller.cc:148`) runs whenever `max_code_length > 0` and ignores `auto_select_` entirely, so 第五码 comes out on here where the reference leaves it off — the two are one setting in librime and cannot be split; 五笔拼音混输 needs a merged wubi+pinyin table this port does not ship (a separate schema, as rime-wubi builds it), and 五笔编码提示 needs a candidate comment the schema does not produce. From fcae0dd46b64797f610b54786a2a37cfc619ac9b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:24:40 +0800 Subject: [PATCH 71/94] Start the six fuzzy pairs the reference starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's preference XML carries a default for every fuzzy pair (res/cU.xml, R.xml.settings_preferences): its master switch is off, and six of the fifteen pairs -- z=zh, c=ch, s=sh, an=ang, en=eng, in=ing -- are true while the other nine are false. Our pairs all started off, so a user who turned 模糊音 on got nothing until they ticked them one by one. FuzzyRule.defaultOn now carries those six, the shipped openime_fuzzy.yaml is generated from them, and the page note says so. Also records the reference's 上滑输入数字符号 switch (enable_key_fusiyin, default false) and why our up swipe stays on by default: the native engine owns that behaviour there, so what it does at run time cannot be read off the artifacts, and turning ours off would strand the corner marks the skin draws. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../main/assets/rime-data/openime_fuzzy.yaml | 14 ++++++++++++- .../llc/slacker/openime/core/FuzzyRule.kt | 20 +++++++++++-------- .../openime/panel/SettingsPanelController.kt | 2 +- .../java/llc/slacker/openime/FuzzyRuleTest.kt | 13 ++++++++++-- docs/DECISIONS.md | 8 ++++++++ 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c306620c..ae68c77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 - 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 - 双拼方案的名称按参考实现的原文对齐:设置里的选项行叫「26键双拼方案」,两项分别是「小鹤双拼」与「自然码双拼」(参考 `resources.arsc` 里双拼方案名只有这两条字符串,没有单叫「自然码」的),原来我们写的是「双拼方案」和「自然码」。双拼方案本身不变,仍是这两个。 - 联想(表情)栏的表换成参考实现自己的那一张。参考的 `follow_dict.dat` 是一棵按「刚上屏的词」查的 UTF-16 字典树,结点里存的就是该词对应的符号(`中国` → 🇨🇳、`火` → 🔥、`上` → ⬆️ ↑、`一` → 1️⃣ ①,`doubaoime-teardown/DECODE-BATCH-C.md` §3.1、§3.2),共 1 万多个词条、其中 5508 个带符号;我们原来是一张手写的 502 词表(开心 → 😊 这类),参考有联想的词(火、好、上班、下雨)上屏后一个角标都不出。现在这张表由 `scripts/import_doubao_data.py` 从参考数据生成(`emoji/follow-keywords.tsv`,5508 词、5616 个符号),手写表仍然先读,两边都有的 312 个词保留手写表里的表情。匹配规则没变(单字只整词匹配,多字词可匹配结尾),所以变的只是覆盖面;表的解析放在输入法启动时的后台线程(与英文词表同处),首次上屏不需要等它。这一栏现在的名字与参考实现一致,叫「智能联想」(参考设置页原文「智能联想」+「根据上文提供的文字、表情、符号等推荐」,`base.apk` 的 `resources.arsc`),说明也改成「选词后按上文给出接续词、表情和颜文字」——原来叫「表情联想」,但栏里早就同时有接续词和颜文字。 diff --git a/app/src/main/assets/rime-data/openime_fuzzy.yaml b/app/src/main/assets/rime-data/openime_fuzzy.yaml index 64efc919..155311ee 100644 --- a/app/src/main/assets/rime-data/openime_fuzzy.yaml +++ b/app/src/main/assets/rime-data/openime_fuzzy.yaml @@ -1,3 +1,15 @@ # Generated by openIME from the 模糊音 switches; do not edit. rules: - __append: [] + __append: + - derive/^zh/z/ + - derive/^z([^h])/zh$1/ + - derive/^ch/c/ + - derive/^c([^h])/ch$1/ + - derive/^sh/s/ + - derive/^s([^h])/sh$1/ + - derive/^([^iuv]*)an$/$1ang/ + - derive/^([^iuv]*)ang$/$1an/ + - derive/en$/eng/ + - derive/eng$/en/ + - derive/in$/ing/ + - derive/ing$/in/ diff --git a/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt b/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt index 69522e68..a018b532 100644 --- a/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt +++ b/app/src/main/java/llc/slacker/openime/core/FuzzyRule.kt @@ -3,8 +3,12 @@ package llc.slacker.openime.core /** * One fuzzy-pinyin pair the user can switch on or off on its own. * - * Six initials and nine finals. All start off ([defaultOn]); the user turns on - * the ones their accent needs. + * Six initials and nine finals. The six commonest confusions start on + * ([defaultOn]) and the rest start off, which is the reference keyboard's own + * default set (`res/cU.xml`, the preference XML: `kZEqualToZh`, `kCEqualToCh`, + * `kSEqualToSh`, `kAnEqualToAng`, `kEnEqualToEng` and `kInEqualToIng` are the + * six carrying `defaultValue=true`). Nothing applies while 模糊音 itself is + * off — that switch ships off, as it does there. * * Each pair works both ways and is defined twice, kept equal by tests: [rime] * is the librime speller algebra (written to openime_fuzzy.yaml), [variants] @@ -17,15 +21,15 @@ enum class FuzzyRule( val defaultOn: Boolean, val rime: List, ) { - Z_ZH("z_zh", "z = zh", true, false, listOf("derive/^zh/z/", "derive/^z([^h])/zh\$1/")), - C_CH("c_ch", "c = ch", true, false, listOf("derive/^ch/c/", "derive/^c([^h])/ch\$1/")), - S_SH("s_sh", "s = sh", true, false, listOf("derive/^sh/s/", "derive/^s([^h])/sh\$1/")), + Z_ZH("z_zh", "z = zh", true, true, listOf("derive/^zh/z/", "derive/^z([^h])/zh\$1/")), + C_CH("c_ch", "c = ch", true, true, listOf("derive/^ch/c/", "derive/^c([^h])/ch\$1/")), + S_SH("s_sh", "s = sh", true, true, listOf("derive/^sh/s/", "derive/^s([^h])/sh\$1/")), N_L("n_l", "n = l", true, false, listOf("derive/^n/l/", "derive/^l/n/")), F_H("f_h", "f = h", true, false, listOf("derive/^f/h/", "derive/^h/f/")), R_L("r_l", "r = l", true, false, listOf("derive/^r/l/", "derive/^l/r/")), - AN_ANG("an_ang", "an = ang", false, false, listOf("derive/^([^iuv]*)an\$/\$1ang/", "derive/^([^iuv]*)ang\$/\$1an/")), - EN_ENG("en_eng", "en = eng", false, false, listOf("derive/en\$/eng/", "derive/eng\$/en/")), - IN_ING("in_ing", "in = ing", false, false, listOf("derive/in\$/ing/", "derive/ing\$/in/")), + AN_ANG("an_ang", "an = ang", false, true, listOf("derive/^([^iuv]*)an\$/\$1ang/", "derive/^([^iuv]*)ang\$/\$1an/")), + EN_ENG("en_eng", "en = eng", false, true, listOf("derive/en\$/eng/", "derive/eng\$/en/")), + IN_ING("in_ing", "in = ing", false, true, listOf("derive/in\$/ing/", "derive/ing\$/in/")), IAN_IANG("ian_iang", "ian = iang", false, false, listOf("derive/ian\$/iang/", "derive/iang\$/ian/")), UAN_UANG("uan_uang", "uan = uang", false, false, listOf("derive/uan\$/uang/", "derive/uang\$/uan/")), UN_ONG("un_ong", "un = ong", false, false, listOf("derive/ong\$/un/", "derive/un\$/ong/")), 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 9b3bb072..8662c99d 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -356,7 +356,7 @@ internal class SettingsPanelController( .toTypedArray(), ) content.addView( - noteText("每一组默认关闭,按自己容易混的音打开。开得越多,候选越杂。"), + noteText("常用的六组(z = zh、c = ch、s = sh、an = ang、en = eng、in = ing)默认打开,其余默认关闭,按自己容易混的音增减。开得越多,候选越杂。"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, diff --git a/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt b/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt index b5be964a..8b6fa79f 100644 --- a/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt +++ b/app/src/test/java/llc/slacker/openime/FuzzyRuleTest.kt @@ -36,10 +36,19 @@ class FuzzyRuleTest { } @Test - fun fifteenPairsAllStartOff() { + fun fifteenPairsWithTheReferencesSixOnByDefault() { assertEquals(15, FuzzyRule.entries.size) assertEquals(6, FuzzyRule.entries.count { it.initial }) - assertTrue(FuzzyRule.DEFAULTS.isEmpty()) + // The reference's preference XML (res/cU.xml) defaults these six to + // true: kZEqualToZh, kCEqualToCh, kSEqualToSh, kAnEqualToAng, + // kEnEqualToEng, kInEqualToIng. + assertEquals( + setOf( + FuzzyRule.Z_ZH, FuzzyRule.C_CH, FuzzyRule.S_SH, + FuzzyRule.AN_ANG, FuzzyRule.EN_ENG, FuzzyRule.IN_ING, + ), + FuzzyRule.DEFAULTS, + ) } @Test diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6ef1251e..b2e0731e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -333,3 +333,11 @@ Add new entries at the end. - **The wubi keyboard commits a unique four-code candidate.** The reference's 五笔 page carries four switches, and their defaults are in its own preference XML `res/cU.xml` (`R.xml.settings_preferences`, read by `SettingsConfigNext.`): 五笔编码提示 `wubi_code_hint_switch_state` on, 五笔拼音混输 `wubi_mixed_switch_state` on, 四码唯一直接上屏 `wubi_four_code_switch_state` on, 第五码将首选词上屏 `wubi_fifth_code_switch_state` **off**. Only the third has a counterpart in this port's Rime schema, and `wubi86.schema.yaml` did not set it: `speller/auto_select` is what commits a four-code input whose segment holds exactly one candidate (`librime 1.17.0`, `app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`, gated on `auto_select_` and on `menu->Prepare(2) == 1`, `src/rime/menu.cc:26`), so a wubi word with no rival stayed on the bar until the next key. The schema now sets `auto_select: true`, and the code's longest form is four letters in the reference's own table — 25 codes of one letter, 617 of two, 4,809 of three, 143,749 of four, none longer (`doubaoime-teardown/wubi86-decoded.tsv`) — so the gate cannot fire early. *Reason:* it is the reference's default on a keyboard the port already ships, and the switch is one line of schema. The other two cannot be ported as they stand: `AutoSelectAtMaxCodeLength` (`speller.cc:148`) runs whenever `max_code_length > 0` and ignores `auto_select_` entirely, so 第五码 comes out on here where the reference leaves it off — the two are one setting in librime and cannot be split; 五笔拼音混输 needs a merged wubi+pinyin table this port does not ship (a separate schema, as rime-wubi builds it), and 五笔编码提示 needs a candidate comment the schema does not produce. + +- **Six 模糊音 pairs start on, following the reference's own defaults.** + The reference's fuzzy page has a master switch and fifteen pairs, and its preference XML gives every one of them a default (`res/cU.xml`, `R.xml.settings_preferences`): the master `enable_fuzzy` **off**, and with it on, `kZEqualToZh`, `kCEqualToCh`, `kSEqualToSh`, `kAnEqualToAng`, `kEnEqualToEng` and `kInEqualToIng` are `defaultValue=true` while the other nine (`kLEqualToN`, `kFEqualToH`, `kREqualToL`, `kIanEqualToIang`, `kUanEqualToUang`, `kUnEqualToOng`, `kEngEqualToOng`, `kHuiEqualToFei`, `kHuangEqualToWang`) are false — one key per pair this port ships, the set matching fifteen for fifteen. This port's pairs all started off (`FuzzyRule.defaultOn`, and the shipped `openime_fuzzy.yaml` held an empty rule list), so a user who turned 启用模糊音 on got nothing until they ticked pairs one by one. `defaultOn` now carries the reference's six and `openime_fuzzy.yaml` is generated from them. + *Reason:* the master switch still ships off, exactly as there, so nothing changes until the user asks for fuzzy input — and when they do, the six pairs a Chinese speaker is likeliest to need are already the reference's answer. The pairs are the same fifteen either way (verified row by row against `f_syllable-variants.tsv` when they were added), so only the defaults move. + +- **An up swipe on a letter key keeps typing its corner mark here, where the reference ships it off.** + The reference's keyboard-layout page carries a switch titled 「上滑输入数字符号」 (`fragment_kbd_layout`, `res/Hi.xml`, the row with id `0x7f0a0300`, title `fusiyin_enable_title`) whose state is read from the preference `enable_key_fusiyin` — the code that binds it is the fragment that reads that very key — and that preference defaults to **false** in `res/cU.xml`. Its skin carries the gesture as a bubble with the choices in it (`fusiyin_bubble_text="1 Q q"` on the Q key, `input_kbd_pinyin26.xml:10`), which the native engine opens and commits from (`libkeyboard.so`: `fusiyin enable: {}, old: {}`, `show fusiyin swipe up guide`, `kFusiyinBubbleSelection default commit fusiyin`). This port's equivalent switch (`上滑输入数字`) defaults to on and its swipe commits the mark directly, without a bubble. + *Reason:* what the reference's switch does at run time is not decidable from the artifacts — the native engine is the one that reads it, and with it off the swipe may do nothing or may commit the bubble's first item, which is what this port already does. Turning it off here would leave the corner marks the skin draws (rendered on every 26-key letter, as the reference always renders them) unreachable, a visible loss against an undecided gain, so the default stays on and the finding is recorded instead. From 55d979a95805a3e83e02426cc9609d44a755115a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:48:29 +0800 Subject: [PATCH 72/94] Read the words the reference reads differently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference's misread-word dictionary (incorrect_syllable, 6,308 records of a word plus the reading one of its characters takes there) disagrees with our tables on 66 of them: 岑参 is Cén Shēn where we read cen can, 五行八作 takes háng and zuō, 说客 takes shuō. Those differences now generate pinyin_reading_fixes.tsv (64 rows), which PinyinLexicon loads next to pinyin_phrases.tsv, so the word can be typed under its real reading. Only the differences ship: 3,390 records already read as the tables do, and the 2,852 the tables lack are a coverage question rather than a wrong reading. Each row keeps the word's own weight, so a correction never overtakes the ordinary phrases. The generator maps ü to v before stripping tone marks. Decomposition alone turns ǚ into u and the first run wrote luhuzi for 捋胡子, an override of a reading that was already right. The file's index stays out of the port: its counting space resolves to neither the records nor the characters, and which syllable each row names does not decode from the artifacts. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + app/src/main/assets/pinyin_reading_fixes.tsv | 66 ++++++++++ .../openime/candidate/PinyinLexicon.kt | 25 ++++ .../llc/slacker/openime/ReadingFixesTest.kt | 71 +++++++++++ docs/DECISIONS.md | 6 + scripts/import_doubao_data.py | 113 ++++++++++++++++++ 6 files changed, 282 insertions(+) create mode 100644 app/src/main/assets/pinyin_reading_fixes.tsv create mode 100644 app/src/test/java/llc/slacker/openime/ReadingFixesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae68c77a..4bcb9432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 参考实现自带一份「错音词」表(每个词标出某个字在词里该怎么读,如「阿房宫」的「阿」读 ē、「房」读 páng),共 6308 条。对照我们的词表:3390 条读音本来就一致,2852 条词我们表里根本没有(多为姓名和少见的专名),剩下 66 条是我们读错的——岑参(应读 cén shēn,我们表里是 cēn cān)、五行八作(wǔ háng bā zuō)、说客(shuō kè)这类。这 66 条现在生成一张 64 行的修正表 `pinyin_reading_fixes.tsv`,由拼音词库一并加载,所以读错的词现在能用正确读音打出来(打 `censhen` 出「岑参」)。修正行沿用该词自己的词频权重,不会盖过同读音的常用词;只写不一致的那几十条,读音相同的 3390 条不重复,我们表里没有的 2852 条属于「收词范围」而不是「读音错误」,这次不动。参考那份表的索引(每行一个音节 id 加两个计数字段)含义在它的 native 代码里,从数据无法判定,我们只用了内容能逐字节解码的部分。 - 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 - 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 - 双拼方案的名称按参考实现的原文对齐:设置里的选项行叫「26键双拼方案」,两项分别是「小鹤双拼」与「自然码双拼」(参考 `resources.arsc` 里双拼方案名只有这两条字符串,没有单叫「自然码」的),原来我们写的是「双拼方案」和「自然码」。双拼方案本身不变,仍是这两个。 diff --git a/app/src/main/assets/pinyin_reading_fixes.tsv b/app/src/main/assets/pinyin_reading_fixes.tsv new file mode 100644 index 00000000..ead5cf76 --- /dev/null +++ b/app/src/main/assets/pinyin_reading_fixes.tsv @@ -0,0 +1,66 @@ +# Readings from the reference's misread-word table, where the merged +# tables read the word differently. Generated by scripts/import_doubao_data.py. +baomo 薄膜 4625 +baowanzhaigua 抱蔓摘瓜 4 +bise 鼻塞 18460 +bianyifang 便宜坊 5219 +bingshengjingqi 屏声静气 38 +budejin 不得劲 650 +caimeier 猜谜儿 55 +censhen 岑参 1 +chenbolin 陈柏霖 443 +chichongrui 迟重瑞 52 +chishengxizu 赤绳系足 95 +chuguailouchou 出乖露丑 335 +cuanzhu 攒竹 25 +cuanzhuxue 攒竹穴 60 +daozaishini 道在屎溺 258 +diuxinlachang 丢心落肠 1 +gelili 盖丽丽 2 +goumang 句芒 312 +haigeqiao 海蛤壳 130 +jieqiaochong 介壳虫 3090 +jingbaojimihua 劲爆鸡米花 397 +jingjiu 劲酒 202 +juanjin 圈进 1515 +juncai 隽才 70 +lengchongmu 冷冲模 2195 +lilusanbaidan 吏禄三百石 15 +lingguizhugantang 苓桂术甘汤 4 +luchuxiaorong 露出笑容 5365 +lutoujiao 露头角 820 +luoboying 萝卜缨 5 +majiabuxiaoqu 马家堡小区 4 +meier 谜儿 315 +mieyingzhuifeng 灭景追风 1 +mojinshayu 没金铩羽 1 +moxue 磨削 2152 +mumu 木模 2375 +namezhe 那么着 405 +niudong 牛丼 999 +niudongfan 牛丼饭 999 +oubolai 欧珀莱 3790 +qiangdisonglong 强的松龙 118 +quji 取给 750 +renzheyaoshan 仁者乐山 212 +rongrongyixie 融融泄泄 10 +shengzhazi 生查子 1470 +shoupaji 手扒鸡 75 +shudi 倏地 16850 +shuoke 说客 5375 +taoshai 套色 5585 +taozaizhiji 叨在知己 1 +tiaodangxian 调档线 4350 +tuzouguluo 兔走鹘落 1 +wanjingwu 玩劲舞 150 +wuhangbazuo 五行八作 200 +wuzhiyuqisi 恶之欲其死 2 +xiuxiuyangxing 修身养性 11435 +yexingpixiu 夜行被绣 1 +yujuhuanying 欲拒还迎 1515 +zhaxiaoxin 查小欣 20 +zhaodesewei 昭德塞违 2 +zhijiangmu 纸浆模 210 +zhizheyaoshui 智者乐水 2560 +zhucibishi 属词比事 5 +zuijingbao 最劲爆 880 diff --git a/app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt b/app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt index edffe198..0cca7dab 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/PinyinLexicon.kt @@ -6,6 +6,7 @@ import android.content.Context object PinyinLexicon { private const val CHAR_ASSET = "pinyin_chars.tsv" private const val PHRASE_ASSET = "pinyin_phrases.tsv" + private const val READING_FIX_ASSET = "pinyin_reading_fixes.tsv" private const val CHAR_WEIGHT_ASSET = "rime-data/openime_dicts/8105.dict.yaml" private data class WeightedValue( @@ -76,6 +77,14 @@ object PinyinLexicon { } } + // pinyin_reading_fixes.tsv corrects readings the tables above get + // wrong (the reference's misread-word list). Its rows keep the + // word's own corpus weight, so a correction never jumps ahead of + // the ordinary phrases of that pinyin. + context.assets.open(READING_FIX_ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + readingFixes(lines).forEach { (pinyin, text, weight) -> add(pinyin, text, weight) } + } + val weights = HashMap() val ordered = result.mapValues { (pinyin, values) -> values.values @@ -117,6 +126,22 @@ object PinyinLexicon { return rows } + /** `(pinyin, text, weight)` rows of the reading-fix table (comment lines skipped). */ + internal fun readingFixes(lines: Sequence): List> { + val rows = ArrayList>() + for (line in lines) { + if (line.isBlank() || line.startsWith('#')) continue + val fields = line.split('\t', limit = 3) + if (fields.size < 3) continue + val pinyin = fields[0].trim() + val text = fields[1].trim() + val weight = fields[2].trim().toIntOrNull() ?: continue + if (pinyin.isEmpty() || text.isEmpty() || weight < 0) continue + rows += Triple(pinyin, text, weight) + } + return rows + } + /** Already-loaded generated lexicon; empty only in isolated JVM tests before [load]. */ fun current(): Map> = cached.orEmpty() diff --git a/app/src/test/java/llc/slacker/openime/ReadingFixesTest.kt b/app/src/test/java/llc/slacker/openime/ReadingFixesTest.kt new file mode 100644 index 00000000..e2ff3f98 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ReadingFixesTest.kt @@ -0,0 +1,71 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.PinyinLexicon +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The reference's misread-word table, reduced to the words its own reading + * disagrees with ours on (see scripts/import_doubao_data.py). + */ +class ReadingFixesTest { + private fun asset(relative: String): File = sequenceOf( + File("src/main/assets/$relative"), + File("app/src/main/assets/$relative"), + ).first { it.isFile } + + private val fixes by lazy { PinyinLexicon.readingFixes(asset("pinyin_reading_fixes.tsv").readLines().asSequence()) } + + /** Every syllable the generated Pinyin table knows. */ + private val syllables: Set by lazy { + asset("pinyin_chars.tsv").readLines() + .map { it.substringBefore('\t') } + .filter { it.isNotEmpty() && it.all { ch -> ch in 'a'..'z' } } + .toSet() + } + + /** Split a code the way the decoder reads it: longest syllable first. */ + private fun split(code: String): List? { + val out = ArrayList() + var index = 0 + while (index < code.length) { + val length = (6 downTo 1).firstOrNull { index + it <= code.length && code.substring(index, index + it) in syllables } + ?: return null + out += code.substring(index, index + length) + index += length + } + return out + } + + @Test + fun everyRowIsOneSyllablePerCharacterOfAKnownWord() { + assertTrue("读音修正表不该退化成空表", fixes.size >= 40) + fixes.forEach { (pinyin, text, weight) -> + val parts = split(pinyin) + assertTrue("$pinyin 必须能切成音节", parts != null) + assertEquals("$pinyin / $text 的音节数必须等于字数", text.length, parts!!.size) + assertTrue("$text 必须是汉字", text.all { it in '一'..'鿿' }) + assertTrue("$pinyin / $text 的权重必须为正", weight > 0) + } + assertEquals(fixes.size, fixes.map { it.first to it.second }.distinct().size) + } + + @Test + fun theReferenceReadsTheseWordsDifferentlyFromTheTables() { + // 岑参 is Cén Shēn, read cēn cān by the tables. Rows are pinyin / word / weight. + val byWord = fixes.associateBy { it.second } + assertEquals("censhen", byWord.getValue("岑参").first) + // 五行八作 takes háng and zuō, 说客 takes shuō. + assertEquals("wuhangbazuo", byWord.getValue("五行八作").first) + assertEquals("shuoke", byWord.getValue("说客").first) + } + + @Test + fun aClosedVowelNeverComesBackAsAPlainU() { + // 捋胡子 is lv hu zi. The generated rows strip tone marks, and a strip + // that loses the diaeresis would emit the wrong reading lu hu zi here. + assertTrue(fixes.none { it.second == "捋胡子" }) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b2e0731e..50cfd5b9 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -341,3 +341,9 @@ Add new entries at the end. - **An up swipe on a letter key keeps typing its corner mark here, where the reference ships it off.** The reference's keyboard-layout page carries a switch titled 「上滑输入数字符号」 (`fragment_kbd_layout`, `res/Hi.xml`, the row with id `0x7f0a0300`, title `fusiyin_enable_title`) whose state is read from the preference `enable_key_fusiyin` — the code that binds it is the fragment that reads that very key — and that preference defaults to **false** in `res/cU.xml`. Its skin carries the gesture as a bubble with the choices in it (`fusiyin_bubble_text="1 Q q"` on the Q key, `input_kbd_pinyin26.xml:10`), which the native engine opens and commits from (`libkeyboard.so`: `fusiyin enable: {}, old: {}`, `show fusiyin swipe up guide`, `kFusiyinBubbleSelection default commit fusiyin`). This port's equivalent switch (`上滑输入数字`) defaults to on and its swipe commits the mark directly, without a bubble. *Reason:* what the reference's switch does at run time is not decidable from the artifacts — the native engine is the one that reads it, and with it off the swipe may do nothing or may commit the bubble's first item, which is what this port already does. Turning it off here would leave the corner marks the skin draws (rendered on every 26-key letter, as the reference always renders them) unreachable, a visible loss against an undecided gain, so the default stays on and the finding is recorded instead. + +- **The reference's misread words carry their reading as a small fix table.** + The reference ships a misread-word dictionary — `assets/dict/66c18d5eaa2e`, type 12034, `incorrectsyllable` — whose data section is 6,308 records of `[u16 word_id][u16 nbytes][UTF-16LE word][u16 xlen][UTF-16LE note]` walking all 131,558 bytes, and whose note names the 0-based position of a character and how that word reads it: `阿房` `0ē`, `呆板` `0dāi`, `阿房宫` `0ē 1páng`, `东莞` `1guǎn` (`doubaoime-teardown/DECODE-BATCH-A.md` §5, `incorrectsyllable-decoded.tsv`). Against this port's tables 3,390 of those records already read the same way, 2,852 name words the tables do not carry at all (mostly personal names and rare proper nouns), and 66 read differently — 岑参 `cen can` for Cén Shēn, 五行八作 `wu xing ba zuo` for wǔ háng bā zuō, 说客 `shui ke` for shuō kè. Those 66 records now generate `pinyin_reading_fixes.tsv` (64 rows after duplicates), which `PinyinLexicon` loads next to `pinyin_phrases.tsv`. + *Reason:* a word the tables read wrongly cannot be typed at all under its real reading, and the correction is data, so it costs nothing at run time: the file is 64 rows, it loads with the tables the lexicon already reads, and each row keeps the word's own corpus weight so a correction never overtakes the ordinary phrases of the same reading. Only the differences ship — rows for the 3,390 words that already agree would repeat the tables, and the 2,852 the tables lack are a coverage question (a lexicon of rare proper nouns), not a wrong reading to fix. The file is generated by `scripts/import_doubao_data.py` from the teardown's decoded TSV, and `--reading-fixes-only` writes it without rewriting the large tables. + The note readings carry tone marks, and this port's tables spell the front rounded vowel `v` (捋胡子 is `lv hu zi`): stripping the marks with Unicode decomposition alone turns ǚ into u, which made the first run write `luhuzi` and `yulu` — an *override* of a reading that was already right. The generator maps ü/ǖ/ǘ/ǚ/ǜ to v before decomposing, and `ReadingFixesTest.aClosedVowelNeverComesBackAsAPlainU` guards it. + Nothing of the file's *index* is ported. It is 12,541 rows of `(u16 syllable id, u32 end_rec, u32 end_byte)` closed by the sentinel `(0, 12540, 131558)`; the teardown leaves the counting space of `end_rec` unverified and the port's own walk agrees that it counts neither the records (6,308) nor the characters of the words (21,505) nor of the notes (25,350) — its maximum is the row count minus one — while the third column does land on record boundaries: every one of its 12,166 nonzero values is the byte end of a record of the data section, and its last one is the end of the section itself. The keys are syllable ids of the canonical table, but which syllable of which record each row names does not resolve from the artifacts (the rows are neither in key order — 3,874 descents — nor do their keys match the record at their own byte, at the next one, or any reading the word's characters can take), and no setting in `res/cU.xml` mentions the file, so the index is left where the reference keeps it — in its native code — and only the content that is decodable to the byte is used. diff --git a/scripts/import_doubao_data.py b/scripts/import_doubao_data.py index d9b2e1f7..55538c7f 100644 --- a/scripts/import_doubao_data.py +++ b/scripts/import_doubao_data.py @@ -40,6 +40,13 @@ (word / symbol / symbol ..., tab separated). The curated ``emoji/keywords.tsv`` stays first in the app's merge, so it is never overwritten. +9. ``pinyin_reading_fixes.tsv`` + Readings for words the reference's misread-word table (its + ``incorrectsyllable`` dictionary) reads differently from the merged base + table, one row per corrected reading (pinyin / text / weight). Only the + differences are written: the table's other words already carry the right + reading. The app loads this small file next to ``pinyin_phrases.tsv``, + so a correction never has to wait for the large local merge. Originals are backed up once to ``.local/doubao-import/originals/`` and a report is written to ``.local/doubao-import/report.json``. @@ -52,6 +59,7 @@ import re import statistics import sys +import unicodedata from pathlib import Path HAN_RE = re.compile( @@ -172,6 +180,101 @@ def write_rows(path: Path, header, rows): os.replace(tmp, path) +def toneless(syllable: str) -> str: + """`nuó` -> `nuo`, the spelling the Rime tables use. + + A front rounded vowel keeps its diaeresis in the tonic vowel, but the + tables spell that syllable with ``v`` (``lǚ`` is ``lv``, never ``lu``), + so the diaeresis has to survive the tone marks. + """ + out = [] + for character in syllable: + if character in "üǖǘǚǜ": # ü ǖ ǘ ǚ ǜ + out.append("v") + continue + out.append("".join(c for c in unicodedata.normalize("NFD", character) + if not unicodedata.combining(c))) + return "".join(out) + + +def build_reading_fixes(src: Path, base_entries, assets: Path, report): + """Write pinyin_reading_fixes.tsv from the reference's misread-word table. + + ``incorrectsyllable-decoded.tsv`` names a word and, per annotated + position, the reading that word needs. Most of those words already carry + that reading in the merged base table; only the ones that do not are + written out, as the word's existing pinyin with the annotated positions + replaced. The app reads this file directly, so the corrections reach + candidates without a rerun of the large tables. + """ + path = src / "incorrectsyllable-decoded.tsv" + if not path.is_file(): + report["reading_fixes"] = {"skipped": "incorrectsyllable-decoded.tsv not found"} + return + entries_by_word = {} + for text, pinyin, weight, _ in base_entries: + if len(text) < 2: + continue + syllables = tuple(pinyin.split()) + if len(syllables) == len(text): + entries_by_word.setdefault(text, []).append((syllables, weight)) + + rows = {} + records = satisfied = unknown = 0 + with io.open(path, encoding="utf-8") as f: + next(f) + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 4: + continue + word, notes = parts[2], parts[3] + wanted = {} + for token in notes.split(): + position, reading = token.split(":", 1) + wanted[int(position)] = toneless(reading) + if not HAN_RE.match(word) or not wanted or len(word) < 2: + continue + records += 1 + entries = entries_by_word.get(word) + if not entries: + unknown += 1 + continue + if any(all(syllables[i] == reading for i, reading in wanted.items()) + for syllables, _ in entries): + satisfied += 1 + continue + syllables, weight = max(entries, key=lambda e: e[1]) + fixed = [syllables[i] if i < len(syllables) else "" for i in range(len(syllables))] + for position, reading in wanted.items(): + if position < len(fixed): + fixed[position] = reading + text = " ".join(fixed) + if not PINYIN_RE.match(text): + continue + key = (text, word) + rows[key] = max(rows.get(key, 0), weight) + + if not rows: + report["reading_fixes"] = {"records": records, "written": 0} + return + out = assets / "pinyin_reading_fixes.tsv" + tmp = out.with_suffix(out.suffix + ".tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# Readings from the reference's misread-word table, where the merged\n") + f.write("# tables read the word differently. Generated by scripts/import_doubao_data.py.\n") + for (pinyin, word), weight in sorted(rows.items()): + f.write("%s\t%s\t%d\n" % (pinyin.replace(" ", ""), word, weight)) + os.replace(tmp, out) + report["reading_fixes"] = { + "records": records, + "already_correct": satisfied, + "word_not_in_base": unknown, + "written": len(rows), + } + print("reading fixes: %d rows written (%d records, %d already correct, %d words unknown)" + % (len(rows), records, satisfied, unknown)) + + def build_wubi_dict(src: Path, rime: Path, report): """Generate openime_dicts/wubi86.dict.yaml from the decoded candidate table.""" path = src / "wubi86-decoded.tsv" @@ -390,6 +493,8 @@ def main(): ap.add_argument("--min-phrase-weight", type=int, default=100_000) ap.add_argument("--max-phrases", type=int, default=120_000) ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--reading-fixes-only", action="store_true", + help="write pinyin_reading_fixes.tsv and stop, without rewriting the tables") args = ap.parse_args() repo = Path(args.repo) @@ -427,6 +532,14 @@ def main(): report["ice_base_entries"] = len(base_entries) report["ice_char_entries"] = len(char_entries) + print("[1b/5] write reading fixes ...") + if not args.dry_run: + build_reading_fixes(src, base_entries, assets, report) + if args.reading_fixes_only: + (repo / ".local" / "doubao-import" / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return + print("[2/5] read Doubao lexicon ...") doubao = [] # (text, pinyin, score) with io.open(lexicon_path, encoding="utf-8") as f: From 86143957bbc0a5ef4e402b9ca312071bde81e3a9 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:52:04 +0800 Subject: [PATCH 73/94] Read the letter-hint order from the reference on P and L MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both of the reference's 26-key layouts list the lower case first on P and L ("p P", "l L") — in the bubble text and in the keycode list — where the other twenty-four keys read "X x". The nine-key layout leads lowercase everywhere, and the swipe-up lists set their own per-key order, so this is the reference's data on those two keys rather than a rule to generalise. The port now offers the same order there. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/keyboard/LetterHintPolicy.kt | 17 ++++++++++++----- .../llc/slacker/openime/LetterHintPolicyTest.kt | 11 +++++++++++ docs/DECISIONS.md | 4 ++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcb9432..abc2310e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 小写字母提示的顺序按参考键盘对齐:长按 P、L 两键时,候选里小写排在大写前面(`p P`、`l L`),其余 24 键仍是大写在前。参考皮肤的中文与英文两套 26 键布局都这么写(`input_kbd_pinyin26.xml`、`input_kbd_english26.xml` 的 `long_press_bubble_text`,连按键码列表也是 `0x0050 0x1050` 小写在前),不是录入误差;这两键是两排十个键里各自最右的一个,参考其余气球顺序也按每键各自设定(上滑数字的位置在 Q 键最前、W 键居中、P 键最后),所以照它的数据来。 - 参考实现自带一份「错音词」表(每个词标出某个字在词里该怎么读,如「阿房宫」的「阿」读 ē、「房」读 páng),共 6308 条。对照我们的词表:3390 条读音本来就一致,2852 条词我们表里根本没有(多为姓名和少见的专名),剩下 66 条是我们读错的——岑参(应读 cén shēn,我们表里是 cēn cān)、五行八作(wǔ háng bā zuō)、说客(shuō kè)这类。这 66 条现在生成一张 64 行的修正表 `pinyin_reading_fixes.tsv`,由拼音词库一并加载,所以读错的词现在能用正确读音打出来(打 `censhen` 出「岑参」)。修正行沿用该词自己的词频权重,不会盖过同读音的常用词;只写不一致的那几十条,读音相同的 3390 条不重复,我们表里没有的 2852 条属于「收词范围」而不是「读音错误」,这次不动。参考那份表的索引(每行一个音节 id 加两个计数字段)含义在它的 native 代码里,从数据无法判定,我们只用了内容能逐字节解码的部分。 - 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 - 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 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 0493cac2..6850bb49 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt @@ -8,8 +8,7 @@ package llc.slacker.openime.keyboard * mark exists. * * Swipe up on the key commits the hint. A long press opens the letter in both - * cases, upper first, as the reference keyboard does — it does not offer the - * hint. + * cases, in the reference keyboard's own order — it does not offer the hint. */ internal object LetterHintPolicy { private val DIGITS = mapOf( @@ -40,8 +39,16 @@ internal object LetterHintPolicy { /** * What a long press on a letter key offers, in popup order: the letter in - * both cases, upper first. + * both cases, upper first — except on P and L, where the reference's own + * long-press list reads lower first (`p P`, `l L`, in both its Chinese and + * its English layout). No other key or layout leads with the lower case, + * so this is the reference's order on those two keys and not a rule. */ - fun caseChoices(letter: Char): List = - listOf(letter.uppercaseChar().toString(), letter.lowercaseChar().toString()) + private val LOWER_FIRST = setOf('p', 'l') + + fun caseChoices(letter: Char): List { + val lower = letter.lowercaseChar().toString() + val upper = letter.uppercaseChar().toString() + return if (letter.lowercaseChar() in LOWER_FIRST) listOf(lower, upper) else listOf(upper, lower) + } } diff --git a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt index 8c24da1f..3899ad58 100644 --- a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt @@ -75,4 +75,15 @@ class LetterHintPolicyTest { assertEquals(listOf("Q", "q"), LetterHintPolicy.caseChoices('q')) assertEquals(listOf("S", "s"), LetterHintPolicy.caseChoices('s')) } + + /** The reference skin lists P and L lower first in both of its layouts. */ + @Test + fun onlyPAndLLeadWithTheLowerCase() { + assertEquals(listOf("p", "P"), LetterHintPolicy.caseChoices('p')) + assertEquals(listOf("l", "L"), LetterHintPolicy.caseChoices('l')) + assertEquals( + listOf('l', 'p'), + letters.filter { LetterHintPolicy.caseChoices(it).first() == it.toString() }, + ) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 50cfd5b9..ec08a2aa 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -140,8 +140,8 @@ Add new entries at the end. *Reason:* keyboard-type parity with the reference product without Lua or external dependencies. - **Letter hints follow the reference skin key by key.** - The corner marks on the 26-key letters are the reference product's own map (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml`, `input_kbd_english26.xml`, attribute `ficon_text`): the same key carries the full-width mark in Chinese and the ASCII mark in English, and the top row carries 1–0. A long press opens only the letter in both cases, upper first (`long_press_bubble_text`); the mark belongs to the swipe-up gesture (`fusiyin_keycode`), and the comma/period key also commits the mark above it (。 / .) on an up swipe. - *Reason:* the previous marks (a=~, s=!, d=@ …) were an older scheme of our own; these values are read from the shipped skin instead of guessed. The reference lists the lower case first on P and L only; we keep a single rule (upper first) on every key, and the digit row is unchanged. + The corner marks on the 26-key letters are the reference product's own map (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml`, `input_kbd_english26.xml`, attribute `ficon_text`): the same key carries the full-width mark in Chinese and the ASCII mark in English, and the top row carries 1–0. A long press opens only the letter in both cases (`long_press_bubble_text`); the mark belongs to the swipe-up gesture (`fusiyin_keycode`), and the comma/period key also commits the mark above it (。 / .) on an up swipe. + *Reason:* the previous marks (a=~, s=!, d=@ …) were an older scheme of our own; these values are read from the shipped skin instead of guessed, and the digit row is unchanged. The long-press list is read the same way, order and all: every key reads `X x` except P and L, which read `p P` and `l L` in **both** of the reference's layouts and in their keycode lists too (`long_press_bubble_keycode="0x0050 0x1050"`), so the port offers lower first on those two keys and upper first on the other twenty-four (`LetterHintPolicy.caseChoices`, `LetterHintPolicyTest.onlyPAndLLeadWithTheLowerCase`). The two are the last key of the two ten-key rows, and the skin sets its bubble orders per key rather than by a rule elsewhere as well — the swipe-up list leads with the mark on Q (`1 Q q`), with the upper case on W (`W 2 w`), and with the lower case on P (`p P 0`), while the nine-key layout leads lowercase on *every* key's long press (`input_kbd_pinyin9.xml`: `g h i G H I 4`) — so the order is taken as the reference's data on those keys, not generalised from it. - **Key weights and the nine-key column follow the reference skin.** The 26-key letters A and L carry weight 15 against a letter's 10 (`input_kbd_pinyin26.xml:48,85` against `style.xml:300`), and the same ratio covers 分词 and 退格 in the last row (`cls_button_left_big` / `cls_button_right_big`, `style.xml:907-912`); openIME drew nine equal letters and 1.25 for the function keys. The nine-key and stroke side column is 删除 / 重输 / 发送 with send twice as tall (`input_kbd_pinyin9.xml:104-111`, `height_weight="2"`), and the space bar carries the corner 0 (`ficon_text="0"`, `fusiyin_keycode="0x3030"`) instead of a separate zero key: an up swipe on the space bar types it, behind the same 上滑数字 setting as the letter marks. Candidate items pad 8dp per side (`cls_option_canditem` `textpadding="0.027"` of the 308-unit strip plus `childpadding="4"`). From ea7e255d00bbd49ad86a84ef4bfd710bffe49e4b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:53:14 +0800 Subject: [PATCH 74/94] Record the two differences the preference sweep leaves Unlike the letter-hint order, these two are deliberate: this port's haptic default (its own tuned KEYBOARD_TAP with a slider) and the single association switch against the reference's four. Everything else in the reference's 123-row preference file is matched where the feature exists here, and the rows for features this port has no counterpart for are called out in the same note. Co-Authored-By: Claude Code --- docs/DECISIONS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ec08a2aa..a29819bb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -347,3 +347,7 @@ Add new entries at the end. *Reason:* a word the tables read wrongly cannot be typed at all under its real reading, and the correction is data, so it costs nothing at run time: the file is 64 rows, it loads with the tables the lexicon already reads, and each row keeps the word's own corpus weight so a correction never overtakes the ordinary phrases of the same reading. Only the differences ship — rows for the 3,390 words that already agree would repeat the tables, and the 2,852 the tables lack are a coverage question (a lexicon of rare proper nouns), not a wrong reading to fix. The file is generated by `scripts/import_doubao_data.py` from the teardown's decoded TSV, and `--reading-fixes-only` writes it without rewriting the large tables. The note readings carry tone marks, and this port's tables spell the front rounded vowel `v` (捋胡子 is `lv hu zi`): stripping the marks with Unicode decomposition alone turns ǚ into u, which made the first run write `luhuzi` and `yulu` — an *override* of a reading that was already right. The generator maps ü/ǖ/ǘ/ǚ/ǜ to v before decomposing, and `ReadingFixesTest.aClosedVowelNeverComesBackAsAPlainU` guards it. Nothing of the file's *index* is ported. It is 12,541 rows of `(u16 syllable id, u32 end_rec, u32 end_byte)` closed by the sentinel `(0, 12540, 131558)`; the teardown leaves the counting space of `end_rec` unverified and the port's own walk agrees that it counts neither the records (6,308) nor the characters of the words (21,505) nor of the notes (25,350) — its maximum is the row count minus one — while the third column does land on record boundaries: every one of its 12,166 nonzero values is the byte end of a record of the data section, and its last one is the end of the section itself. The keys are syllable ids of the canonical table, but which syllable of which record each row names does not resolve from the artifacts (the rows are neither in key order — 3,874 descents — nor do their keys match the record at their own byte, at the next one, or any reading the word's characters can take), and no setting in `res/cU.xml` mentions the file, so the index is left where the reference keeps it — in its native code — and only the content that is decodable to the byte is used. + +- **The preference pages carry two deliberate differences; the rest of the reference's list is matched.** + The reference's whole preference set is one XML file — 123 rows, 78 of them booleans, `res/cU.xml` = `R.xml.settings_preferences`, read by `SettingsConfigNext.` — and walking it against this port's settings leaves two differences. Haptics: the reference's `enable_key_vibrate` defaults **false** and `key_vibrate_intensity` is `"NONE"` (sound off, `enable_key_sound=false`, matches this port). Association granularity: the reference splits the row into four switches — `typing_associate_enabled`, `emoji_recommend_enabled`, `image_recommend_enabled` and `kaomoji_recommend_enabled`, all `true` — where this port ships one switch, on, and no image recommendations. + *Reason:* the vibration default is this port's own setting with its own slider, tuned and shipped deliberately (a crisp `KEYBOARD_TAP` per key, `CHANGELOG` 0.0.3), so copying the reference's off would remove a feature to match a toggle, not a behaviour; the association row offers 接续词, 表情 and 颜文字 from the reference's own table, and a switch per kind would add three settings for one row. Every other default in the list is matched where the feature exists here: 四码上屏/模糊音六组/双拼名称/回车发送/首字母大写/浮动气泡/剪贴板与短信提示条/联想总开关/语音标点与去语气词, and the rows whose features this port does not have (云端、账号同步、单手模式、AI 助手、图片推荐、自定义工具栏、候选字号、高分屏开关) are untouched by design. From 46e034825df4b5628532f26b08b00df21afac151 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:58:07 +0800 Subject: [PATCH 75/94] Keep one long-press order on every key The reference reads "p P" and "l L" where its other twenty-four keys read "X x", and its nine-key long press leads lowercase throughout: one product contradicting itself. A consistent keyboard is worth more than a two-key copy of that, so every key offers the letter in both cases upper first and the skin's two exceptions stay in DECISIONS.md with the rest of the evidence. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 - .../openime/keyboard/LetterHintPolicy.kt | 18 +++++++----------- .../slacker/openime/LetterHintPolicyTest.kt | 14 +++++--------- docs/DECISIONS.md | 2 +- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abc2310e..4bcb9432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 小写字母提示的顺序按参考键盘对齐:长按 P、L 两键时,候选里小写排在大写前面(`p P`、`l L`),其余 24 键仍是大写在前。参考皮肤的中文与英文两套 26 键布局都这么写(`input_kbd_pinyin26.xml`、`input_kbd_english26.xml` 的 `long_press_bubble_text`,连按键码列表也是 `0x0050 0x1050` 小写在前),不是录入误差;这两键是两排十个键里各自最右的一个,参考其余气球顺序也按每键各自设定(上滑数字的位置在 Q 键最前、W 键居中、P 键最后),所以照它的数据来。 - 参考实现自带一份「错音词」表(每个词标出某个字在词里该怎么读,如「阿房宫」的「阿」读 ē、「房」读 páng),共 6308 条。对照我们的词表:3390 条读音本来就一致,2852 条词我们表里根本没有(多为姓名和少见的专名),剩下 66 条是我们读错的——岑参(应读 cén shēn,我们表里是 cēn cān)、五行八作(wǔ háng bā zuō)、说客(shuō kè)这类。这 66 条现在生成一张 64 行的修正表 `pinyin_reading_fixes.tsv`,由拼音词库一并加载,所以读错的词现在能用正确读音打出来(打 `censhen` 出「岑参」)。修正行沿用该词自己的词频权重,不会盖过同读音的常用词;只写不一致的那几十条,读音相同的 3390 条不重复,我们表里没有的 2852 条属于「收词范围」而不是「读音错误」,这次不动。参考那份表的索引(每行一个音节 id 加两个计数字段)含义在它的 native 代码里,从数据无法判定,我们只用了内容能逐字节解码的部分。 - 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 - 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 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 6850bb49..ee231713 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt @@ -39,16 +39,12 @@ internal object LetterHintPolicy { /** * What a long press on a letter key offers, in popup order: the letter in - * both cases, upper first — except on P and L, where the reference's own - * long-press list reads lower first (`p P`, `l L`, in both its Chinese and - * its English layout). No other key or layout leads with the lower case, - * so this is the reference's order on those two keys and not a rule. + * both cases, upper first on every key. The reference reads lower first on + * P and L (`p P`, `l L`, in both its Chinese and its English layout) while + * its other twenty-four keys read `X x`, and its nine-key long press leads + * lowercase throughout; that is one product contradicting itself, so this + * port keeps its single order instead of copying the two keys. */ - private val LOWER_FIRST = setOf('p', 'l') - - fun caseChoices(letter: Char): List { - val lower = letter.lowercaseChar().toString() - val upper = letter.uppercaseChar().toString() - return if (letter.lowercaseChar() in LOWER_FIRST) listOf(lower, upper) else listOf(upper, lower) - } + fun caseChoices(letter: Char): List = + listOf(letter.uppercaseChar().toString(), letter.lowercaseChar().toString()) } diff --git a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt index 3899ad58..ed23fe9a 100644 --- a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt @@ -69,21 +69,17 @@ class LetterHintPolicyTest { assertTrue(LetterHintPolicy.hint('1', false) == null) } + /** Every key leads upper, P and L included: one order, not the skin's two. */ @Test fun longPressOffersTheLetterInBothCasesUpperFirst() { assertEquals(listOf("A", "a"), LetterHintPolicy.caseChoices('a')) assertEquals(listOf("Q", "q"), LetterHintPolicy.caseChoices('q')) assertEquals(listOf("S", "s"), LetterHintPolicy.caseChoices('s')) - } - - /** The reference skin lists P and L lower first in both of its layouts. */ - @Test - fun onlyPAndLLeadWithTheLowerCase() { - assertEquals(listOf("p", "P"), LetterHintPolicy.caseChoices('p')) - assertEquals(listOf("l", "L"), LetterHintPolicy.caseChoices('l')) + assertEquals(listOf("P", "p"), LetterHintPolicy.caseChoices('p')) + assertEquals(listOf("L", "l"), LetterHintPolicy.caseChoices('l')) assertEquals( - listOf('l', 'p'), - letters.filter { LetterHintPolicy.caseChoices(it).first() == it.toString() }, + letters.size, + letters.count { LetterHintPolicy.caseChoices(it).first() == it.toString().uppercase() }, ) } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a29819bb..ced0337e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -141,7 +141,7 @@ Add new entries at the end. - **Letter hints follow the reference skin key by key.** The corner marks on the 26-key letters are the reference product's own map (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml`, `input_kbd_english26.xml`, attribute `ficon_text`): the same key carries the full-width mark in Chinese and the ASCII mark in English, and the top row carries 1–0. A long press opens only the letter in both cases (`long_press_bubble_text`); the mark belongs to the swipe-up gesture (`fusiyin_keycode`), and the comma/period key also commits the mark above it (。 / .) on an up swipe. - *Reason:* the previous marks (a=~, s=!, d=@ …) were an older scheme of our own; these values are read from the shipped skin instead of guessed, and the digit row is unchanged. The long-press list is read the same way, order and all: every key reads `X x` except P and L, which read `p P` and `l L` in **both** of the reference's layouts and in their keycode lists too (`long_press_bubble_keycode="0x0050 0x1050"`), so the port offers lower first on those two keys and upper first on the other twenty-four (`LetterHintPolicy.caseChoices`, `LetterHintPolicyTest.onlyPAndLLeadWithTheLowerCase`). The two are the last key of the two ten-key rows, and the skin sets its bubble orders per key rather than by a rule elsewhere as well — the swipe-up list leads with the mark on Q (`1 Q q`), with the upper case on W (`W 2 w`), and with the lower case on P (`p P 0`), while the nine-key layout leads lowercase on *every* key's long press (`input_kbd_pinyin9.xml`: `g h i G H I 4`) — so the order is taken as the reference's data on those keys, not generalised from it. + *Reason:* the previous marks (a=~, s=!, d=@ …) were an older scheme of our own; these values are read from the shipped skin instead of guessed, and the digit row is unchanged. The long-press list's *contents* are read the same way; its order on two keys is not. Every key reads `X x` except P and L, which read `p P` and `l L` in **both** of the reference's layouts and in their keycode lists too (`long_press_bubble_keycode="0x0050 0x1050"`), while its nine-key long press leads lowercase on *every* key (`input_kbd_pinyin9.xml`: `g h i G H I 4`) and its own swipe-up lists put the mark first on Q (`1 Q q`), the upper case first on W (`W 2 w`), the lower case first on P (`p P 0`). That is one product contradicting itself key by key, and a long press here offers the same thing on every key instead: the letter in both cases, upper first (`LetterHintPolicy.caseChoices`, `LetterHintPolicyTest.longPressOffersTheLetterInBothCasesUpperFirst`, which pins P and L with the rest). Copying the two exceptions would trade one consistent keyboard for a two-key copy of an inconsistency in their skin — the same call as the 剪贴板/剪切板 spelling the reference mixes. - **Key weights and the nine-key column follow the reference skin.** The 26-key letters A and L carry weight 15 against a letter's 10 (`input_kbd_pinyin26.xml:48,85` against `style.xml:300`), and the same ratio covers 分词 and 退格 in the last row (`cls_button_left_big` / `cls_button_right_big`, `style.xml:907-912`); openIME drew nine equal letters and 1.25 for the function keys. The nine-key and stroke side column is 删除 / 重输 / 发送 with send twice as tall (`input_kbd_pinyin9.xml:104-111`, `height_weight="2"`), and the space bar carries the corner 0 (`ficon_text="0"`, `fusiyin_keycode="0x3030"`) instead of a separate zero key: an up swipe on the space bar types it, behind the same 上滑数字 setting as the letter marks. Candidate items pad 8dp per side (`cls_option_canditem` `textpadding="0.027"` of the 308-unit strip plus `childpadding="4"`). From efa79cf99c4fb877879c724871448fc328c2542e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:10:20 +0800 Subject: [PATCH 76/94] Give the floating card a touchable area again 0.0.10-beta.1 added onComputeInsets to stop the app behind from being resized or panned while the keyboard floats. The report puts the card's own bottom edge into contentTopInsets and visibleTopInsets, but the base onComputeInsets leaves the window on TOUCHABLE_INSETS_VISIBLE, whose area is "the area inside of the visible insets" (ViewTreeObserver's InternalInsetsInfo). A visible top at the window's bottom edge insets the touchable area by the whole window height: the floating card drew as usual and answered no touch. AOSP pairs the same full-height report with TOUCHABLE_INSETS_FRAME in its own full-screen branch, so the floating report sets the frame mode too. The app behind still is not resized or panned, and the docked keyboard never enters the branch. FloatingInsetsInstrumentedTest pins the three values, keeps the base report for a card with no height yet, and states the mechanism next to the fix. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../openime/FloatingInsetsInstrumentedTest.kt | 91 +++++++++++++++++++ .../slacker/openime/LocalVoiceImeService.kt | 28 ++++-- docs/DECISIONS.md | 5 + 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcb9432..45b4c155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 - 参考实现自带一份「错音词」表(每个词标出某个字在词里该怎么读,如「阿房宫」的「阿」读 ē、「房」读 páng),共 6308 条。对照我们的词表:3390 条读音本来就一致,2852 条词我们表里根本没有(多为姓名和少见的专名),剩下 66 条是我们读错的——岑参(应读 cén shēn,我们表里是 cēn cān)、五行八作(wǔ háng bā zuō)、说客(shuō kè)这类。这 66 条现在生成一张 64 行的修正表 `pinyin_reading_fixes.tsv`,由拼音词库一并加载,所以读错的词现在能用正确读音打出来(打 `censhen` 出「岑参」)。修正行沿用该词自己的词频权重,不会盖过同读音的常用词;只写不一致的那几十条,读音相同的 3390 条不重复,我们表里没有的 2852 条属于「收词范围」而不是「读音错误」,这次不动。参考那份表的索引(每行一个音节 id 加两个计数字段)含义在它的 native 代码里,从数据无法判定,我们只用了内容能逐字节解码的部分。 - 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 - 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt new file mode 100644 index 00000000..f4ac4c55 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingInsetsInstrumentedTest.kt @@ -0,0 +1,91 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Rect +import android.inputmethodservice.InputMethodService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 浮动卡片向系统报告"身后被遮住多少":内容顶和可见顶都取卡片自己的下边缘,应用既不 resize + * 也不 pan;同一份报告还必须配 `TOUCHABLE_INSETS_FRAME`,否则整块卡片收不到触摸。 + * + * `super.onComputeInsets` 设的模式是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the + * visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`)。可见顶报成卡片 + * 整高时,那块区域就是"卡片 frame 从顶部内缩掉整个高度",一个空矩形——beta10 的浮动键盘 + * 因此整块点不动。AOSP 全屏分支做同一份报告时正是配 `TOUCHABLE_INSETS_FRAME`。 + */ +@RunWith(AndroidJUnit4::class) +class FloatingInsetsInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun withService(test: (LocalVoiceImeService) -> Unit) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val service = LocalVoiceImeService() + // The service is constructed directly instead of being started by the + // platform, so it has no base context yet. + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + test(service) + } + } + + @Test + fun aFloatingCardReportsItsOwnBottomEdgeAndStaysTouchableEverywhere() = withService { service -> + val outInsets = InputMethodService.Insets() + service.applyFloatingInsets(outInsets, CARD_HEIGHT_PX) + + assertEquals(CARD_HEIGHT_PX, outInsets.contentTopInsets) + assertEquals(CARD_HEIGHT_PX, outInsets.visibleTopInsets) + assertEquals( + "The whole card must keep answering touches", + InputMethodService.Insets.TOUCHABLE_INSETS_FRAME, + outInsets.touchableInsets, + ) + } + + @Test + fun aCardThatHasNoHeightYetLeavesTheBaseReportAlone() = withService { service -> + val outInsets = InputMethodService.Insets() + outInsets.contentTopInsets = BASE_CONTENT_TOP_INSETS + outInsets.visibleTopInsets = BASE_VISIBLE_TOP_INSETS + outInsets.touchableInsets = InputMethodService.Insets.TOUCHABLE_INSETS_VISIBLE + + service.applyFloatingInsets(outInsets, 0) + + assertEquals(BASE_CONTENT_TOP_INSETS, outInsets.contentTopInsets) + assertEquals(BASE_VISIBLE_TOP_INSETS, outInsets.visibleTopInsets) + assertEquals( + InputMethodService.Insets.TOUCHABLE_INSETS_VISIBLE, + outInsets.touchableInsets, + ) + } + + /** + * 报告为什么必须配 FRAME:默认模式下可触摸区就是"可见内缩之内的区域"( + * `ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_VISIBLE`),可见顶等于卡片 + * 高度时它被剪成空集,键盘一点反应都没有。这条把机制摆在修复旁边,别人就不能把 + * FRAME 那一行当冗余删掉。 + */ + @Test + fun theDefaultModeWouldLeaveTheCardWithNoTouchableArea() { + val touchable = Rect(0, 0, CARD_WIDTH_PX, CARD_HEIGHT_PX) + touchable.inset(0, CARD_HEIGHT_PX, 0, 0) + + assertTrue("A full-height visible top leaves nothing touchable", touchable.isEmpty) + } + + private companion object { + const val CARD_WIDTH_PX = 1080 + const val CARD_HEIGHT_PX = 720 + const val BASE_CONTENT_TOP_INSETS = 111 + const val BASE_VISIBLE_TOP_INSETS = 222 + } +} diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index c33ab223..f9d60db1 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -360,19 +360,35 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can /** * 停靠时键盘占据屏幕底部;浮动时卡片只是浮层。系统按窗口 frame 计算 IME 遮挡区域, * 会让应用把整块屏幕当作"被输入法盖住"而把输入框顶上去(resize/pan)。 - * 浮动时这里报告"没有遮挡内容",输入框原地不动。 + * 浮动时这里报告"没有遮挡内容",输入框原地不动;这份报告还要带上整帧可触摸, + * 见 [applyFloatingInsets]。 */ override fun onComputeInsets(outInsets: InputMethodService.Insets) { super.onComputeInsets(outInsets) if (floatingWindow.enabled) { - val fullHeight = window?.window?.decorView?.height ?: 0 - if (fullHeight > 0) { - outInsets.contentTopInsets = fullHeight - outInsets.visibleTopInsets = fullHeight - } + applyFloatingInsets(outInsets, window?.window?.decorView?.height ?: 0) } } + /** + * 浮动卡片对"身后被遮住多少"的报告:内容顶和可见顶都取卡片自己的下边缘,即窗口之内 + * 没有任何区域盖住后面的应用,应用既不 resize 也不 pan。 + * + * 这份报告同时决定卡片本身哪一块能接触摸:基类 `onComputeInsets` 设的模式是 + * `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched", + * `ViewTreeObserver.InternalInsetsInfo`),可见顶报成窗口底就等于把可触摸区剪成空集, + * 整个浮动键盘收不到任何触摸(beta10 的卡死)。AOSP 全屏分支做同一份报告时正是配 + * `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`: + * `info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); + * info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),浮动卡片照配。 + */ + internal fun applyFloatingInsets(outInsets: InputMethodService.Insets, cardHeightPx: Int) { + if (cardHeightPx <= 0) return + outInsets.contentTopInsets = cardHeightPx + outInsets.visibleTopInsets = cardHeightPx + outInsets.touchableInsets = InputMethodService.Insets.TOUCHABLE_INSETS_FRAME + } + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) // Orientation changes affect keyboard geometry, not the user's chosen diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ced0337e..d5e9127d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -351,3 +351,8 @@ Add new entries at the end. - **The preference pages carry two deliberate differences; the rest of the reference's list is matched.** The reference's whole preference set is one XML file — 123 rows, 78 of them booleans, `res/cU.xml` = `R.xml.settings_preferences`, read by `SettingsConfigNext.` — and walking it against this port's settings leaves two differences. Haptics: the reference's `enable_key_vibrate` defaults **false** and `key_vibrate_intensity` is `"NONE"` (sound off, `enable_key_sound=false`, matches this port). Association granularity: the reference splits the row into four switches — `typing_associate_enabled`, `emoji_recommend_enabled`, `image_recommend_enabled` and `kaomoji_recommend_enabled`, all `true` — where this port ships one switch, on, and no image recommendations. *Reason:* the vibration default is this port's own setting with its own slider, tuned and shipped deliberately (a crisp `KEYBOARD_TAP` per key, `CHANGELOG` 0.0.3), so copying the reference's off would remove a feature to match a toggle, not a behaviour; the association row offers 接续词, 表情 and 颜文字 from the reference's own table, and a switch per kind would add three settings for one row. Every other default in the list is matched where the feature exists here: 四码上屏/模糊音六组/双拼名称/回车发送/首字母大写/浮动气泡/剪贴板与短信提示条/联想总开关/语音标点与去语气词, and the rows whose features this port does not have (云端、账号同步、单手模式、AI 助手、图片推荐、自定义工具栏、候选字号、高分屏开关) are untouched by design. + +- **A floating card reports that it covers nothing, and that report must carry the whole frame as its touchable region.** + The floating card puts its own bottom edge into both `contentTopInsets` and `visibleTopInsets` (`LocalVoiceImeService.applyFloatingInsets`, added in 0.0.10-beta.1), so the app behind is neither resized nor panned. `InputMethodService.onComputeInsets` — the base this port overrides — leaves the window on `touchableInsets = TOUCHABLE_INSETS_VISIBLE` with an empty `touchableRegion`, and `ViewTreeObserver.InternalInsetsInfo` defines that mode as "the area inside of the visible insets can be touched", where `visibleInsets` are "offsets from the frame of the window at which windows behind it are visible". A visible top at the window's own bottom edge therefore insets the touchable area by the whole window height: the floating keyboard drew normally and answered no touch at all. AOSP's own full-screen branch makes the same "we cover nothing behind" report and pairs it with the frame mode — `info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.touchableRegion.setEmpty(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);` — so the floating report now sets `TOUCHABLE_INSETS_FRAME` too. + *Reason:* one of the two values alone is a trap the framework documents only across two files: the insets decide both what the app behind is told and which part of the card answers touches, and only the frame mode keeps a window that declares itself invisible-to-the-app touchable across its whole area. The docked keyboard never enters the branch, so nothing else moves. `FloatingInsetsInstrumentedTest` pins the three values, keeps the base report for a card that has no height yet, and states the mechanism itself (the visible-bounded region of a full-height report is empty) next to the fix, so the frame mode cannot be dropped as redundant. + From 143b2b284d16e93865d062d3b8c45de46f07a853 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:13:48 +0800 Subject: [PATCH 77/94] Stop re-measuring the keyboard on every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateResponsiveGeometry runs from onMeasure and wrote mainDock's and the second key row's layout parameters back unconditionally. Assigning layoutParams always calls requestLayout(), and a request made after performTraversals has cleared mLayoutRequested schedules another full layout pass ("Clear this now, so that if anything requests a layout in the rest of this function we will catch it and re-run a full layout pass", ViewRootImpl.performTraversals) — while nothing in the chain was conditional. The keyboard therefore re-measured, re-laid out and redrew every frame for as long as it was shown, docked or floating. The three writes now happen only when the value they carry differs. Co-Authored-By: Claude Code --- .../openime/keyboard/ImeKeyboardView.kt | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) 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 451cc7a7..8e43c829 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -1294,9 +1294,13 @@ open class ImeKeyboardView( } (mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params -> - params.width = FrameLayout.LayoutParams.MATCH_PARENT - params.gravity = Gravity.TOP - mainDock.layoutParams = params + if (params.width != FrameLayout.LayoutParams.MATCH_PARENT || + params.gravity != Gravity.TOP + ) { + params.width = FrameLayout.LayoutParams.MATCH_PARENT + params.gravity = Gravity.TOP + mainDock.layoutParams = params + } } if (floatingKeyboardController.enabled) { @@ -1312,8 +1316,10 @@ open class ImeKeyboardView( // display for optical centering. A floating window can be // narrower than the display, so that cached width would clip // the first and last keys. Let the row fill its local window. - (row.layoutParams as? LinearLayout.LayoutParams)?.let { params -> - params.width = (measuredWidthPx * 0.9f).toInt() + val rowWidth = (measuredWidthPx * 0.9f).toInt() + val params = row.layoutParams as? LinearLayout.LayoutParams + if (params != null && (params.width != rowWidth || params.gravity != Gravity.CENTER_HORIZONTAL)) { + params.width = rowWidth params.gravity = Gravity.CENTER_HORIZONTAL row.layoutParams = params } @@ -1367,9 +1373,11 @@ open class ImeKeyboardView( keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> val rowWidth = ((measuredWidthPx - bodyLeft - bodyRight) * 0.9f).toInt() val params = row.layoutParams as? LinearLayout.LayoutParams - if (params != null) { + // 只有真的变了才写回:赋 layoutParams 一定会 requestLayout(),而这里是从 + // onMeasure 调进来的,每次测量都排下一次布局就等于每帧重排一遍。 + if (params != null && (params.width != rowWidth || params.gravity != Gravity.CENTER_HORIZONTAL)) { + params.width = rowWidth params.gravity = Gravity.CENTER_HORIZONTAL - if (params.width != rowWidth) params.width = rowWidth row.layoutParams = params } } From ca2cf02a6ced4d9f34669060eca94fa3d79357a2 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:35:58 +0800 Subject: [PATCH 78/94] Let the top zone claim the gestures that land on bare space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating keyboard's long-press drag only started when the press landed on a toolbar icon. With no child under the finger, the framework dispatches the rest of the gesture straight to the zone's own onTouchEvent and never asks the interceptor again ("intercepted = true" in the else branch, ViewGroup.dispatchTouchEvent, actionMasked != ACTION_DOWN with no touch target), and the zone's onTouchEvent returned super's false — so the long press armed and nothing ever came of it. The zone now claims the gesture itself once it was going to intercept the press, swallows the DOWN, and starts the drag on the first move after the long press. The docked height drag never armed at all. It gates on the press being inside the toolbar row (ImeTopZone.isInsideToolbar) and compared the row taken with getLocationInWindow — window-local — against MotionEvent.rawY, which is "the original location of the event on the screen, before it had been adjusted for the containing window and views". This keyboard's window is not the screen (dumpsys window: frame=[0,1731][1080,2400], gravity BOTTOM), so its toolbar row sits at window y 0 while a finger on the row reports a raw y of about 1731: the check could not be true on a device. It now measures the row on screen, the same way the candidate choice strip tests raw touches (KeyPopupController.trackChoiceTouch). Co-Authored-By: Claude Code --- .../FloatingGameKeyboardInstrumentedTest.kt | 43 ++++++++++ .../slacker/openime/keyboard/ImeTopZone.kt | 81 ++++++++++++++----- docs/DECISIONS.md | 4 + 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt index 45b435c0..a2f17f0d 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -215,6 +215,49 @@ class FloatingGameKeyboardInstrumentedTest { } } + @Test + fun longPressOnTheBlankStripOfTheTopZoneDragsToo() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { keyboard.setFloatingWindowMode(true); true } + settleTip(harness, keyboard) + val topZone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + // The 48dp toolbar row sits centered in the 60dp zone, so a strip at the + // bottom of the zone belongs to no child. Nothing consumes the DOWN there, + // and the framework never asks the interceptor again once a gesture has no + // touch target: the zone itself has to claim it (ImeTopZone.onTouchEvent). + val (x, y) = harness.awaitMain { + val row = topZone.findViewWithTag("toolbar-row") + if (row?.height == 0 || topZone.height == 0) return@awaitMain null + val zoneLocation = IntArray(2).also { topZone.getLocationInWindow(it) } + val rowLocation = IntArray(2).also { row.getLocationInWindow(it) } + val rowBottom = rowLocation[1] - zoneLocation[1] + row.height + val strip = topZone.height - rowBottom + if (strip < 4) return@awaitMain null + (topZone.width / 2f) to (rowBottom + strip / 2f) + } + val downTime = SystemClock.uptimeMillis() + 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 from blank top-zone space", recorder.drags.isNotEmpty()) + assertEquals( + "Drag deltas follow the finger after the grab point", + 60.0, + recorder.drags.sumOf { it.first.toDouble() }, + 1.0, + ) + assertEquals(40.0, recorder.drags.sumOf { it.second.toDouble() }, 1.0) + assertTrue("Releasing a drag must not dock the keyboard", recorder.floatingChanges.isEmpty()) + assertEquals("A drag on blank space must not open a panel", Panel.NONE, keyboard.currentPanel()) + true + } + } + @Test fun shortTapOnTopZoneStillClicksAndDoesNotDrag() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setFloatingWindowMode(true); true } 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 82557658..c9d2b806 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -874,6 +874,8 @@ internal class ImeTopZone( private var longPressToolbarOnly = false private var longPressArmed = false private var longPressDragging = false + /** 这一次手势被拦下来了(顶栏内的按下):[onTouchEvent] 据此认领空白处的手势。 */ + private var longPressEngaged = false private var pressRawX = 0f private var pressRawY = 0f private val longPressRunnable = Runnable { @@ -895,40 +897,59 @@ internal class ImeTopZone( removeCallbacks(longPressRunnable) longPressArmed = false longPressDragging = false + longPressEngaged = false } - /** 手指是否落在顶栏(工具条)范围内;用窗口坐标比较,兼容嵌套层级。 */ + /** + * 手指是否落在顶栏(工具条)范围内:`raw` 是屏幕坐标("the original location of + * the event on the screen, before it had been adjusted for the containing window", + * `MotionEvent.getRawX`),所以这里也用屏幕坐标取行框——和候选项滑选 + * ([KeyPopupController.trackChoiceTouch])一个取法。输入法窗口不在屏幕原点 + * (实测 `frame=[0,1731][1080,2400]`),拿 `getLocationInWindow` 的行框 + * (窗口内 y=0..)去比屏幕上的 `rawY`(≈1731+)永远不成立,停靠键盘的高度调节 + * 因此根本起不了手。 + */ private fun isInsideToolbar(rawX: Float, rawY: Float): Boolean { if (toolbarRow.width == 0 || toolbarRow.height == 0) return false val location = IntArray(2) - toolbarRow.getLocationInWindow(location) + toolbarRow.getLocationOnScreen(location) return rawX >= location[0] && rawX <= location[0] + toolbarRow.width && rawY >= location[1] && rawY <= location[1] + toolbarRow.height } + /** 长按还没成立就移动超过阈值:取消这次长按,滑动不该被当成拖动。 */ + private fun cancelLongPressIfMoved(event: MotionEvent) { + val slop = ViewConfiguration.get(context).scaledTouchSlop + if (kotlin.math.abs(event.rawX - pressRawX) > slop || + kotlin.math.abs(event.rawY - pressRawY) > slop + ) { + removeCallbacks(longPressRunnable) + } + } + + /** 长按已成立:起手拖动。两条派发路径(拦截器与 [onTouchEvent])共用。 */ + private fun beginDragIfArmed(drag: LongPressDragTarget, event: MotionEvent): Boolean { + if (!longPressArmed) return false + drag.begin(event) + longPressDragging = true + return true + } + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { val drag = longPressDrag ?: return false when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { resetLongPress() if (longPressToolbarOnly && !isInsideToolbar(ev.rawX, ev.rawY)) return false + longPressEngaged = true pressRawX = ev.rawX pressRawY = ev.rawY postDelayed(longPressRunnable, ViewConfiguration.getLongPressTimeout().toLong()) } MotionEvent.ACTION_MOVE -> { - if (longPressArmed) { - drag.begin(ev) - longPressDragging = true - return true - } - val slop = ViewConfiguration.get(context).scaledTouchSlop - if (kotlin.math.abs(ev.rawX - pressRawX) > slop || - kotlin.math.abs(ev.rawY - pressRawY) > slop - ) { - removeCallbacks(longPressRunnable) - } + if (beginDragIfArmed(drag, ev)) return true + cancelLongPressIfMoved(ev) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { @@ -941,19 +962,38 @@ internal class ImeTopZone( return false } + /** + * 顶栏里的空白处(图标之间、空闲时那一行输入区)没有任何子视图消费这次按下, + * 框架此后就不再回头问 [onInterceptTouchEvent],长按成立也起不了手——按下落在 + * 工具栏图标上时能拖正是因为那个图标消费了它。所以被拦下的这次手势由这里认领: + * 吞掉 DOWN 保住整条流,长按成立后在 MOVE 上起手。 + */ override fun onTouchEvent(event: MotionEvent): Boolean { val drag = longPressDrag - if (drag == null || !longPressDragging) return super.onTouchEvent(event) - drag.onTouch(event) - if (event.actionMasked == MotionEvent.ACTION_UP || - event.actionMasked == MotionEvent.ACTION_CANCEL - ) { - resetLongPress() + if (drag == null || (!longPressEngaged && !longPressDragging)) return super.onTouchEvent(event) + if (longPressDragging) { + drag.onTouch(event) + if (event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + resetLongPress() + } + return true + } + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + if (beginDragIfArmed(drag, event)) { + drag.onTouch(event) + } else { + cancelLongPressIfMoved(event) + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> resetLongPress() } return true } - /** 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. @@ -1008,6 +1048,7 @@ internal class ImeTopZone( refreshToolbar() } + /** Floating (game) mode: a slimmer toolbar without text editing and undo. */ fun setCompactToolbar(value: Boolean) { if (compact == value) return compact = value diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d5e9127d..35a45287 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -356,3 +356,7 @@ Add new entries at the end. The floating card puts its own bottom edge into both `contentTopInsets` and `visibleTopInsets` (`LocalVoiceImeService.applyFloatingInsets`, added in 0.0.10-beta.1), so the app behind is neither resized nor panned. `InputMethodService.onComputeInsets` — the base this port overrides — leaves the window on `touchableInsets = TOUCHABLE_INSETS_VISIBLE` with an empty `touchableRegion`, and `ViewTreeObserver.InternalInsetsInfo` defines that mode as "the area inside of the visible insets can be touched", where `visibleInsets` are "offsets from the frame of the window at which windows behind it are visible". A visible top at the window's own bottom edge therefore insets the touchable area by the whole window height: the floating keyboard drew normally and answered no touch at all. AOSP's own full-screen branch makes the same "we cover nothing behind" report and pairs it with the frame mode — `info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.touchableRegion.setEmpty(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);` — so the floating report now sets `TOUCHABLE_INSETS_FRAME` too. *Reason:* one of the two values alone is a trap the framework documents only across two files: the insets decide both what the app behind is told and which part of the card answers touches, and only the frame mode keeps a window that declares itself invisible-to-the-app touchable across its whole area. The docked keyboard never enters the branch, so nothing else moves. `FloatingInsetsInstrumentedTest` pins the three values, keeps the base report for a card that has no height yet, and states the mechanism itself (the visible-bounded region of a full-height report is empty) next to the fix, so the frame mode cannot be dropped as redundant. + +- **The toolbar's long-press area is measured in screen coordinates, like the touch it tests.** + 「键盘调节」 (`ImeTopZone.setLongPressDrag(toolbarOnly = true)`) arms only when the press lands inside the toolbar row, and the press arrives as `MotionEvent.rawX/rawY` — "the original location of the event on the screen, before it had been adjusted for the containing window and views" (`MotionEvent.getRawX`). The row was measured with `getLocationInWindow`, which is window-local: the IME window does not sit at the screen origin (`dumpsys window`: `mAttrs={… gr=BOTTOM CENTER_VERTICAL …}`, `Frames: parent=[0,128][1080,2400] frame=[0,1731][1080,2400]`), so the row's window y is 0 while a finger on it reports a raw y of about 1731 — the check could not be true on a device and the docked drag never armed. It now uses `getLocationOnScreen`, the same measure the candidate choice strip already tests raw touches with (`KeyPopupController.trackChoiceTouch`). + *Reason:* raw coordinates are screen coordinates; comparing them with window-local ones only works while a window happens to cover the whole screen, which the docked IME window never does. The floating card is unaffected: it arms anywhere on the zone (`toolbarOnly = false`), and its drag works off raw deltas, which do not depend on the origin. From 21771a7618cc61984b2a47f8106649afe4cd16ad Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:36:07 +0800 Subject: [PATCH 79/94] Write the floating window only when the card really moves Dragging the card wrote the window's attributes on every touch sample, including the samples whose rounded delta is zero: at 60Hz that is a relayout of the IME window per frame while a finger jitters on the toolbar, and the drag path also re-read the opacity preference each time. applyLayout now returns early when position, size and alpha all match what the window already carries, and the opacity comes from the value scheduleLayout last applied. A drag that does move the card still writes, so the bounds still clamp and the position is still remembered. Co-Authored-By: Claude Code --- .../openime/FloatingWindowInstrumentedTest.kt | 84 +++++++++++++++++++ .../floating/FloatingWindowController.kt | 34 +++++++- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt new file mode 100644 index 00000000..72320e39 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingWindowInstrumentedTest.kt @@ -0,0 +1,84 @@ +package llc.slacker.openime + +import android.os.Handler +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.floating.FloatingWindowController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The floating card's window attributes: a drag only writes them when the card + * really moves, because every write is a relayout of the window. + */ +@RunWith(AndroidJUnit4::class) +class FloatingWindowInstrumentedTest { + @Test + fun aDragOnlyWritesTheWindowWhenTheCardMoves() { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val logs = mutableListOf() + var opacityReads = 0 + val controller = harness.awaitMain { activity -> + FloatingWindowController( + resources = activity.resources, + mainHandler = Handler(Looper.getMainLooper()), + windowProvider = { activity.window }, + keyboardHeightPx = { 600 }, + floatingWidthPercent = { 88 }, + floatingOpacityPercent = { opacityReads++; 100 }, + debugLog = { logs += it }, + ).also { it.enable() } + } + try { + // enable() posts the first layout; wait for it to reach the window. + harness.awaitMain { activity -> + if (activity.window.attributes.width > 0) true else null + } + logs.clear() + val readsAfterEnable = opacityReads + val startX = harness.awaitMain { harnessActivity -> harnessActivity.window.attributes.x } + harness.awaitMain { + // Finger jitter: the delta truncates to 0, so the card does not + // move and the window must not be rewritten. + repeat(20) { controller.drag(0.4f, 0.4f) } + assertTrue( + "Sub-pixel drag samples must not write the window", + logs.none { it.startsWith(DRAG_LOG) }, + ) + assertEquals( + "A drag must not re-read the opacity from the settings", + readsAfterEnable, + opacityReads, + ) + // A real move writes once, and the next sub-pixel sample again + // writes nothing. + controller.drag(50f, 30f) + assertEquals( + "A real move must write the window exactly once", + 1, + logs.count { it.startsWith(DRAG_LOG) }, + ) + controller.drag(0.4f, 0f) + assertEquals(1, logs.count { it.startsWith(DRAG_LOG) }) + true + } + harness.awaitMain { activity -> + assertTrue( + "The written position must be the dragged one ($startX -> ${activity.window.attributes.x})", + activity.window.attributes.x > startX, + ) + true + } + } finally { + controller.restore() + } + } + } + + private companion object { + const val DRAG_LOG = "floating-window-drag" + } +} diff --git a/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt index 67295ce6..77403075 100644 --- a/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingWindowController.kt @@ -34,6 +34,7 @@ internal class FloatingWindowController( private var baseAlpha: Float? = null private val positionMemory = FloatingPositionMemory() private var lastOrientation: Int? = null + private var appliedOpacityPercent: Int? = null fun enable(resetPosition: Boolean = x == 0 && y == 0) { enabled = true @@ -116,7 +117,13 @@ internal class FloatingWindowController( attrs.width = desiredWidth attrs.height = currentHeight attrs.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - attrs.alpha = floatingOpacityPercent().coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT) / 100f + val opacityPercent = floatingOpacityPercent() + .coerceIn( + ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, + ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT, + ) + appliedOpacityPercent = opacityPercent + attrs.alpha = opacityPercent / 100f attrs.x = x.coerceIn(bounds[0], bounds[1]) attrs.y = y.coerceIn(bounds[2], bounds[3]) x = attrs.x @@ -145,11 +152,19 @@ internal class FloatingWindowController( val bounds = bounds(screenWidth, screenHeight, width, height) x = x.coerceIn(bounds[0], bounds[1]) y = y.coerceIn(bounds[2], bounds[3]) + val alpha = opacityAlpha() + // 每个触摸样本都写一次窗口属性就是一次 relayout:手指移动不足一像素的样本 + // (取整后位置没变)和其余值都没变时,直接不写。 + if (x == attrs.x && y == attrs.y && + width == attrs.width && height == attrs.height && alpha == attrs.alpha + ) { + return + } attrs.gravity = Gravity.TOP or Gravity.START attrs.width = width attrs.height = height attrs.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - attrs.alpha = floatingOpacityPercent().coerceIn(ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT) / 100f + attrs.alpha = alpha attrs.x = x attrs.y = y imeWindow.attributes = attrs @@ -157,6 +172,21 @@ internal class FloatingWindowController( debugLog("floating-window-drag x=$x y=$y w=$width h=$height") } + /** + * 最近一次写进窗口的不透明度。拖动每个样本都要用它,属性写回读设置没有必要; + * [scheduleLayout] 在启用、改样式、转屏时都会重读。 + */ + private fun opacityAlpha(): Float { + val percent = appliedOpacityPercent + ?: floatingOpacityPercent() + .coerceIn( + ImeGeometryTokens.FLOATING_OPACITY_MIN_PERCENT, + ImeGeometryTokens.FLOATING_OPACITY_MAX_PERCENT, + ) + .also { appliedOpacityPercent = it } + return percent / 100f + } + private fun rememberPosition() { val orientation = resources.configuration.orientation lastOrientation = orientation From 8e859fbd28af3b8b3886208576ed99de8b8005a2 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:37:26 +0800 Subject: [PATCH 80/94] Stop a press with no finger from blocking every later rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasPressedKey() answers "a key still owns this gesture" by walking the rows for isPressed, and renderModeBody defers to a 40ms poll while it is true. isPressed is not cleared when a view is taken out of the hierarchy, so a gesture whose key was rebuilt away under it — or one a rebuild swallowed — left a key that looked pressed forever: every later rebuild (a keyboard switch, a rotation, closing a panel) silently did nothing and the poll re-posted itself for as long as the keyboard was attached. A touch stream is only live between a DOWN and its UP, and this view already sees every event that reaches the keyboard, so dispatchTouchEvent now tracks whether one is open and hasPressedKey() answers false when none is. The deferral still protects a real gesture, and the rebuild it deferred lands as soon as the finger lifts. Co-Authored-By: Claude Code --- .../AuditInteractionInstrumentedTest.kt | 60 +++++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 15 +++++ 2 files changed, 75 insertions(+) diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index 7bd3542b..574df276 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -32,6 +32,7 @@ import llc.slacker.openime.voice.VoiceRecognitionEvents import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -1224,6 +1225,65 @@ class AuditInteractionInstrumentedTest { assertEquals("A rebuild during the press must not swallow the space", 1, recorder.spaces) } + @Test + fun aPressLeftWithoutAFingerOnlyDefersWhileTheFingerIsStillDown() = withKeyboard { harness, _, keyboard -> + // Half by half: the deferral (see rebuildWhileSpaceIsHeldDoesNotSwallowTheSpace) + // must protect a real gesture and must not outlive it. A press left behind by + // an earlier gesture — the rebuild that swallowed it, or a view swapped out + // mid-gesture — used to defer every later rebuild and poll for it forever. + val releaseDelayMs = minOf( + 100L, + (ViewConfiguration.getLongPressTimeout().toLong() / 2L).coerceAtLeast(1L), + ) + lateinit var staleSpace: View + harness.awaitMain { + staleSpace = requireNotNull(keyboard.findViewWithTag("key-space")) + staleSpace.isPressed = true + true + } + harness.awaitMain { + keyboard.setMode(KeyboardMode.ENGLISH_26, notifyListener = false) + val rebuilt = keyboard.findViewWithTag("key-space") + assertNotNull("The mode switch must rebuild the rows of the new layout", rebuilt) + assertNotSame( + "A press with no finger on the screen must not defer this rebuild", + staleSpace, + rebuilt, + ) + assertFalse("The deferred rebuild must let go of the stale key", staleSpace.isAttachedToWindow) + true + } + + // A finger really on the screen: the deferral still holds, and the rebuild + // lands as soon as that finger lifts. + var released = false + var downTime = 0L + lateinit var heldSpace: View + harness.awaitMain { + val point = keyPoint(keyboard, "key-space") + heldSpace = requireNotNull(keyboard.findViewWithTag("key-space")) + downTime = SystemClock.uptimeMillis() + pointers(keyboard, downTime, MotionEvent.ACTION_DOWN, listOf(point)) + assertTrue("Space must own the active press", heldSpace.isPressed) + keyboard.setMode(KeyboardMode.PINYIN_26, notifyListener = false) + assertSame( + "A live finger must still defer the rebuild", + heldSpace, + keyboard.findViewWithTag("key-space"), + ) + keyboard.postDelayed({ + pointers(keyboard, downTime, MotionEvent.ACTION_UP, listOf(point)) + released = true + }, releaseDelayMs) + true + } + harness.awaitMain(timeoutMs = 3_000L) { if (released) true else null } + // The rebuild poll runs on a 40ms cadence; give it a few turns. + harness.awaitMain(timeoutMs = 3_000L) { + keyboard.findViewWithTag("key-space")?.takeIf { it !== heldSpace } + } + } + @Test fun backspaceTapDeletesExactlyOnceAndCancelDeletesNothing() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { 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 8e43c829..28d910ee 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -319,6 +319,8 @@ open class ImeKeyboardView( // a letter, the backspace repeat). Remember the request and replay it once // the finger lifts instead of tearing the key out from under the gesture. private var pendingRowRebuild = false + /** 有没有手指按在这块键盘上:分辨真手势与上一次手势留下的 isPressed 残留。 */ + private var touchStreamActive = false private val pendingRowRebuildPoll = object : Runnable { override fun run() { if (!pendingRowRebuild) return @@ -1158,6 +1160,10 @@ open class ImeKeyboardView( } return false } + // 屏上已经没有手指时,任何 isPressed 都只是上一次手势的残留(重建把手势吞掉、 + // 视图在手势中途被换下)。它不该再把之后的每一次重建都挡回去:那样切换键盘、 + // 转屏、关面板都会静默失效,还留一个 40ms 的轮询跑个不停。 + if (!touchStreamActive) return false // Space owns a physical touch stream through its touch listener. Some // Android versions clear View.isPressed while that stream is still // active, so a pending row rebuild must also respect the gesture owner. @@ -2182,6 +2188,7 @@ open class ImeKeyboardView( spaceVoiceGestureController.shutdown() floatingKeyboardController.reset() pendingRowRebuild = false + touchStreamActive = false hidePopup() } @@ -2494,6 +2501,14 @@ open class ImeKeyboardView( * exactly once without falling back to a stream of synthetic taps. */ override fun dispatchTouchEvent(event: MotionEvent): Boolean { + // 有没有手指在屏上:见 hasPressedKey。POINTER_UP 时 pointerCount 仍算上正在 + // 抬起的那根,所以 > 1 才代表手势还在。 + touchStreamActive = when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> true + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> false + MotionEvent.ACTION_POINTER_UP -> event.pointerCount > 1 + else -> touchStreamActive + } if (event.actionMasked == MotionEvent.ACTION_DOWN) { keyPopupController.hideIfOutside(event.x, event.y) silencePlatformFeedback(this) From 903400577ed8da565b95a72552c7160d4422b098 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:37:55 +0800 Subject: [PATCH 81/94] Preview the keyboard height while dragging and save it once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docked height drag went through setKeyboardHeightPercent on every touch sample: each one wrote the preference and rebuilt the whole key surface, so a single drag persisted the height a hundred times and rebuilt the keyboard a hundred times. The drag now previews — the row heights and the wanted window height follow the finger, and the rebuild is posted to the next frame so a frame with several samples rebuilds once — while the commit happens on release, from either the UP or a CANCEL that takes the gesture away (KeyboardHeightDragController.onDragEnd). The keyboard reports and persists the height exactly once, when the user lets go. Co-Authored-By: Claude Code --- .../KeyboardHeightDragInstrumentedTest.kt | 158 ++++++++++++++++++ .../openime/keyboard/ImeKeyboardView.kt | 56 ++++++- .../keyboard/KeyboardHeightDragController.kt | 11 +- 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt new file mode 100644 index 00000000..5534691b --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardHeightDragInstrumentedTest.kt @@ -0,0 +1,158 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.voice.VoiceModelLifecycleState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * Docked 「键盘调节」: long-press the toolbar row, then drag up or down to set the + * keyboard height. The drag previews on every sample but writes the height once. + */ +@RunWith(AndroidJUnit4::class) +class KeyboardHeightDragInstrumentedTest { + private class Recorder { + lateinit var keyboard: ImeKeyboardView + /** Heights the keyboard reported as committed, in order. */ + val heights = mutableListOf() + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, args -> + when (method.name) { + "voiceModelState" -> VoiceModelLifecycleState.COLD + "onKeyboardHeightChanged" -> { heights += args!![0] as Int; null } + "onFloatingKeyboardChanged" -> { + keyboard.setFloatingWindowMode(args!![0] as Boolean) + null + } + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null + } + } as ImeKeyboardView.Listener + } + + private fun withKeyboard( + test: (DirectActivityHarness, 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(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 touch(view: View, action: Int, downTime: Long, x: Float, y: Float) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + try { + view.dispatchTouchEvent(event) + } finally { + event.recycle() + } + } + + @Test + fun theHeightDragPreviewsEverySampleAndCommitsOnce() = withKeyboard { harness, recorder, keyboard -> + val context = InstrumentationRegistry.getInstrumentation().targetContext + val start = harness.awaitMain { + ImeSettingsRepository.loadKeyboardHeightPercent(context, landscape = false) + } + // Drag away from the clamp ceiling, so every sample counts and the + // direction of the expected change is known. + val step = if (start > 130) 20f else -20f + val samples = 12 + val downTime = SystemClock.uptimeMillis() + val zone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } + // Raw touch coordinates are screen coordinates ("the original location of + // the event on the screen, before it had been adjusted for the containing + // window", MotionEvent.getRawX), while the IME window does not sit at the + // screen origin: the press belongs at the row's own screen position, which + // is what ImeTopZone.isInsideToolbar gates the docked drag on. Synthetic + // events carry raw == x/y, so this test dispatches to the zone itself and + // presses where the row is on screen. + val (pressX, pressY) = harness.awaitMain { + val row = requireNotNull(keyboard.findViewWithTag("toolbar-row")) + check(row.height > 0) { "The toolbar row has not been laid out" } + val location = IntArray(2).also { row.getLocationOnScreen(it) } + (location[0] + row.width / 2f) to (location[1] + row.height / 2f) + } + val startHeight = harness.awaitMain { keyboard.desiredWindowHeightPx() } + val firstKey = harness.awaitMain { keyboard.findViewWithTag("key:a") } + harness.awaitMain { touch(zone, MotionEvent.ACTION_DOWN, downTime, pressX, pressY); true } + SystemClock.sleep(ViewConfiguration.getLongPressTimeout() + 250L) + val previewHeight = harness.awaitMain { + var y = pressY + repeat(samples) { + y += step + touch(zone, MotionEvent.ACTION_MOVE, downTime, pressX, y) + } + assertTrue( + "Dragging the toolbar must resize the keyboard while the finger moves", + keyboard.desiredWindowHeightPx() != startHeight, + ) + // One main-thread turn, [samples] samples: the preview coalesces the + // rebuild onto the next frame instead of rebuilding per sample. + assertSame( + "A drag must not rebuild the key rows sample by sample", + firstKey, + keyboard.findViewWithTag("key:a"), + ) + assertTrue("A drag must not report a height before it ends", recorder.heights.isEmpty()) + assertEquals( + "A drag must not persist a height before it ends", + start, + ImeSettingsRepository.loadKeyboardHeightPercent(context, landscape = false), + ) + checkNotNull(keyboard.desiredWindowHeightPx()) + } + harness.awaitMain { + touch(zone, MotionEvent.ACTION_UP, downTime, pressX, pressY + step * samples) + true + } + harness.awaitMain { + assertEquals("Releasing the drag reports the height exactly once", 1, recorder.heights.size) + val committed = recorder.heights.single() + assertEquals( + "Releasing the drag persists the height it reports", + committed, + ImeSettingsRepository.loadKeyboardHeightPercent(context, landscape = false), + ) + assertTrue( + "The drag must move the height the way the finger went ($start -> $committed)", + if (step < 0) committed > start else committed < start, + ) + assertEquals( + "The height that is persisted must be the one on screen", + previewHeight, + keyboard.desiredWindowHeightPx(), + ) + 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 28d910ee..e9e76aa0 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -321,6 +321,11 @@ open class ImeKeyboardView( private var pendingRowRebuild = false /** 有没有手指按在这块键盘上:分辨真手势与上一次手势留下的 isPressed 残留。 */ private var touchStreamActive = false + private var modeBodyRebuildPosted = false + private val modeBodyRebuild = Runnable { + modeBodyRebuildPosted = false + renderModeBody() + } private val pendingRowRebuildPoll = object : Runnable { override fun run() { if (!pendingRowRebuild) return @@ -551,7 +556,10 @@ open class ImeKeyboardView( private var oneHandMode = ImeSettingsRepository.loadOneHandMode(context) /** 停靠键盘的“键盘调节”拖拽(顶栏长按后上下拖)。 */ - private val heightDragController = KeyboardHeightDragController(::adjustKeyboardHeightByDrag) + private val heightDragController = KeyboardHeightDragController( + onDragBy = ::adjustKeyboardHeightByDrag, + onDragEnd = ::commitKeyboardHeightAfterDrag, + ) private var navigationBottomInsetPx = 0 private val themeApplier: ImeThemeApplier by lazy { ImeThemeApplier( @@ -2038,10 +2046,14 @@ open class ImeKeyboardView( } } - /** 键盘调节:按竖直拖动量改变高度百分比(持久化在 [setKeyboardHeightPercent] 内)。 */ + /** + * 键盘调节:拖动中只做即时预览(行高跟着手指变),松手时才写回设置。 + * 原来每个触摸样本都走 [setKeyboardHeightPercent]:一次拖动写上百次设置、 + * 整块键面重建上百次。 + */ private fun adjustKeyboardHeightByDrag(deltaY: Float) { if (floatingKeyboardController.enabled) return - setKeyboardHeightPercent( + previewKeyboardHeightPercent( KeyboardHeightDragPolicy.applyTo( keyboardHeightPercent, deltaY, @@ -2050,6 +2062,42 @@ open class ImeKeyboardView( ) } + /** 拖动结束(松手,或系统把手势收走)时把最终高度存下来,并让面板跟着刷新。 */ + private fun commitKeyboardHeightAfterDrag() { + if (floatingKeyboardController.enabled) return + ImeSettingsRepository.saveKeyboardHeightPercent( + context, + keyboardHeightPercent, + landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, + ) + listener.onKeyboardHeightChanged(keyboardHeightPercent) + } + + /** 拖动中的即时预览:改行高与键盘高度,重建压到一帧一次。 */ + private fun previewKeyboardHeightPercent(percent: Int) { + val bounded = percent.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT) + if (keyboardHeightPercent == bounded) return + keyboardHeightPercent = bounded + renderedMode = null + layoutMetrics = buildLayoutMetrics() + applyDynamicHeights() + if (!standalonePanel && panel == Panel.NONE) { + scheduleModeBodyRebuild() + } else { + requestLayout() + } + } + + /** + * 一帧最多重建一次键面:拖动这类逐样本的输入用它把重建合并到下一帧, + * 一帧内的多个样本只重建一次。 + */ + private fun scheduleModeBodyRebuild() { + if (modeBodyRebuildPosted) return + modeBodyRebuildPosted = true + postOnAnimation(modeBodyRebuild) + } + private fun setKeyboardHeightPercent(percent: Int) { val bounded = percent.coerceIn(ImeGeometryTokens.KEYBOARD_HEIGHT_MIN_PERCENT, ImeGeometryTokens.KEYBOARD_HEIGHT_MAX_PERCENT) if (keyboardHeightPercent == bounded) return @@ -2184,10 +2232,12 @@ open class ImeKeyboardView( // voiceGestureSession, which stopVoiceIfActive() cleared above. repeatHandler.removeCallbacksAndMessages(null) removeCallbacks(pendingRowRebuildPoll) + removeCallbacks(modeBodyRebuild) backspaceGestureController.shutdown() spaceVoiceGestureController.shutdown() floatingKeyboardController.reset() pendingRowRebuild = false + modeBodyRebuildPosted = false touchStreamActive = false hidePopup() } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt index 281e2559..33eaf562 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardHeightDragController.kt @@ -11,6 +11,8 @@ import llc.slacker.openime.floating.LongPressDragTarget */ internal class KeyboardHeightDragController( private val onDragBy: (Float) -> Unit, + /** 手势结束(松手或被系统收走)时收尾:拖动中只预览,最终值这时才落地。 */ + private val onDragEnd: () -> Unit, ) : LongPressDragTarget { private var active = false private var pointerId = -1 @@ -40,11 +42,18 @@ internal class KeyboardHeightDragController( if (deltaY != 0f) onDragBy(deltaY) } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> reset() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> finish() } return true } + /** 拖动收尾:真的拖过([active])才报告结束,一次轻点不算。 */ + private fun finish() { + val wasDragging = active + reset() + if (wasDragging) onDragEnd() + } + override fun reset() { active = false pointerId = -1 From 423de1821825aa83aaaa02dd2275f2113fa7b574 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:38:23 +0800 Subject: [PATCH 82/94] Record the four touch-path fixes in the changelog Co-Authored-By: Claude Code --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b4c155..2604530e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 +- 顶栏的长按拖动现在在空白处也起手。浮动键盘原来只有按在图标上才能拖:手指落在图标之间(或空闲时那一行)时没有任何子视图消费这次按下,框架从此把整条手势直接派发给顶栏自己的 `onTouchEvent`,不再回头问拦截器(`ViewGroup.dispatchTouchEvent` 在没有触摸目标时 `intercepted = true`),而这里返回 `super` 的 false,长按成立了也没人接手。现在顶栏认领这条手势,吞掉按下、长按后在第一个移指样本上起手。停靠键盘的「键盘调节」则在真机上根本起不了手:它要求按在工具条行内,而行的位置取自 `getLocationInWindow`(窗口内坐标),拿去比的是 `MotionEvent.rawY`——"the original location of the event on the screen, before it had been adjusted for the containing window and views"。输入法窗口不在屏幕原点(`dumpsys window`:`frame=[0,1731][1080,2400]`,`gravity=BOTTOM`),行的窗口 y 是 0、手指的屏幕 y 约 1731,这个判断在真机上永远不成立。现在按屏幕坐标取行框,与候选项滑选(`KeyPopupController.trackChoiceTouch`)同一取法。 +- 「键盘调节」拖动时只做预览,松手才写一次。原来每个触摸样本都走一次 `setKeyboardHeightPercent`:一次拖动写上百次设置、整块键面重建上百次。现在行高和键盘高度跟着手指实时变,重建压到下一帧(一帧内多个样本只重建一次),松手(或手势被系统收走)时才写回一次:存下最终高度并通知一次。 +- 浮动键盘拖动只在卡片真的移动时才写窗口属性。原来每个触摸样本都写一次,包括取整后位移为 0 的抖动样本——一次写入就是一次窗口 relayout,60Hz 下就是每帧一次;拖动路径每条还要重读一次不透明度设置。现在位置、尺寸、不透明度都没变化时直接不写,不透明度沿用上次应用的值;真的移动仍然照写,边界仍然夹取。 +- 键面上残留的按下不再挡住之后的所有重建。`hasPressedKey()` 靠遍历 `isPressed` 判断「还有键占着这条手势」,`renderModeBody` 在此期间会把重建交给一个 40ms 的轮询。视图被移出层级时 `isPressed` 不会被清掉,于是被重建换下、或把手势吞掉的那一次按下,会让某个键永远看起来是按下的:此后每次重建(切键盘、转屏、关面板)都静默失效,轮询还一直跑着。现在 `dispatchTouchEvent` 记着手上有没有触摸流,没有时 `hasPressedKey()` 一律回答"没有",真手势期间的推迟不受影响,松手后那次被推迟的重建照常落地。 - 参考实现自带一份「错音词」表(每个词标出某个字在词里该怎么读,如「阿房宫」的「阿」读 ē、「房」读 páng),共 6308 条。对照我们的词表:3390 条读音本来就一致,2852 条词我们表里根本没有(多为姓名和少见的专名),剩下 66 条是我们读错的——岑参(应读 cén shēn,我们表里是 cēn cān)、五行八作(wǔ háng bā zuō)、说客(shuō kè)这类。这 66 条现在生成一张 64 行的修正表 `pinyin_reading_fixes.tsv`,由拼音词库一并加载,所以读错的词现在能用正确读音打出来(打 `censhen` 出「岑参」)。修正行沿用该词自己的词频权重,不会盖过同读音的常用词;只写不一致的那几十条,读音相同的 3390 条不重复,我们表里没有的 2852 条属于「收词范围」而不是「读音错误」,这次不动。参考那份表的索引(每行一个音节 id 加两个计数字段)含义在它的 native 代码里,从数据无法判定,我们只用了内容能逐字节解码的部分。 - 模糊音打开时,常用的六组(`z = zh`、`c = ch`、`s = sh`、`an = ang`、`en = eng`、`in = ing`)默认就是打开的,其余九组默认关闭。参考实现的模糊音设置页也是这个默认:总开关「模糊拼音」默认关闭,而它自己的偏好 XML 里这六组写着 `defaultValue=true`(`res/cU.xml` 即 `R.xml.settings_preferences`,键名 `kZEqualToZh`、`kCEqualToCh`、`kSEqualToSh`、`kAnEqualToAng`、`kEnEqualToEng`、`kInEqualToIng`),另外九组为 false——十五组与我们的开关一一对应。我们原来十五组全关,用户打开「启用模糊音」后还要自己一组组勾。总开关仍默认关闭,所以不碰这个设置的人不受影响。 - 五笔四码打完只剩一个候选时立即上屏(参考设置的「四码唯一直接上屏」,默认打开)。参考实现的五笔设置页有四个开关,默认值就写在它自己的偏好 XML 里(`res/cU.xml` 即 `R.xml.settings_preferences`,由 `SettingsConfigNext.` 读取):五笔编码提示、五笔拼音混输、四码唯一直接上屏三项默认打开,第五码将首选词上屏默认关闭。我们的 `wubi86.schema.yaml` 原来没有设 `speller/auto_select`,而 librime 只有这个开关打开才会把「只此一个候选」的四码输入直接上屏(librime 1.17.0,`app/src/main/cpp/vendor/librime/src/rime/gear/speller.cc:161`),于是没有对手的五笔字一直留在候选栏等下一个键。参考自己的五笔码最长四码(一码 25 个、二码 617 个、三码 4809 个、四码 143749 个),所以这个开关不会提前触发。另外两项在 librime 里对不上:第五码上屏(`speller.cc:148`)只要 `max_code_length` 大于 0 就一定发生、不看 `auto_select`,参考那边默认关闭,两者拆不开;五笔拼音混输要一份五笔与拼音合一的表(rime-wubi 是单独一个方案),编码提示要候选注释。 From d4f0089d67f0f70b525af76c593fc3e5c28e5e35 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:41:21 +0800 Subject: [PATCH 83/94] Find the top zone's blank band next to the icon strip The zone gives toolbarRow its own full height, so the row reaches the zone's bottom: measuring the band below the row found 0px and the test polled until its 30s timeout. The band is the slack around the 48dp icon strip inside the row, which is where the gesture lands on no child. Co-Authored-By: Claude Code --- .../FloatingGameKeyboardInstrumentedTest.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt index a2f17f0d..28b3985d 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -220,19 +220,19 @@ class FloatingGameKeyboardInstrumentedTest { harness.awaitMain { keyboard.setFloatingWindowMode(true); true } settleTip(harness, keyboard) val topZone = harness.awaitMain { keyboard.findViewWithTag("ime_toolbar") } - // The 48dp toolbar row sits centered in the 60dp zone, so a strip at the - // bottom of the zone belongs to no child. Nothing consumes the DOWN there, - // and the framework never asks the interceptor again once a gesture has no - // touch target: the zone itself has to claim it (ImeTopZone.onTouchEvent). + // 工具栏那一行自己就是顶栏的高度(`ImeTopZone` 给 toolbarRow 的高度就是 + // COMPOSED_TOP_ZONE_HEIGHT_DP),48dp 的图标条在它里面居中,上下各留一条空带。 + // 空带里没有任何子视图消费按下,而一条没有 touch target 的手势之后框架不再回头 + // 问拦截器:只能由顶栏自己认领(ImeTopZone.onTouchEvent)。 val (x, y) = harness.awaitMain { - val row = topZone.findViewWithTag("toolbar-row") - if (row?.height == 0 || topZone.height == 0) return@awaitMain null + val icons = topZone.findViewWithTag("toolbar-icon-strip") + if (icons?.height == 0 || topZone.height == 0) return@awaitMain null val zoneLocation = IntArray(2).also { topZone.getLocationInWindow(it) } - val rowLocation = IntArray(2).also { row.getLocationInWindow(it) } - val rowBottom = rowLocation[1] - zoneLocation[1] + row.height - val strip = topZone.height - rowBottom + val iconsLocation = IntArray(2).also { icons.getLocationInWindow(it) } + val iconsBottom = iconsLocation[1] - zoneLocation[1] + icons.height + val strip = topZone.height - iconsBottom if (strip < 4) return@awaitMain null - (topZone.width / 2f) to (rowBottom + strip / 2f) + (topZone.width / 2f) to (iconsBottom + strip / 2f) } val downTime = SystemClock.uptimeMillis() harness.awaitMain { touch(topZone, MotionEvent.ACTION_DOWN, downTime, x, y); true } From bc97f37caa8cddb514f39966fd07236a79b449b7 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:01:01 +0800 Subject: [PATCH 84/94] Insert a line break on a long press of a send key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference documents the gesture itself: the subtitle of its own switch (`res/cU.xml`, key `enable_key_enter_send_msg`, default false) reads 「长按「发送」可换行。开关支持App范围」, and its confirm dialog repeats the line. Only the send face is documented, so only that face takes a long press here; a composition reads 确定 on the key and keeps its press-only behaviour, as do the other action faces. `enterKeyLongPressFor` sits next to the other Enter-key policy so the key face, the dispatched action and the long press all resolve from the same EditorInfo bits. `ImeKeyboardView.syncEnterKeyPresentation` wires the listener and says the gesture in the key's contentDescription (「发送,长按换行」), `onEnterNewline` commits a live composition the way the key's own press does and otherwise sends a raw Enter. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../KeyboardBottomRowInstrumentedTest.kt | 44 +++++++++++++++++++ .../slacker/openime/LocalVoiceImeService.kt | 16 +++++++ .../openime/editor/EnterActionPolicy.kt | 20 +++++++++ .../openime/keyboard/ImeKeyboardView.kt | 32 ++++++++++++-- .../slacker/openime/EnterActionPolicyTest.kt | 33 ++++++++++++++ docs/DECISIONS.md | 4 ++ 7 files changed, 147 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2604530e..291d3410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 发送键长按换行:参考自己的设置文案写着这条手势(`enable_key_enter_sub_title`「长按「发送」可换行。开关支持App范围」,开关本体 `enable_key_enter_send_msg` 在 `res/cU.xml` 里默认关闭、标题「回车键发送消息」,拨动开关时的确认框也重复这句)。编辑器声明了发送动作、键面读「发送」时长按插入一个换行,走的是「换行」那一面同样的真 Enter 键事件(没有动作时的那条路径),不再是发送。合成中键面读「确定」,「搜索」「前往」「完成」这些动作参考没有这条手势,都保持原来的一次按压;键的无障碍描述跟着念出这条手势(「发送,长按换行」,与角标键「,上滑或长按输入X」同一写法)。 - 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 - 顶栏的长按拖动现在在空白处也起手。浮动键盘原来只有按在图标上才能拖:手指落在图标之间(或空闲时那一行)时没有任何子视图消费这次按下,框架从此把整条手势直接派发给顶栏自己的 `onTouchEvent`,不再回头问拦截器(`ViewGroup.dispatchTouchEvent` 在没有触摸目标时 `intercepted = true`),而这里返回 `super` 的 false,长按成立了也没人接手。现在顶栏认领这条手势,吞掉按下、长按后在第一个移指样本上起手。停靠键盘的「键盘调节」则在真机上根本起不了手:它要求按在工具条行内,而行的位置取自 `getLocationInWindow`(窗口内坐标),拿去比的是 `MotionEvent.rawY`——"the original location of the event on the screen, before it had been adjusted for the containing window and views"。输入法窗口不在屏幕原点(`dumpsys window`:`frame=[0,1731][1080,2400]`,`gravity=BOTTOM`),行的窗口 y 是 0、手指的屏幕 y 约 1731,这个判断在真机上永远不成立。现在按屏幕坐标取行框,与候选项滑选(`KeyPopupController.trackChoiceTouch`)同一取法。 - 「键盘调节」拖动时只做预览,松手才写一次。原来每个触摸样本都走一次 `setKeyboardHeightPercent`:一次拖动写上百次设置、整块键面重建上百次。现在行高和键盘高度跟着手指实时变,重建压到下一帧(一帧内多个样本只重建一次),松手(或手势被系统收走)时才写回一次:存下最终高度并通知一次。 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt index 61ae036d..a3798958 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import llc.slacker.openime.core.KeyboardMode @@ -9,6 +10,7 @@ import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.keyboard.ProductionKeyPolicy 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 @@ -21,6 +23,8 @@ import java.lang.reflect.Proxy class KeyboardBottomRowInstrumentedTest { private class Recorder { val characters = mutableListOf() + var enters = 0 + var newlines = 0 val listener = Proxy.newProxyInstance( ImeKeyboardView.Listener::class.java.classLoader, arrayOf(ImeKeyboardView.Listener::class.java), @@ -28,6 +32,8 @@ class KeyboardBottomRowInstrumentedTest { when (method.name) { "voiceModelState" -> VoiceModelLifecycleState.COLD "onCharacter" -> { characters += args!![0] as String; null } + "onEnter" -> { enters++; null } + "onEnterNewline" -> { newlines++; null } else -> if (method.returnType == java.lang.Boolean.TYPE) false else null } } as ImeKeyboardView.Listener @@ -168,6 +174,44 @@ class KeyboardBottomRowInstrumentedTest { assertEquals(listOf(".", ","), recorder.characters.toList()) } + /** + * 发送键的长按是换行:参考自己的设置文案就是这句(`enable_key_enter_sub_title` + * 「长按「发送」可换行。开关支持App范围」)。只有「发送」这一面有长按——建立中的 + * 合成读「确定」,其余动作读它们自己的动作词,长按仍是各自那一次按压。 + */ + @Test + fun theSendKeyLongPressesToALineBreak() = withKeyboard { harness, recorder, keyboard -> + harness.awaitMain { + keyboard.editorOptionsOverride = EditorInfo.IME_ACTION_SEND + // Rebuild the 26-key rows so the enter key is created under the send face. + keyboard.setMode(KeyboardMode.PINYIN_9) + keyboard.setMode(KeyboardMode.PINYIN_26) + true + } + val send = settled(harness, keyboard, "key-enter") + harness.awaitMain { + assertEquals("发送,长按换行", send.contentDescription.toString()) + assertTrue("the send key must take a long press", send.performLongClick()) + assertEquals("a long press must not send", 0, recorder.enters) + assertEquals(1, recorder.newlines) + true + } + + harness.awaitMain { + keyboard.editorOptionsOverride = EditorInfo.IME_ACTION_SEARCH + keyboard.setMode(KeyboardMode.ENGLISH_26) + keyboard.setMode(KeyboardMode.PINYIN_26) + true + } + val search = settled(harness, keyboard, "key-enter") + harness.awaitMain { + assertEquals("搜索", search.contentDescription.toString()) + assertFalse("only 发送 takes a long press", search.performLongClick()) + assertEquals(1, recorder.newlines) + true + } + } + @Test fun englishKeysShowLowercaseLetters() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index f9d60db1..d0687a2c 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -1196,6 +1196,22 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } } + /** + * 长按「发送」:按参考自己的说法(`enable_key_enter_sub_title`「长按「发送」可换行」) + * 插入一个换行,走的是没有输入动作时那条同样的路径(真 Enter 键事件), + * 而不是再拆一条只写 `\n` 的路。合成中(键面是「确定」)不该有长按, + * 真发生也当普通的 Enter 处理,不把没上屏的拼音留在输入框里。 + */ + override fun onEnterNewline() = guarded("onEnterNewline") { + prepareForManualInput() + if (lastComposition.isNotEmpty()) { + commitRawComposition() + return + } + voiceCorrectionTracker.finalizeIfNeeded() + gateway.sendKeyDownUp(KeyEvent.KEYCODE_ENTER) + } + override fun onCompositionChanged(composition: String, candidates: List) = guarded("onCompositionChanged") { prepareForManualInput() if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput() 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 30749af7..5c68b206 100644 --- a/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt @@ -40,3 +40,23 @@ internal fun enterKeyFaceFor(imeOptions: Int?, composing: Boolean): String { /** 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 + +/** What a long press on the Enter key does, when it does anything. */ +internal enum class EnterKeyLongPress { Newline } + +/** + * The long press the reference puts on its 发送 key — 「长按「发送」可换行」, the + * subtitle of its own switch for that key (`res/cU.xml` key `enable_key_enter_send_msg`, + * default false, titled 「回车键发送消息」, and its confirm dialog repeats the line: + * 「…显示「发送」。长按「发送」可换行。」). Only the send face is documented there, so + * only that face takes one here; a composition reads 确定 on the key, not 发送, and + * keeps its press-only behaviour. + */ +internal fun enterKeyLongPressFor(imeOptions: Int, composing: Boolean): EnterKeyLongPress? { + if (composing) return null + return if (enterKeyPresentationFor(imeOptions).editorAction == EditorInfo.IME_ACTION_SEND) { + EnterKeyLongPress.Newline + } else { + 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 e9e76aa0..8066eb44 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -51,7 +51,9 @@ import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.data.SymbolRecentRepository import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.editor.EnterKeyLongPress import llc.slacker.openime.editor.enterKeyFaceFor +import llc.slacker.openime.editor.enterKeyLongPressFor import llc.slacker.openime.editor.enterKeyPresentationFor import llc.slacker.openime.floating.FloatingKeyboardController import llc.slacker.openime.panel.ClipboardPanelController @@ -102,6 +104,14 @@ open class ImeKeyboardView( } fun onVoiceCommit() {} fun onEnter() + + /** + * A long press on a send key inserts a line break instead of sending + * ([enterKeyLongPressFor]). Views that only display the keyboard take no + * long press, so the default is empty. + */ + fun onEnterNewline() {} + fun onCompositionChanged(composition: String, candidates: List) fun onNineKeyCompositionChanged( composition: String, @@ -1836,7 +1846,7 @@ open class ImeKeyboardView( candidateBarController.syncExpandControl( hasCandidates = currentCandidates.isNotEmpty(), ) - syncEnterKeyPresentation(state.editorInfo?.imeOptions) + syncEnterKeyPresentation(state.editorInfo?.imeOptions ?: currentImeOptions()) } /** @@ -1858,10 +1868,26 @@ open class ImeKeyboardView( val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return val composing = composition.text?.isNotEmpty() == true + val longPress = enterKeyLongPressFor(options, composing) enter.setMainText(enterKeyFaceFor(options, composing)) enter.applyMainTextScale(referenceScale) - enter.contentDescription = if (composing) "确定" else enterKeyPresentationFor(options).label + enter.contentDescription = when { + composing -> "确定" + // 长按按参考自己的说法换行,跟着键面一起念出来(同「,上滑或长按输入X」的写法)。 + longPress == EnterKeyLongPress.Newline -> "${enterKeyPresentationFor(options).label},长按换行" + else -> enterKeyPresentationFor(options).label + } themeApplier.apply(enter, currentThemeTokens()) + enter.setOnLongClickListener( + if (longPress == EnterKeyLongPress.Newline) { + View.OnLongClickListener { + listener.onEnterNewline() + true + } + } else { + null + }, + ) } private var inlineAutofillController: InlineAutofillController? = null @@ -2360,7 +2386,7 @@ open class ImeKeyboardView( composing -> ImeTopZoneState.COMPOSING else -> ImeTopZoneState.IDLE } - syncEnterKeyPresentation((context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions) + syncEnterKeyPresentation(currentImeOptions()) findViewWithTag("key-enter")?.let(::applyThemeToSubtree) topZone.renderState( state = state, diff --git a/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt index f9f5c880..495d9ca6 100644 --- a/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt @@ -1,7 +1,9 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo +import llc.slacker.openime.editor.EnterKeyLongPress import llc.slacker.openime.editor.editorActionForEnter +import llc.slacker.openime.editor.enterKeyLongPressFor import llc.slacker.openime.editor.enterKeyFaceFor import llc.slacker.openime.editor.enterKeyPresentationFor import org.junit.Assert.assertEquals @@ -58,6 +60,37 @@ class EnterActionPolicyTest { } } + /** 参考自己的设置文案:`enable_key_enter_sub_title`「长按「发送」可换行。开关支持App范围」。 */ + @Test + fun theSendFaceTakesALongPressThatInsertsALineBreak() { + assertEquals( + EnterKeyLongPress.Newline, + enterKeyLongPressFor(EditorInfo.IME_ACTION_SEND, composing = false), + ) + } + + @Test + fun noOtherEnterFaceTakesALongPressTheReferenceDoesNotDocument() { + listOf( + EditorInfo.IME_ACTION_DONE, + EditorInfo.IME_ACTION_SEARCH, + EditorInfo.IME_ACTION_GO, + EditorInfo.IME_ACTION_NEXT, + EditorInfo.IME_ACTION_PREVIOUS, + EditorInfo.IME_ACTION_NONE, + EditorInfo.IME_ACTION_UNSPECIFIED, + // The editor keeps the key on its raw-Enter face even though it asked for SEND. + EditorInfo.IME_ACTION_SEND or EditorInfo.IME_FLAG_NO_ENTER_ACTION, + ).forEach { options -> + assertNull("options=$options", enterKeyLongPressFor(options, composing = false)) + } + } + + @Test + fun aCompositionReadsDeterminateAndTakesNoLongPress() { + assertNull(enterKeyLongPressFor(EditorInfo.IME_ACTION_SEND, composing = true)) + } + @Test fun theKeyFaceIsComposingOrActionOrReturnArrow() { val send = EditorInfo.IME_ACTION_SEND diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 35a45287..33cd2566 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -360,3 +360,7 @@ Add new entries at the end. - **The toolbar's long-press area is measured in screen coordinates, like the touch it tests.** 「键盘调节」 (`ImeTopZone.setLongPressDrag(toolbarOnly = true)`) arms only when the press lands inside the toolbar row, and the press arrives as `MotionEvent.rawX/rawY` — "the original location of the event on the screen, before it had been adjusted for the containing window and views" (`MotionEvent.getRawX`). The row was measured with `getLocationInWindow`, which is window-local: the IME window does not sit at the screen origin (`dumpsys window`: `mAttrs={… gr=BOTTOM CENTER_VERTICAL …}`, `Frames: parent=[0,128][1080,2400] frame=[0,1731][1080,2400]`), so the row's window y is 0 while a finger on it reports a raw y of about 1731 — the check could not be true on a device and the docked drag never armed. It now uses `getLocationOnScreen`, the same measure the candidate choice strip already tests raw touches with (`KeyPopupController.trackChoiceTouch`). *Reason:* raw coordinates are screen coordinates; comparing them with window-local ones only works while a window happens to cover the whole screen, which the docked IME window never does. The floating card is unaffected: it arms anywhere on the zone (`toolbarOnly = false`), and its drag works off raw deltas, which do not depend on the origin. + +- **A send key inserts a line break on a long press, and says so.** + The reference's own switch for that key carries the gesture in its text: the subtitle is 「长按「发送」可换行。开关支持App范围」 (`resources.arsc`, `enable_key_enter_sub_title`), the switch itself is `enable_key_enter_send_msg` in its preference XML (`res/cU.xml`, `R.xml.settings_preferences`, `defaultValue=false`, titled 「回车键发送消息」 at `enable_key_enter_title`), and the confirmation it shows when the switch moves repeats the line (「…显示「发送」。长按「发送」可换行。」, `enter_key_send_msg_confirm_content`; read back with `aapt2 dump xmltree --file res/cU.xml base.apk` and `aapt2 dump resources base.apk`). The keyboard layer draws both faces of the key (`libkeyboard.so` strings 发送 and 换行). This port draws 发送 from the editor's own action and had nothing behind a long press: `EnterActionPolicy.enterKeyLongPressFor` now answers [Newline] for that face alone, and the service inserts the line break the 换行 face inserts — the same raw Enter key event, `LocalVoiceImeService.onEnterNewline`. + *Reason:* the reference states the gesture to the user in its own words, and a chat editor that asked for SEND otherwise has no way to type a line break from this keyboard. The other direction is left out because nothing justifies it: no string in the reference documents a long press on a 换行 face, and an editor carrying `IME_FLAG_NO_ENTER_ACTION` asked for a newline, so sending there would override the flag it set. The key announces the gesture the way the corner-mark keys announce theirs (`Pinyin26KeyboardRenderer`: 「,上滑或长按输入。」) — its description reads 「发送,长按换行」, and the composition face keeps its plain 确定. From 8754912a99efdae0b1b7c82353c0ce8c68e8ca2e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:29:20 +0800 Subject: [PATCH 85/94] Split a clipboard entry into words on a long press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference names all three parts of this feature itself: the clipboard list's tip 「长按列表可进入拆词」 (`string/clipboard_history_word_seg_tip`), the view it opens, titled 「滑动选词」 (`word_segmentation_title`), and its one action, 「添加到常用语」 (`word_segmentation_add_to_common_phrase`), with a colour set that separates a clickable label from an un-clickable one and gives that action a disabled face. This port had nothing behind a long press on a clipboard card. `HanWordSegmenter` takes the longest word the tables carry at each position (they hold up to eight characters, 依法追究刑事责任 being the longest row of `pinyin_phrases.tsv`); `TextWordIndex` is their text side, built once on the panel's own thread. `ClipboardPanelController` opens the split on a long press, selects a run by sliding across the words — the run stays when the finger lifts — and files it into 常用语 (`QuickPhraseRepository`), disabled until something is selected. `WordFlowLayout` is the wrapped list the words are laid out in. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + ...ipboardWordSegmentationInstrumentedTest.kt | 255 +++++++++++++++ .../openime/keyboard/ImeThemeApplier.kt | 28 ++ .../openime/panel/ClipboardPanelController.kt | 300 ++++++++++++++++-- .../slacker/openime/text/HanWordSegmenter.kt | 99 ++++++ .../llc/slacker/openime/text/TextWordIndex.kt | 27 ++ .../slacker/openime/widget/WordFlowLayout.kt | 95 ++++++ .../openime/ArchitectureLayeringTest.kt | 4 +- .../slacker/openime/HanWordSegmenterTest.kt | 81 +++++ docs/DECISIONS.md | 4 + 10 files changed, 870 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/text/HanWordSegmenter.kt create mode 100644 app/src/main/java/llc/slacker/openime/text/TextWordIndex.kt create mode 100644 app/src/main/java/llc/slacker/openime/widget/WordFlowLayout.kt create mode 100644 app/src/test/java/llc/slacker/openime/HanWordSegmenterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 291d3410..32e77e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 剪贴板列表也能拆词:长按任意一条进入「滑动选词」(参考自己的文案:列表上那句提示是「长按列表可进入拆词」`clipboard_history_word_seg_tip`,视图标题 `word_segmentation_title`「滑动选词」)。文字按词库切成词——每个位置取词表里最长的那个词(词表最长 8 字,「依法追究刑事责任」就是),字母数字连成一段,标点和空白是不能选的间隔——在词上滑动划出一段再点「添加到常用语」(`word_segmentation_add_to_common_phrase`,没选中时按钮变淡且不可点,对应参考的 `word_segmentation_add_to_common_phrase_bg_disabled`),随后面板落在常用语列表,那一段就在里面。参考这一页的成组颜色把词条分成可选与不可选两种(`ime_word_segmentation_item_bg_color`、`..._item_selected_bg_color`、`..._label_clickable_bg_color`、`..._label_un_clickable_bg_color`),我们的口径一样:词可以选,标点与空白只是间隔。参考另有「常用语数量已达上限」,我们的常用语本来不设上限,这条不移植。 - 发送键长按换行:参考自己的设置文案写着这条手势(`enable_key_enter_sub_title`「长按「发送」可换行。开关支持App范围」,开关本体 `enable_key_enter_send_msg` 在 `res/cU.xml` 里默认关闭、标题「回车键发送消息」,拨动开关时的确认框也重复这句)。编辑器声明了发送动作、键面读「发送」时长按插入一个换行,走的是「换行」那一面同样的真 Enter 键事件(没有动作时的那条路径),不再是发送。合成中键面读「确定」,「搜索」「前往」「完成」这些动作参考没有这条手势,都保持原来的一次按压;键的无障碍描述跟着念出这条手势(「发送,长按换行」,与角标键「,上滑或长按输入X」同一写法)。 - 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 - 顶栏的长按拖动现在在空白处也起手。浮动键盘原来只有按在图标上才能拖:手指落在图标之间(或空闲时那一行)时没有任何子视图消费这次按下,框架从此把整条手势直接派发给顶栏自己的 `onTouchEvent`,不再回头问拦截器(`ViewGroup.dispatchTouchEvent` 在没有触摸目标时 `intercepted = true`),而这里返回 `super` 的 false,长按成立了也没人接手。现在顶栏认领这条手势,吞掉按下、长按后在第一个移指样本上起手。停靠键盘的「键盘调节」则在真机上根本起不了手:它要求按在工具条行内,而行的位置取自 `getLocationInWindow`(窗口内坐标),拿去比的是 `MotionEvent.rawY`——"the original location of the event on the screen, before it had been adjusted for the containing window and views"。输入法窗口不在屏幕原点(`dumpsys window`:`frame=[0,1731][1080,2400]`,`gravity=BOTTOM`),行的窗口 y 是 0、手指的屏幕 y 约 1731,这个判断在真机上永远不成立。现在按屏幕坐标取行框,与候选项滑选(`KeyPopupController.trackChoiceTouch`)同一取法。 diff --git a/app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt new file mode 100644 index 00000000..fd444987 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/ClipboardWordSegmentationInstrumentedTest.kt @@ -0,0 +1,255 @@ +package llc.slacker.openime + +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +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.ClipboardHistoryRepository +import llc.slacker.openime.data.QuickPhraseRepository +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 拆词 on the clipboard panel: the reference enters it with a long press on the + * list (`clipboard_history_word_seg_tip`「长按列表可进入拆词」), selects words by + * sliding across them (`word_segmentation_title`「滑动选词」) and files the run + * into the quick phrases (`word_segmentation_add_to_common_phrase` + * 「添加到常用语」, disabled until a run is selected — + * `word_segmentation_add_to_common_phrase_bg_disabled`). + */ +@RunWith(AndroidJUnit4::class) +class ClipboardWordSegmentationInstrumentedTest { + + private lateinit var harness: DirectActivityHarness + + /** 今天/天气/不错 are rows of the phrase table; 天气不错 is not. */ + private val fixture = "今天天气不错,iPhone 15" + + @Before + fun launch() { + harness = DirectActivityHarness(DebugKeyboardActivity::class.java) + harness.launch() + } + + @After + fun close() { + harness.close() + } + + @Test + fun aLongPressOpensTheSplitAndTheSlideFilesTheRun() { + lateinit var keyboard: ImeKeyboardView + harness.awaitMain { activity -> + ClipboardHistoryRepository.clearAll(activity) + ClipboardHistoryRepository.add(activity, fixture) + keyboard = ImeKeyboardView(activity, NoopListener()) + activity.findViewById(android.R.id.content).addView( + keyboard, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + keyboard.showPanel(Panel.CLIPBOARD) + true + } + + val card = harness.awaitMain { + keyboard.findViewWithTag("clip-card")?.takeIf { it.width > 0 } + } + harness.awaitMain { assertTrue("the clipboard card must take a long press", card.performLongClick()); true } + + // The split is built off the main thread (the word tables are read for + // it), so the flow arrives a moment after the long press. + val flow = harness.awaitMain { + keyboard.findViewWithTag("word-seg-flow")?.takeIf { it.childCount > 0 } + } + harness.awaitMain { + assertEquals( + "the view carries the reference's own title", + "滑动选词", + keyboard.findViewWithTag("panel-section-title")?.text?.toString(), + ) + assertEquals( + listOf("今天", "天气", "不错", ",", "iPhone", " ", "15"), + (0 until flow.childCount).map { (flow.getChildAt(it) as TextView).text.toString() }, + ) + true + } + + val add = keyboard.findViewWithTag("word-seg-add") + harness.awaitMain { + assertNotNull(add) + assertFalse("nothing is selected yet", add!!.isEnabled) + assertEquals(ImeSurfacePolicy.DISABLED_ALPHA, add.alpha, 0.001f) + true + } + + // 滑动选词: press 今天, sweep to 不错, lift. + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + send(flow, MotionEvent.ACTION_DOWN, downTime, centreX(flow, 0), centreY(flow, 0)) + send(flow, MotionEvent.ACTION_MOVE, downTime, centreX(flow, 1), centreY(flow, 1)) + send(flow, MotionEvent.ACTION_MOVE, downTime, centreX(flow, 2), centreY(flow, 2)) + send(flow, MotionEvent.ACTION_UP, downTime, centreX(flow, 2), centreY(flow, 2)) + true + } + harness.awaitMain { + assertEquals("the run is the three words, not one", 1f, add!!.alpha, 0.001f) + assertTrue(add.isEnabled) + (0..2).forEach { index -> + assertTrue( + "row $index must read as selected", + (flow.getChildAt(index).tag as String).startsWith("word-token-selected:"), + ) + } + assertEquals( + "a separator is never part of the run", + "word-token-plain:3", + flow.getChildAt(3).tag as String, + ) + true + } + + harness.awaitMain { add!!.performClick(); true } + harness.awaitMain { activity -> + assertTrue( + "the run must land in the quick phrases", + QuickPhraseRepository.load(activity).any { it.text == "今天天气不错" }, + ) + assertEquals( + "the panel lands on the list the phrase went into", + "常用语", + keyboard.findViewWithTag("segment-selected")?.text?.toString(), + ) + // Leave the store as it was: the phrase this test added is its own. + QuickPhraseRepository.load(activity).filter { it.text == "今天天气不错" } + .forEach { QuickPhraseRepository.remove(activity, it.id) } + ClipboardHistoryRepository.clearAll(activity) + keyboard.shutdown() + true + } + } + + @Test + fun aLongEntryWrapsAndTheSlideCrossesTheLines() { + val long = "今天天气不错,".repeat(4).trimEnd(',') + lateinit var keyboard: ImeKeyboardView + harness.awaitMain { activity -> + ClipboardHistoryRepository.clearAll(activity) + ClipboardHistoryRepository.add(activity, long) + keyboard = ImeKeyboardView(activity, NoopListener()) + activity.findViewById(android.R.id.content).addView( + keyboard, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + keyboard.showPanel(Panel.CLIPBOARD) + true + } + val card = harness.awaitMain { + keyboard.findViewWithTag("clip-card")?.takeIf { it.width > 0 } + } + harness.awaitMain { assertTrue(card.performLongClick()); true } + val flow = harness.awaitMain { + keyboard.findViewWithTag("word-seg-flow")?.takeIf { it.childCount > 0 } + } + + val downTime = SystemClock.uptimeMillis() + harness.awaitMain { + val rows = (0 until flow.childCount).map { flow.getChildAt(it).top }.distinct().size + assertTrue("a long text must wrap onto more than one line, got $rows", rows > 1) + val last = flow.childCount - 1 + // 今天天气不错,今天天气不错,… — the last word is the final 不错. + assertEquals("不错", (flow.getChildAt(last) as TextView).text.toString()) + send(flow, MotionEvent.ACTION_DOWN, downTime, centreX(flow, 0), centreY(flow, 0)) + // Sweep down the lines, the way a finger crosses them. + (0..last).forEach { index -> + send(flow, MotionEvent.ACTION_MOVE, downTime, centreX(flow, index), centreY(flow, index)) + } + send(flow, MotionEvent.ACTION_UP, downTime, centreX(flow, last), centreY(flow, last)) + true + } + harness.awaitMain { + val add = keyboard.findViewWithTag("word-seg-add") + assertNotNull(add) + assertTrue("the whole text is one run", add!!.isEnabled) + add.performClick() + true + } + harness.awaitMain { activity -> + assertTrue( + "the whole text must be selectable across its lines", + QuickPhraseRepository.load(activity).any { it.text == long }, + ) + QuickPhraseRepository.load(activity).filter { it.text == long } + .forEach { QuickPhraseRepository.remove(activity, it.id) } + ClipboardHistoryRepository.clearAll(activity) + keyboard.shutdown() + true + } + } + + private fun centreX(flow: ViewGroup, index: Int): Float { + val child = flow.getChildAt(index) + return (child.left + child.right) / 2f + } + + private fun centreY(flow: ViewGroup, index: Int): Float { + val child = flow.getChildAt(index) + return (child.top + child.bottom) / 2f + } + + private fun send(view: View, action: Int, downTime: Long, x: Float, y: Float) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0) + try { + view.dispatchTouchEvent(event) + } finally { + event.recycle() + } + } + + private class NoopListener : ImeKeyboardView.Listener { + override fun onModeChanged(mode: KeyboardMode) = Unit + override fun onPanelChanged(panel: Panel) = Unit + override fun onCharacter(char: String) = Unit + override fun onBackspace() = Unit + override fun onClearAll() = Unit + override fun onSpace() = Unit + override fun onFloatingKeyboardChanged(floating: Boolean) = Unit + override fun onFloatingKeyboardDragged(deltaX: Float, deltaY: Float) = Unit + override fun onVoiceToggle() = Unit + override fun onEnter() = Unit + override fun onCompositionChanged(composition: String, candidates: List) = Unit + override fun onCandidateSelected(candidate: String) = Unit + override fun onCompositionBackspace() = Unit + override fun onThemeChanged(theme: ImeTheme) = Unit + override fun onAppearanceChanged(appearance: ImeAppearance) = Unit + override fun onShiftStateChanged(state: ShiftState) = Unit + override fun onCandidateExpanded(open: Boolean) = Unit + override fun onSymbolSelected(symbol: String) = Unit + override fun onEmojiSelected(emoji: String) = Unit + override fun onTextEdit(action: String) = Unit + override fun onSoundChanged(enabled: Boolean) = Unit + override fun onHapticChanged(enabled: Boolean) = Unit + override fun onPopupChanged(enabled: Boolean) = Unit + override fun onFuzzyChanged(enabled: Boolean) = Unit + } +} 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 09c6abdf..efe1be02 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -754,6 +754,34 @@ internal class ImeThemeApplier( ) } + // 拆词: a word is a card, the selected run of them the panel's + // selection face, and a separator carries no face of its own. + tag?.startsWith("word-token-selected:") == true -> { + view.setTextColor(ImeSurfacePolicy.selectedText(t)) + view.background = ImeDrawableFactory.rounded( + ImeSurfacePolicy.segmentSelected(t), + toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + ImeSurfacePolicy.selectedText(t), + toPx(1), + ) + view.typeface = android.graphics.Typeface.DEFAULT_BOLD + } + + tag?.startsWith("word-token:") == true -> { + view.setTextColor(t.keyText) + view.background = ImeDrawableFactory.rounded( + t.toolCardBackground, + toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + ) + view.typeface = android.graphics.Typeface.DEFAULT + } + + tag?.startsWith("word-token-plain:") == true -> { + view.setTextColor(t.keySecondaryText) + view.background = null + view.typeface = android.graphics.Typeface.DEFAULT + } + tag == "segment-selected" -> { view.setTextColor(t.keyText) view.background = if (hasAncestorTag(view, "settings-panel")) { diff --git a/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt index ab999aaa..4afee3a6 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt @@ -7,6 +7,7 @@ import android.os.Build import android.text.TextUtils import android.text.format.DateUtils import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.HorizontalScrollView @@ -20,9 +21,13 @@ import llc.slacker.openime.data.ClipboardHistoryRepository import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.text.HanToken +import llc.slacker.openime.text.HanWordSegmenter +import llc.slacker.openime.text.TextWordIndex import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView +import llc.slacker.openime.widget.WordFlowLayout /** * Owns clipboard/quick-phrase panel presentation and transient load state. @@ -61,39 +66,83 @@ internal class ClipboardPanelController( private var tab = 0 private var loadGeneration = 0 + /** The entry whose text is being split into words, when that view is open. */ + private var wordSegEntry: ClipboardEntry? = null + + /** The words of [wordSegEntry], in the order they read. */ + private var wordSegTokens: List = emptyList() + + /** The token range the finger has swept, when one has. */ + private var wordSegSelection: IntRange? = null + + /** The token the current slide started on. */ + private var wordSegAnchor: Int? = null + /** 0 = clipboard history, 1 = quick phrases; applied on the next [render]. */ fun selectTab(index: Int) { tab = index.coerceIn(0, 1) } + /** The panel was closed or replaced: abandon the load and any 拆词 view with it. */ fun invalidatePendingLoad() { loadGeneration++ + wordSegEntry = null + wordSegTokens = emptyList() + wordSegSelection = null + wordSegAnchor = null } fun render(reusePanel: Boolean = false) { expandedPanel.removeAllViews() val header = createHeader("") while (header.childCount > 1) header.removeViewAt(header.childCount - 1) - header.addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL; tag = "segmented-track" - setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) - listOf("剪贴板", "常用语").forEachIndexed { index, label -> - addView(TextView(context).apply { - text = label; textSize = ImeTypographyTokens.BODY_SP; gravity = Gravity.CENTER; includeFontPadding = false - tag = if (tab == index) "segment-selected" else "segment-option" - contentDescription = label; isClickable = true; isFocusable = true - setOnClickListener { onFeedback(); tab = index; render(true) } - }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) - } - }, LinearLayout.LayoutParams(0, toPx(34), 1f).apply { marginStart = toPx(36); marginEnd = toPx(20) }) + if (wordSegEntry == null) { + header.addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL; tag = "segmented-track" + setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) + listOf("剪贴板", "常用语").forEachIndexed { index, label -> + addView(TextView(context).apply { + text = label; textSize = ImeTypographyTokens.BODY_SP; gravity = Gravity.CENTER; includeFontPadding = false + tag = if (tab == index) "segment-selected" else "segment-option" + contentDescription = label; isClickable = true; isFocusable = true + setOnClickListener { onFeedback(); tab = index; render(true) } + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + } + }, LinearLayout.LayoutParams(0, toPx(34), 1f).apply { marginStart = toPx(36); marginEnd = toPx(20) }) + } header.addView(TextView(context).apply { - text = if (tab == 0) "↻" else "+ 新增" - tag = if (tab == 0) "clipboard-refresh" else "quick-phrase-add" - textSize = if (tab == 0) 24f else 12f; gravity = Gravity.CENTER - contentDescription = if (tab == 0) "重新读取剪贴板" else "新增常用语" + val splitting = wordSegEntry != null + text = when { + splitting -> "返回" + tab == 0 -> "↻" + else -> "+ 新增" + } + tag = when { + splitting -> "word-seg-back" + tab == 0 -> "clipboard-refresh" + else -> "quick-phrase-add" + } + textSize = if (tab == 0 && !splitting) 24f else 12f; gravity = Gravity.CENTER + contentDescription = when { + splitting -> "返回剪贴板列表" + tab == 0 -> "重新读取剪贴板" + else -> "新增常用语" + } isClickable = true; isFocusable = true - setOnClickListener { onFeedback(); if (tab == 0) render(true) else onOpenQuickPhraseEditor(null) } - }, LinearLayout.LayoutParams(toPx(if (tab == 0) 48 else 70), toPx(34))) + setOnClickListener { + onFeedback() + when { + splitting -> { + wordSegEntry = null + wordSegTokens = emptyList() + wordSegSelection = null + render(true) + } + tab == 0 -> render(true) + else -> onOpenQuickPhraseEditor(null) + } + } + }, LinearLayout.LayoutParams(toPx(if (tab == 0 && wordSegEntry == null) 48 else 70), toPx(34))) expandedPanel.addView(header, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(48))) val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -103,10 +152,10 @@ internal class ClipboardPanelController( val column = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - if (tab == 0) { - renderClipboardHistory(column, body) - } else { - renderQuickPhrases(column) + when { + wordSegEntry != null -> renderWordSegmentation(column, body) + tab == 0 -> renderClipboardHistory(column, body) + else -> renderQuickPhrases(column) } val scroll = createVerticalScroll(column, "clipboard-scroll") @@ -136,6 +185,17 @@ internal class ClipboardPanelController( body: LinearLayout, ) { column.addView(createSectionTitle("最近复制"), wrapParams()) + // The reference tells the gesture on this list itself + // (`clipboard_history_word_seg_tip`「长按列表可进入拆词」). + column.addView( + TextView(context).apply { + text = "长按任意一条可进入拆词" + textSize = ImeTypographyTokens.PANEL_NOTE_SP + setPadding(toPx(4), 0, toPx(4), toPx(4)) + tag = "panel-note" + }, + wrapParams(), + ) val loadingHint = TextView(context).apply { text = "正在读取剪贴板…" textSize = ImeTypographyTokens.PANEL_BODY_SP @@ -216,6 +276,192 @@ internal class ClipboardPanelController( } } + /** + * 拆词: the entry's text split into the words the tables carry, with the + * reference's own gesture on them — 「滑动选词」, slide across the words to + * select the run you want (`word_segmentation_title`), then hand the run to + * the quick phrases (`word_segmentation_add_to_common_phrase` + * 「添加到常用语」, disabled until something is selected — + * `word_segmentation_add_to_common_phrase_bg_disabled`). + * + * The words are the panel's own rows rather than the reference's item grid, + * but the selection is the reference's: a slide, not a tap per word. + */ + private fun renderWordSegmentation(column: LinearLayout, body: LinearLayout) { + val entry = wordSegEntry ?: return + column.addView(createSectionTitle("滑动选词"), wrapParams()) + val loadingHint = TextView(context).apply { + text = "正在分词…" + textSize = ImeTypographyTokens.PANEL_NOTE_SP + setPadding(toPx(4), 0, toPx(4), toPx(4)) + tag = "panel-note" + } + column.addView(loadingHint, wrapParams()) + val flow = WordFlowLayout(context).apply { + tag = "word-seg-flow" + setPadding(toPx(2), toPx(6), toPx(2), toPx(6)) + } + column.addView( + flow, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + val addButton = createPanelButton("添加到常用语", ImeTypographyTokens.BODY_SP, false).apply { + tag = "word-seg-add" + contentDescription = "添加到常用语" + } + val addRow = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + tag = "word-seg-actions" + } + addRow.addView( + addButton, + LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), + ) + column.addView( + addRow, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ).apply { topMargin = toPx(8) }, + ) + + val generation = ++loadGeneration + flow.setOnTouchListener { _, event -> onWordSegTouch(flow, event) } + Thread { + val words = runCatching { TextWordIndex.words(context) }.getOrNull() + val tokens = if (words == null) { + emptyList() + } else { + HanWordSegmenter.segment(entry.text) { it in words } + } + expandedPanel.post { + if (generation != loadGeneration || wordSegEntry !== entry || column.parent == null) { + return@post + } + (loadingHint.parent as? ViewGroup)?.removeView(loadingHint) + if (tokens.isEmpty()) { + column.addView( + statusText("这段内容没有可分词的文字。", error = false), + wrapParams(), + ) + return@post + } + wordSegTokens = tokens + tokens.forEachIndexed { index, token -> flow.addView(wordTokenView(index, token)) } + syncWordSegSelection() + applyTheme() + onContentLoaded() + } + }.apply { isDaemon = true }.start() + } + + private fun wordTokenView(index: Int, token: HanToken): TextView = TextView(context).apply { + text = token.text + textSize = ImeTypographyTokens.TITLE_SP + gravity = Gravity.CENTER + includeFontPadding = false + tag = if (token.selectable) "word-token" else "word-token-plain" + contentDescription = token.text + setPadding(toPx(8), toPx(5), toPx(8), toPx(5)) + minimumHeight = toPx(28) + }.apply { + val params = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + params.setMargins(0, toPx(2), toPx(4), toPx(2)) + layoutParams = params + this.tag = "${this.tag}:$index" + } + + /** + * The slide: the word under the finger at DOWN is the anchor, every + * selectable word the finger then reaches extends the run, and the run + * stays when the finger lifts so the action can be taken after. + */ + private fun onWordSegTouch(flow: WordFlowLayout, event: MotionEvent): Boolean { + val index = flow.childIndexAt(event.x, event.y) + val selectable = index >= 0 && wordSegTokens.getOrNull(index)?.selectable == true + return when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (!selectable) { + false + } else { + wordSegAnchor = index + wordSegSelection = index..index + syncWordSegSelection() + true + } + } + MotionEvent.ACTION_MOVE -> { + val anchor = wordSegAnchor + if (anchor != null && selectable) { + wordSegSelection = minOf(anchor, index)..maxOf(anchor, index) + syncWordSegSelection() + } + anchor != null + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + wordSegAnchor = null + true + } + else -> false + } + } + + /** Paints the selection onto the word rows and the action button. */ + private fun syncWordSegSelection() { + val flow = expandedPanel.findViewWithTag("word-seg-flow") as? WordFlowLayout ?: return + val selection = wordSegSelection + for (index in 0 until flow.childCount) { + val token = wordSegTokens.getOrNull(index) ?: continue + val view = flow.getChildAt(index) as? TextView ?: continue + val selected = token.selectable && selection != null && index in selection + view.tag = when { + !token.selectable -> "word-token-plain:$index" + selected -> "word-token-selected:$index" + else -> "word-token:$index" + } + view.contentDescription = if (selected) "${token.text},已选" else token.text + } + val text = selectedWordSegText() + val add = expandedPanel.findViewWithTag("word-seg-add") + if (add != null) { + add.isEnabled = text != null + add.isClickable = text != null + add.alpha = if (text != null) 1f else llc.slacker.openime.theme.ImeSurfacePolicy.DISABLED_ALPHA + add.setOnClickListener { + val value = selectedWordSegText() ?: return@setOnClickListener + onFeedback() + QuickPhraseRepository.upsert(context, id = 0L, category = "常用", text = value) + wordSegEntry = null + wordSegTokens = emptyList() + wordSegSelection = null + tab = 1 + render(reusePanel = true) + } + } + applyTheme() + } + + /** + * The text the selected words cover, exactly as the entry spells it: the + * span from the first selected word to the last, so the punctuation written + * between two words comes along rather than being stitched out. + */ + private fun selectedWordSegText(): String? { + val selection = wordSegSelection ?: return null + val indexes = wordSegTokens.indices.filter { it in selection && wordSegTokens[it].selectable } + if (indexes.isEmpty()) return null + val text = wordSegEntry?.text ?: return null + val first = wordSegTokens[indexes.first()] + val last = wordSegTokens[indexes.last()] + return text.substring(first.start, last.end) + } + private fun addRefreshAction(column: LinearLayout) { column.addView( createPanelButton("重新读取", ImeTypographyTokens.CAPTION_SP, true).apply { @@ -283,9 +529,17 @@ internal class ClipboardPanelController( LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL setPadding(toPx(14), toPx(4), toPx(4), toPx(4)); minimumHeight = toPx(56) - tag = "clip-card"; contentDescription = "剪贴板:${entry.text},点击使用" + tag = "clip-card"; contentDescription = "剪贴板:${entry.text},点击使用,长按拆词" isClickable = true; isFocusable = true setOnClickListener { onFeedback(); onCharacter(entry.text) } + setOnLongClickListener { + onFeedback() + wordSegEntry = entry + wordSegTokens = emptyList() + wordSegSelection = null + render(true) + true + } addView(TextView(context).apply { text = entry.text; textSize = ImeTypographyTokens.TITLE_SP; maxLines = 1; ellipsize = TextUtils.TruncateAt.END }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) addView(iconButton(R.drawable.ic_pin, if (entry.pinned) "取消置顶" else "置顶", "clip-pin:${entry.text}") { diff --git a/app/src/main/java/llc/slacker/openime/text/HanWordSegmenter.kt b/app/src/main/java/llc/slacker/openime/text/HanWordSegmenter.kt new file mode 100644 index 00000000..fa6bfe53 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/text/HanWordSegmenter.kt @@ -0,0 +1,99 @@ +package llc.slacker.openime.text + +/** + * One piece of a segmented text: a word the tables know, a run of letters or + * digits, or a separator between them. + * + * [start] and [end] are code-unit offsets into the text it came from, so the + * text a run of tokens covers is the substring between the first and the last + * of them — separators inside the run come along, as they are written. + */ +internal data class HanToken( + val text: String, + val start: Int, + val end: Int, + val selectable: Boolean, +) + +/** + * Splits a text into the words the tables carry, the way the reference's 拆词 + * does for a copied text: it walks the text and takes the longest word it finds + * at each position, so 今天天气 reads 今天 + 天气 rather than four characters, + * and falls back to single characters where no word of the tables starts there. + * + * Letters and digits of any script form one token, so a copied address or + * message id stays whole. Whitespace and punctuation are tokens too, but they + * are not selectable: they separate words rather than being one, and the + * reference's label colours have a clickable and an un-clickable face. + */ +internal object HanWordSegmenter { + /** The longest word of the phrase table is 8 characters (依法追究刑事责任). */ + const val MAX_WORD_CHARS = 8 + + fun segment(text: String, isWord: (String) -> Boolean): List { + val tokens = ArrayList() + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + val width = Character.charCount(codePoint) + when { + isHan(codePoint) -> { + val (token, consumed) = readWord(text, index, isWord) + tokens += token + index += consumed + continue + } + Character.isLetterOrDigit(codePoint) -> { + var end = index + width + while (end < text.length) { + val next = text.codePointAt(end) + if (!Character.isLetterOrDigit(next) || isHan(next)) break + end += Character.charCount(next) + } + tokens += HanToken(text.substring(index, end), index, end, selectable = true) + index = end + continue + } + else -> { + var end = index + width + while (end < text.length) { + val next = text.codePointAt(end) + if (isHan(next) || Character.isLetterOrDigit(next)) break + end += Character.charCount(next) + } + tokens += HanToken(text.substring(index, end), index, end, selectable = false) + index = end + continue + } + } + } + return tokens + } + + /** The longest word starting at [start], or its first character when none does. */ + private fun readWord( + text: String, + start: Int, + isWord: (String) -> Boolean, + ): Pair { + var end = start + var characters = 0 + while (end < text.length && characters < MAX_WORD_CHARS) { + val codePoint = text.codePointAt(end) + if (!isHan(codePoint)) break + end += Character.charCount(codePoint) + characters++ + } + while (characters > 1) { + val candidate = text.substring(start, end) + if (isWord(candidate)) return HanToken(candidate, start, end, selectable = true) to (end - start) + end -= Character.charCount(text.codePointBefore(end)) + characters-- + } + val single = text.substring(start, end) + return HanToken(single, start, end, selectable = isWord(single)) to (end - start) + } + + private fun isHan(codePoint: Int): Boolean = + Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN +} diff --git a/app/src/main/java/llc/slacker/openime/text/TextWordIndex.kt b/app/src/main/java/llc/slacker/openime/text/TextWordIndex.kt new file mode 100644 index 00000000..5fbe766d --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/text/TextWordIndex.kt @@ -0,0 +1,27 @@ +package llc.slacker.openime.text + +import android.content.Context +import llc.slacker.openime.candidate.PinyinLexicon + +/** + * The text side of the Pinyin tables: every word they carry, for 拆词. + * + * The tables are keyed by reading, and the same word sits under each of its + * readings, so the set is the union of the values; single characters come in + * with them, from the character table and the Rime table alike. Built once on + * the panel's own thread, next to the clipboard read that asked for it. + */ +internal object TextWordIndex { + @Volatile + private var cached: Set? = null + + fun words(context: Context): Set = cached ?: synchronized(this) { + cached ?: build(PinyinLexicon.load(context)).also { cached = it } + } + + internal fun build(lexicon: Map>): Set { + val words = HashSet(lexicon.size * 4) + lexicon.values.forEach { values -> words.addAll(values) } + return words + } +} diff --git a/app/src/main/java/llc/slacker/openime/widget/WordFlowLayout.kt b/app/src/main/java/llc/slacker/openime/widget/WordFlowLayout.kt new file mode 100644 index 00000000..1619a954 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/widget/WordFlowLayout.kt @@ -0,0 +1,95 @@ +package llc.slacker.openime.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup + +/** + * Lays its children out left to right and wraps to the next line, which is the + * shape a word list needs when every word is its own width. + * + * It also answers which child a point falls in ([childIndexAt]): the reference + * selects words by sliding across them (`word_segmentation_title` + * 「滑动选词」), so the list has to read both the first and the last word a + * gesture touches, not just the one that was tapped. + */ +internal class WordFlowLayout(context: Context) : ViewGroup(context) { + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val available = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) + .coerceAtLeast(1) + var lineWidth = 0 + var lineHeight = 0 + var contentWidth = 0 + var contentHeight = 0 + for (index in 0 until childCount) { + val child = getChildAt(index) + if (child.visibility == GONE) continue + measureChild(child, widthMeasureSpec, heightMeasureSpec) + val params = child.layoutParams as MarginLayoutParams + val childWidth = child.measuredWidth + params.leftMargin + params.rightMargin + if (lineWidth > 0 && lineWidth + childWidth > available) { + contentWidth = maxOf(contentWidth, lineWidth) + contentHeight += lineHeight + lineWidth = 0 + lineHeight = 0 + } + lineWidth += childWidth + lineHeight = maxOf(lineHeight, child.measuredHeight + params.topMargin + params.bottomMargin) + } + contentWidth = maxOf(contentWidth, lineWidth) + contentHeight += lineHeight + setMeasuredDimension( + resolveSize(contentWidth + paddingLeft + paddingRight, widthMeasureSpec), + resolveSize(contentHeight + paddingTop + paddingBottom, heightMeasureSpec), + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + val available = (right - left - paddingLeft - paddingRight).coerceAtLeast(1) + var x = paddingLeft + var y = paddingTop + var lineHeight = 0 + for (index in 0 until childCount) { + val child = getChildAt(index) + if (child.visibility == GONE) continue + val params = child.layoutParams as MarginLayoutParams + val childWidth = child.measuredWidth + params.leftMargin + params.rightMargin + if (x > paddingLeft && x + childWidth > paddingLeft + available) { + x = paddingLeft + y += lineHeight + lineHeight = 0 + } + child.layout( + x + params.leftMargin, + y + params.topMargin, + x + params.leftMargin + child.measuredWidth, + y + params.topMargin + child.measuredHeight, + ) + x += childWidth + lineHeight = maxOf(lineHeight, child.measuredHeight + params.topMargin + params.bottomMargin) + } + } + + /** The child under a point of this view's own coordinates, or -1. */ + fun childIndexAt(x: Float, y: Float): Int { + for (index in 0 until childCount) { + val child = getChildAt(index) + if (child.visibility != VISIBLE) continue + if (x >= child.left && x < child.right && y >= child.top && y < child.bottom) return index + } + return -1 + } + + override fun generateDefaultLayoutParams(): LayoutParams = + MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams = + MarginLayoutParams(context, attrs) + + override fun checkLayoutParams(params: LayoutParams): Boolean = params is MarginLayoutParams + + /** The keyboard build knows no child type of its own. */ + override fun generateLayoutParams(params: LayoutParams): LayoutParams = MarginLayoutParams(params) +} diff --git a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt index 006d1361..4023ad54 100644 --- a/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt +++ b/app/src/test/java/llc/slacker/openime/ArchitectureLayeringTest.kt @@ -31,8 +31,10 @@ class ArchitectureLayeringTest { "engine" to setOf("core"), "rime" to setOf("core", "data"), "candidate" to setOf("core", "engine", "rime"), + // 文本切分:拆词用的分词器与词表。词表就是拼音表的内容面,所以它建在 candidate 之上。 + "text" to setOf("candidate"), "voice" to setOf("data", "editor", "theme"), - "panel" to setOf("core", "data", "handwriting", "setup", "theme", "widget"), + "panel" to setOf("core", "data", "handwriting", "setup", "text", "theme", "widget"), "keyboard" to setOf( "candidate", "core", "data", "editor", "floating", "handwriting", "panel", "setup", "theme", "voice", "widget", diff --git a/app/src/test/java/llc/slacker/openime/HanWordSegmenterTest.kt b/app/src/test/java/llc/slacker/openime/HanWordSegmenterTest.kt new file mode 100644 index 00000000..9442151d --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/HanWordSegmenterTest.kt @@ -0,0 +1,81 @@ +package llc.slacker.openime + +import llc.slacker.openime.text.HanWordSegmenter +import llc.slacker.openime.text.HanToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HanWordSegmenterTest { + + private fun segment(text: String, vararg words: String): List { + val known = words.toSet() + return HanWordSegmenter.segment(text) { it in known } + } + + /** Whatever the split, the pieces read the text again, offsets and all. */ + private fun assertReadsBack(text: String, tokens: List) { + assertEquals("the tokens must spell the text", text, tokens.joinToString("") { it.text }) + tokens.forEach { token -> + assertEquals(text.substring(token.start, token.end), token.text) + } + assertEquals(0, tokens.first().start) + assertEquals(text.length, tokens.last().end) + } + + @Test + fun theLongestWordWinsAtEachPosition() { + val tokens = segment("今天天气不错", "今天", "天气", "不错", "天气不错") + assertEquals(listOf("今天", "天气不错"), tokens.map { it.text }) + assertTrue(tokens.all { it.selectable }) + assertReadsBack("今天天气不错", tokens) + } + + @Test + fun aWordIsNotStretchedPastTheTables() { + // 「今天」 is known and 「今天天气」 is not: the split keeps the word and + // reads the rest on its own rather than swallowing it. + val tokens = segment("今天天气", "今天", "天气") + assertEquals(listOf("今天", "天气"), tokens.map { it.text }) + } + + @Test + fun aCharacterNoWordCarriesIsShownButNotSelectable() { + val tokens = segment("今a", "今天") + assertEquals(listOf("今", "a"), tokens.map { it.text }) + assertFalse("今 is not a word of the tables here", tokens.first().selectable) + assertTrue("a letter run is worth picking", tokens.last().selectable) + assertReadsBack("今a", tokens) + } + + @Test + fun lettersDigitsAndSeparatorsEachFormOneToken() { + val tokens = segment("iPhone 15,开发 4G", "开发") + assertEquals(listOf("iPhone", " ", "15", ",", "开发", " ", "4G"), tokens.map { it.text }) + assertEquals( + listOf(true, false, true, false, true, false, true), + tokens.map { it.selectable }, + ) + assertReadsBack("iPhone 15,开发 4G", tokens) + } + + @Test + fun theLongestMatchIsCappedAtTheTablesOwnLongestWord() { + // 依法追究刑事责任 is the longest row of pinyin_phrases.tsv at 8 characters. + val eight = "依法追究刑事责任" + val tokens = segment(eight, eight) + assertEquals(listOf(eight), tokens.map { it.text }) + assertTrue(tokens.single().selectable) + // A ninth character starts a token of its own rather than extending it. + val nine = eight + "的" + val split = segment(nine, eight, "的") + assertEquals(listOf(eight, "的"), split.map { it.text }) + assertReadsBack(nine, split) + } + + @Test + fun anEmptyTextSegmentsToNothing() { + assertEquals(emptyList(), segment("", "今天")) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 33cd2566..1b798ccc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -364,3 +364,7 @@ Add new entries at the end. - **A send key inserts a line break on a long press, and says so.** The reference's own switch for that key carries the gesture in its text: the subtitle is 「长按「发送」可换行。开关支持App范围」 (`resources.arsc`, `enable_key_enter_sub_title`), the switch itself is `enable_key_enter_send_msg` in its preference XML (`res/cU.xml`, `R.xml.settings_preferences`, `defaultValue=false`, titled 「回车键发送消息」 at `enable_key_enter_title`), and the confirmation it shows when the switch moves repeats the line (「…显示「发送」。长按「发送」可换行。」, `enter_key_send_msg_confirm_content`; read back with `aapt2 dump xmltree --file res/cU.xml base.apk` and `aapt2 dump resources base.apk`). The keyboard layer draws both faces of the key (`libkeyboard.so` strings 发送 and 换行). This port draws 发送 from the editor's own action and had nothing behind a long press: `EnterActionPolicy.enterKeyLongPressFor` now answers [Newline] for that face alone, and the service inserts the line break the 换行 face inserts — the same raw Enter key event, `LocalVoiceImeService.onEnterNewline`. *Reason:* the reference states the gesture to the user in its own words, and a chat editor that asked for SEND otherwise has no way to type a line break from this keyboard. The other direction is left out because nothing justifies it: no string in the reference documents a long press on a 换行 face, and an editor carrying `IME_FLAG_NO_ENTER_ACTION` asked for a newline, so sending there would override the flag it set. The key announces the gesture the way the corner-mark keys announce theirs (`Pinyin26KeyboardRenderer`: 「,上滑或长按输入。」) — its description reads 「发送,长按换行」, and the composition face keeps its plain 确定. + +- **A clipboard entry takes a long press into 拆词, and a slide picks the words to keep.** + The reference names all three parts of this feature itself. The clipboard list carries the tip 「长按列表可进入拆词」 (`resources.arsc`, `string/clipboard_history_word_seg_tip`, `0x7f1100fa`), the view it opens is titled 「滑动选词」 (`string/word_segmentation_title`, `0x7f110567`) and holds one action, 「添加到常用语」 (`string/word_segmentation_add_to_common_phrase`, `0x7f110566`). Its colour set separates the two kinds of label and gives that action a disabled face — `ime_word_segmentation_item_bg_color`, `ime_word_segmentation_item_selected_bg_color`, `ime_word_segmentation_item_selected_text_color`, `ime_word_segmentation_label_clickable_bg_color`, `ime_word_segmentation_label_un_clickable_bg_color`, `word_segmentation_add_to_common_phrase_bg_disabled` — and its engine carries the subsystem (mangled strings of `libkeyboard.so`: `WordSegmentationByIndex`, `WordSegmentationData`, `WordSegmentationDetailData`, `WordSegmentationHandlerD`). This port had nothing behind a long press on a clipboard card, so a copied paragraph could only be inserted whole. `HanWordSegmenter` now splits an entry with the longest word the tables carry at each position — they hold words of up to eight characters, 依法追究刑事责任 being the longest row of `pinyin_phrases.tsv` — the words come from the text side of the same tables (`TextWordIndex` over `PinyinLexicon`), and `ClipboardPanelController` selects a run by sliding across the words and files it into 常用语 (`QuickPhraseRepository`). + *Reason:* the gesture and its destination are the reference's own words to the user, and the split is what makes a long copied text useful — copying a paragraph and wanting one phrase out of it is the case this exists for. Three details follow the reference's own evidence rather than an invention: the run is a slide, not a tap per word, and it stays selected when the finger lifts so the action can be taken after (「滑动选词」); a separator cannot be selected, because the label colours have a clickable and an un-clickable face and punctuation is not a word; and the action is disabled until something is selected, which is what its disabled background exists for. The words are our panels' own rows in a wrapped list (`WordFlowLayout`) rather than the reference's item grid, and the word set is the tables this port ships rather than a segmentation model — the reference's segmentation runs in its native code, and no asset of its APK is named for a segmentation table (`assets/dict/*` carries none). The count limit the reference states (`common_phrase_count_over_toast` 「常用语数量已达上限」) is not ported: this port's 常用语 have no cap to report. The word index is a new `text` package (it needs the lexicon, so the layer map places it above `candidate`), built once on the panel's own thread next to the clipboard read that asked for it. From 1cb1fc4d833c65efb5e62e089f78dbaaabad1aed Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:47:30 +0800 Subject: [PATCH 86/94] Recommend the poem line the composition spells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference ships a poetry grammar (sys_poem_gram, type 11010) whose lines are keyed by the pinyin they are typed with, and its own settings text names poetry among what 联想 recommends (关闭联想后,将不会推荐诗词、 计算结果、emoji表情。). Generate poem/lines.tsv from the decoded table and offer a line as a candidate once the composition spells three of its syllables, behind the engine's own candidates; a line spelled out whole brings the line that follows it in its own poem. The layer follows the 智能联想 switch the reference groups it under. Three syllables is the measured floor: of the 3,000 most frequent pinyin keys, 2 syllables match 380 (12.7%) and 3 match 3 (0.1%). Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../PoemAssociationInstrumentedTest.kt | 40 + app/src/main/assets/poem/lines.tsv | 5501 +++++++++++++++++ .../slacker/openime/LocalVoiceImeService.kt | 11 + .../openime/candidate/CandidatePipeline.kt | 21 +- .../slacker/openime/candidate/PoemIndex.kt | 133 + .../slacker/openime/CandidatePipelineTest.kt | 31 + .../java/llc/slacker/openime/PoemIndexTest.kt | 76 + docs/DECISIONS.md | 4 + scripts/import_doubao_data.py | 85 + 10 files changed, 5901 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt create mode 100644 app/src/main/assets/poem/lines.tsv create mode 100644 app/src/main/java/llc/slacker/openime/candidate/PoemIndex.kt create mode 100644 app/src/test/java/llc/slacker/openime/PoemIndexTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e77e76..5f54eb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 诗句联想:打出来的拼音凑够一句诗的前三个音节,这一句就出现在候选栏里;整句打全时,紧跟着的那一句也一起出来(打 `chunmianbujuexiao` 出「春眠不觉晓」和「处处闻啼鸟」)。参考实现自带一份诗句词典(`sys_poem_gram`,类型 11010),里面每条线(LINE)是「行号+汉字+音节序列」,后面跟一组关联组(GROUP),记着这一句在自己那首诗里的下一句(`doubaoime-teardown/DECODE-BATCH-D.md` §4,逐字节走完整份 351,270 字节,5,966 行 + 10,542 组);它自己的设置文案把诗词列在联想推荐的内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」。我们原来没有这一层,现在由 `scripts/import_doubao_data.py` 从那份表生成 `poem/lines.tsv`(4 字以上的 5,498 句,其中 5,042 句带下一句),拼音词表旁新增 `PoemIndex`:候选栏先给引擎自己的词,诗句跟在后面。三个音节是量出来的门槛——词表里最常用的 3000 个读音,两个音节就能对上诗句的有 380 个(12.7%,「春风」这种日常输入会一直冒诗句),三个音节只有 3 个(0.1%)。这一层跟着「智能联想」开关走,与参考把诗词算在联想的同一个开关里一致。参考那边的「计算结果」我们没有对等物(我们没有算式候选),四字以下的短句和九键按数字查句都没有移植,句间标点仍留在表里没有用上。 - 剪贴板列表也能拆词:长按任意一条进入「滑动选词」(参考自己的文案:列表上那句提示是「长按列表可进入拆词」`clipboard_history_word_seg_tip`,视图标题 `word_segmentation_title`「滑动选词」)。文字按词库切成词——每个位置取词表里最长的那个词(词表最长 8 字,「依法追究刑事责任」就是),字母数字连成一段,标点和空白是不能选的间隔——在词上滑动划出一段再点「添加到常用语」(`word_segmentation_add_to_common_phrase`,没选中时按钮变淡且不可点,对应参考的 `word_segmentation_add_to_common_phrase_bg_disabled`),随后面板落在常用语列表,那一段就在里面。参考这一页的成组颜色把词条分成可选与不可选两种(`ime_word_segmentation_item_bg_color`、`..._item_selected_bg_color`、`..._label_clickable_bg_color`、`..._label_un_clickable_bg_color`),我们的口径一样:词可以选,标点与空白只是间隔。参考另有「常用语数量已达上限」,我们的常用语本来不设上限,这条不移植。 - 发送键长按换行:参考自己的设置文案写着这条手势(`enable_key_enter_sub_title`「长按「发送」可换行。开关支持App范围」,开关本体 `enable_key_enter_send_msg` 在 `res/cU.xml` 里默认关闭、标题「回车键发送消息」,拨动开关时的确认框也重复这句)。编辑器声明了发送动作、键面读「发送」时长按插入一个换行,走的是「换行」那一面同样的真 Enter 键事件(没有动作时的那条路径),不再是发送。合成中键面读「确定」,「搜索」「前往」「完成」这些动作参考没有这条手势,都保持原来的一次按压;键的无障碍描述跟着念出这条手势(「发送,长按换行」,与角标键「,上滑或长按输入X」同一写法)。 - 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 diff --git a/app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt new file mode 100644 index 00000000..9d0bfd11 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PoemAssociationInstrumentedTest.kt @@ -0,0 +1,40 @@ +package llc.slacker.openime + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.PoemIndex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 诗句联想 against the shipped table: the reference's poetry dictionary + * (`sys_poem_gram`, `poem/lines.tsv`) has to be present in the APK and read + * back the way it was written. + */ +@RunWith(AndroidJUnit4::class) +class PoemAssociationInstrumentedTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun theShippedTableCarriesTheReferencesLines() { + val index = PoemIndex.obtain(context) + assertTrue("the poem table must ship whole, got ${index.size}", index.size > 5_000) + + // The line spelled out brings the line that follows it in its poem. + assertEquals( + listOf("春眠不觉晓", "处处闻啼鸟"), + index.suggestionsFor("chunmianbujuexiao"), + ) + assertEquals( + listOf("海上生明月", "天涯共此时"), + index.suggestionsFor("haishangshengmingyue"), + ) + // Three syllables are enough; two are every day's typing. + assertEquals(listOf("春眠不觉晓"), index.suggestionsFor("chunmianbu")) + assertTrue(index.suggestionsFor("chunmian").isEmpty()) + assertTrue(index.suggestionsFor("haishang").isEmpty()) + } +} diff --git a/app/src/main/assets/poem/lines.tsv b/app/src/main/assets/poem/lines.tsv new file mode 100644 index 00000000..a546fcc6 --- /dev/null +++ b/app/src/main/assets/poem/lines.tsv @@ -0,0 +1,5501 @@ +# Lines of the reference's poetry dictionary (sys_poem_gram), the +# syllables they are typed by, and the line that follows each one. +# Generated by scripts/import_doubao_data.py. +a ye wu da er 阿爷无大儿 木兰无长兄 +a zi wen mei lai 阿姊闻妹来 当户理红妆 +ai nai yi sheng shan shui lv 欸乃一声山水绿 回看天际下中流 +ai wu sheng zhi xu yu 哀吾生之须臾 羡长江之无穷 +ai zai wang sun shen wu shu 哀哉王孙慎勿疏 五陵佳气无时无 +ai zhuan jiu jue 哀转久绝 故渔者歌曰 +an an jian lin tao 黯黯见临洮 昔日长城战 +an chan zhi du long 安禅制毒龙 +an de guang sha qian wan jian 安得广厦千万间 大庇天下寒士俱欢颜 +an de zhi lao bu geng gui 安得至老不更归 +an ling jun bu ting gua ren 安陵君不听寡人 何也 +an ling jun qi xu gua ren 安陵君其许寡人 安陵君曰 +an ling jun shou di yu xian wang er shou zhi 安陵君受地于先王而守之 虽千里不敢易也 +an ling jun yin shi tang ju shi yu qin 安陵君因使唐雎使于秦 秦王谓唐雎曰 +an ling jun yue 安陵君曰 大王加惠 +an neng bian wo shi xiong ci 安能辨我是雄雌 +an neng cui mei zhe yao shi quan gui 安能摧眉折腰事权贵 使我不得开心颜 +an qiu qi neng qian li ye 安求其能千里也 策之不以其道 +an zhi feng he jin lai bian 安知峰壑今来变 当时只记入山深 +an zhi ting lan 岸芷汀兰 郁郁青青 +ang shou guan zhi 昂首观之 项为之强 +ao wu ze gu rou wei xing lu 傲物则骨肉为行路 虽董之以严刑 +ao zhe wei he 凹者为壑 神游其中 +ba bai li fen hui xia zhi 八百里分麾下炙 五十弦翻塞外声 +ba chi long xu fang jin ru 八尺龙须方锦褥 已凉天气未寒时 +ba dong san xia wu xia zhang 巴东三峡巫峡长 猿鸣三声泪沾裳 +ba guan zuo ri jin ru he 罢官昨日今如何 +ba jian mi xu jun 把剑觅徐君 唯见林花落 +ba jian si gu xin mang ran 拔剑四顾心茫然 欲渡黄河冰塞川 +ba jiao ye da zhi zi fei 芭蕉叶大支子肥 僧言古壁佛画好 +ba jiu hua sang ma 把酒话桑麻 待到重阳日 +ba jiu lin feng 把酒临风 其喜洋洋者矣 +ba jiu shi zhi hua 八九十枝花 +ba jiu wen qing tian 把酒问青天 不知天上宫阙 +ba jun ri xing san wan li 八骏日行三万里 穆王何事不重来 +ba nian feng wei tu si zhe 八年风味徒思浙 苦将侬 +ba ren song yu tian 巴人讼芋田 文翁翻教授 +ba shan chu shui qi liang de 巴山楚水凄凉地 二十三年弃置身 +ba shan dao shu er lai 拔山倒树而来 盖一癞蛤蟆 +ba shan ye yu zhang qiu chi 巴山夜雨涨秋池 何当共剪西窗烛 +ba shi shi de gui 八十始得归 道逢乡里人 +ba wu gou kan le 把吴钩看了 栏杆拍遍 +ba yuan feng yu ding 灞原风雨定 晚见雁行频 +ba yue hu die lai 八月蝴蝶来 双飞西园草 +ba yue hu shui ping 八月湖水平 涵虚混太清 +ba yue qiu gao feng nu hao 八月秋高风怒号 卷我屋上三重茅 +bai bu jiu zhe ying yan luan 百步九折萦岩峦 扪参历井仰胁息 +bai cao feng mao 百草丰茂 秋风萧瑟 +bai cao hong ye huang hua 白草红叶黄花 +bai chuan dong dao hai 百川东到海 何时复西归 +bai chuan guan he 百川灌河 泾流之大 +bai di cheng bian gu mu shu 白帝城边古木疏 圣代即今多雨露 +bai fa bei hua luo 白发悲花落 青云羡鸟飞 +bai fa shui jia weng ao 白发谁家翁媪 大儿锄豆溪东 +bai fei ju xing 百废具兴 乃重修岳阳楼 +bai ge zheng liu 百舸争流 鹰击长空 +bai gu luan peng hao 白骨乱蓬蒿 +bai hong guan ri 白虹贯日 要离之刺庆忌也 +bai li xi ju yu shi 百里奚举于市 故天将降大任于是人也 +bai lu heng jiang 白露横江 水光接天 +bai lu wei shuang 白露为霜 所谓伊人 +bai lu wei xi 白露未晞 所谓伊人 +bai lu wei yi 白露未已 所谓伊人 +bai mao fu lv shui 白毛浮绿水 红掌拨清波 +bai men liao luo yi duo wei 白门寥落意多违 红楼隔雨相望冷 +bai nian duo bing du deng tai 百年多病独登台 艰难苦恨繁霜鬓 +bai ri fang ge xu zong jiu 白日放歌须纵酒 青春作伴好还乡 +bai ri yi shan jin 白日依山尽 黄河入海流 +bai shui rao dong cheng 白水绕东城 此地一为别 +bai tou diao gu feng shuang li 白头吊古风霜里 老木沧波无限悲 +bai tou gong nv zai 白头宫女在 闲坐说玄宗 +bai tou sao geng duan 白头搔更短 浑欲不胜簪 +bai tu dao yao cheng 白兔捣药成 问言与谁餐 +bai xing shu gan bu dan shi hu jiang yi ying jiang jun zhe hu 百姓孰敢不箪食壶浆以迎将军者乎 诚如是 +bai xue fen fen he suo si 白雪纷纷何所似 兄子胡儿曰 +bai yin pan li yi qing luo 白银盘里一青螺 +bai you gan qi xin 百忧感其心 万事劳其形 +bai yu tiao zhu luan ru chuan 白雨跳珠乱入船 卷地风来忽吹散 +bai yun hui wang he 白云回望合 青霭入看无 +bai yun qian zai kong you you 白云千载空悠悠 晴川历历汉阳树 +bai yun shen chu you ren jia 白云深处有人家 停车坐爱枫林晚 +bai yun yi jing zhu 白云依静渚 春草闭闲门 +ban bi jian hai ri 半壁见海日 空中闻天鸡 +ban juan hong qi lin yi shui 半卷红旗临易水 霜重鼓寒声不起 +ban mu fang tang yi jian kai 半亩方塘一鉴开 天光云影共徘徊 +ban pi hong xiao yi zhang ling 半匹红绡一丈绫 系向牛头充炭直 +ban ru jiang feng ban ru yun 半入江风半入云 此曲只应天上有 +ban ye jun xing ge xiang bo 半夜军行戈相拨 风头如刀面如割 +ban ye liang chu tou 半夜凉初透 东篱把酒黄昏后 +ban zhui zhi xi chui yang an 斑骓只系垂杨岸 何处西南任好风 +ban zuo zhang ni ban zuo fan 半作障泥半作帆 +bao de san chun hui 报得三春晖 +bao jun huang jin tai shang yi 报君黄金台上意 提携玉龙为君死 +bao ming yue er zhang zhong 抱明月而长终 知不可乎骤得 +bao qi yan zu 保其岩阻 西和诸戎 +bao zhu sheng zhong yi sui chu 爆竹声中一岁除 春风送暖入屠苏 +bao zi zai qi pang 抱子在其旁 右手秉遗穗 +bei ding zhong yuan 北定中原 庶竭驽钝 +bei dou jian chun yuan 北斗兼春远 南陵寓使迟 +bei dou lan gan nan dou xie 北斗阑干南斗斜 今夜偏知春气暖 +bei dou qi xing gao 北斗七星高 哥舒夜带刀 +bei fang you wu chen zhe 北方有侮臣者 愿借子杀之 +bei feng chui yan xue fen fen 北风吹雁雪纷纷 莫愁前路无知已 +bei feng jiang shang han 北风江上寒 我家襄水曲 +bei feng juan di bai cao zhe 北风卷地白草折 胡天八月即飞雪 +bei guo feng guang 北国风光 千里冰封 +bei hai sui she 北海虽赊 扶摇可接 +bei hou he suo jian 背后何所见 珠压腰衱稳称身 +bei ji chao ting zhong bu gai 北极朝廷终不改 西山寇盗莫相侵 +bei pan lang ji 杯盘狼籍 相与枕藉乎舟中 +bei qu bu yi quan yu ji 被驱不异犬与鸡 长者虽有问 +bei shan yu gong zhang xi yue 北山愚公长息曰 汝心之固 +bei shan yu gong zhe 北山愚公者 年且九十 +bei shi mai chang bian 北市买长鞭 旦辞爷娘去 +bei yin da ze 北饮大泽 未至 +bei zhuo yan tian guang 背灼炎天光 力尽不知热 +ben che xiu suo 奔车朽索 其可忽乎 +ben liu dao hai bu fu hui 奔流到海不复回 君不见 +ben shi tong gen sheng 本是同根生 相煎何太急 +ben yi gao nan bao 本以高难饱 徒劳恨费声 +ben zai ji zhou zhi nan 本在冀州之南 河阳之北 +ben zi tong gen sheng 本自同根生 相煎何太急 +beng quan sa sa fei mu mo 迸泉飒飒飞木末 野鹿呦呦走堂下 +bi gu qi gen ben 必固其根本 欲流之远者 +bi hai qing tian ye ye xin 碧海青天夜夜心 +bi ji qi de yi 必积其德义 源不深而望流之远 +bi jiang you dao 必将有盗 其邻人之父亦云 +bi jing xi hu liu yue zhong 毕竟西湖六月中 风光不与四时同 +bi jun qi quan yuan 必浚其泉源 思国之安者 +bi lan gan wai xiu lian chui 碧阑干外绣帘垂 猩血屏风画折枝 +bi li ci xia 佛狸祠下 一片神鸦社鼓 +bi neng bi bu que lou 必能裨补阙漏 有所广益 +bi neng shi hang zhen he mu 必能使行阵和睦 优劣得所 +bi qi shou zhi tian ye 彼其受之天也 如此其贤也 +bi shui dong liu zhi ci hui 碧水东流至此回 两岸青山相对出 +bi tian ru shui ye yun qing 碧天如水夜云轻 雁声远过潇湘去 +bi wa fei meng 碧瓦飞甍 始悟为山市 +bi wei you qie ji yi 必为有窃疾矣 子墨子曰 +bi wen yuan ding ye shen feng 碧文圆顶夜深缝 扇裁月魄羞难掩 +bi xia yi yi zi mou 陛下亦宜自谋 以咨诹善道 +bi xian ku qi xin zhi 必先苦其心志 劳其筋骨 +bi yao qi jing 必摇其精 而况思其力之所不及 +bi you de tian shi zhe yi 必有得天时者矣 然而不胜者 +bi you wo shi yan 必有我师焉 择其善者而从之 +bi yu zhuang cheng yi shu gao 碧玉妆成一树高 万条垂下绿丝绦 +bian cha zhu yu shao yi ren 遍插茱萸少一人 +bian de yi shan 便得一山 山有小口 +bian fu xiang lu 便扶向路 处处志之 +bian qiu yi yan sheng 边秋一雁声 露从今夜白 +bian ren ji gu 砭人肌骨 其意萧条 +bian ting liu xue cheng hai shui 边亭流血成海水 武皇开边意未已 +bian ting piao yao na ke du 边庭飘飖那可度 绝域苍茫更何有 +bian xia xiang yang xiang luo yang 便下襄阳向洛阳 +bian xin ran wang shi 便欣然忘食 性嗜酒 +bian yao huan jia 便要还家 设酒杀鸡作食 +bian yin shi qing dao bi xiao 便引诗情到碧霄 +bian zhi si shi xi ying tian 便至四十西营田 去时里正与裹头 +bian zong you qian zhong feng qing 便纵有千种风情 更与何人说 +biao lue qi ren 摽掠其人 倚叠如山 +bie jun qu xi he shi hai 别君去兮何时还 且放白鹿青崖间 +bie lai cang hai shi 别来沧海事 语罢暮天钟 +bie meng yi yi dao xie jia 别梦依依到谢家 小廊回合曲阑斜 +bie shi mang mang jiang jin yue 别时茫茫江浸月 忽闻水上琵琶声 +bie shi yi ban zi wei zai xin tou 别是一般滋味在心头 +bie yi yu zhi shui duan chang 别意与之谁短长 +bie you you chou an hen sheng 别有幽愁暗恨生 此时无声胜有声 +bin cong za ta shi yao jin 宾从杂遝实要津 后来鞍马何逡巡 +bin zhu jin dong nan zhi mei 宾主尽东南之美 都督阎公之雅望 +bing dian yin chuang meng bu cheng 冰簟银床梦不成 碧天如水夜云轻 +bing ge fei bu jian li ye 兵革非不坚利也 米粟非不多也 +bing ge ji wei xi 兵戈既未息 儿童尽东征 +bing jia yi zu 兵甲已足 当奖率三军 +bing quan leng se xian ning jue 冰泉冷涩弦凝绝 凝绝不通声暂歇 +bing shu qian tou wan mu chun 病树前头万木春 今日听君歌一曲 +bing shuang zheng can qi 冰霜正惨凄 终岁常端正 +bing yi ran zi le 并怡然自乐 见渔人 +bing you cheng qi nong qi biao qiang wu dao yu shui mian zhe 并有乘骑弄旗标枪舞刀于水面者 如履平地 +bing zi wei qi ming 并自为其名 其诗以养父母 +bo han yue yang cheng 波撼岳阳城 欲济无舟楫 +bo huan geng you fan 薄宦梗犹泛 故园芜已平 +bo lan bu jing 波澜不惊 上下天光 +bo lan dong yuan kong 波澜动远空 襄阳好风日 +bo lan shi bu qi 波澜誓不起 妾心井中水 +bo mu kong tan qu 薄暮空潭曲 安禅制毒龙 +bo mu ming ming 薄暮冥冥 虎啸猿啼 +bo mu yu qiao cheng shui ru 薄暮渔樵乘水入 初因避地去人间 +bo se zha ming 波色乍明 鳞浪层层 +bo shang han yan cui 波上寒烟翠 山映斜阳天接水 +bo shang ma si kan zhao qu 波上马嘶看棹去 柳边人歇待船归 +bo tao ru nu 波涛如怒 山河表里潼关路 +bo wu nong yun chou yong zhou 薄雾浓云愁永昼 瑞脑消金兽 +bo zhong zhi jian jian yi lv 伯仲之间见伊吕 指挥若定失萧曹 +bu ba shuang mei dou hua chang 不把双眉斗画长 苦恨年年压金线 +bu bi cang yu ji 不必藏于己 力恶其不出于身也 +bu bi feng ri 不蔽风日 短褐穿结 +bu bi wei ji 不必为己 是故谋闭而不兴 +bu bian niu ma 不辩牛马 于是焉 +bu bian xian yuan he chu xun 不辨仙源何处寻 +bu dan zai tian 不但在天 养怡之福 +bu de dao liao xi 不得到辽西 +bu de gu cai wei 不得顾采薇 既至金门远 +bu de shou gu rou 不得收骨肉 世情恶衰歇 +bu du zi qi zi 不独子其子 使老有所终 +bu fu chu yan 不复出焉 遂与外人间隔 +bu fu de lu 不复得路 南阳刘子骥 +bu gan chang yu lin jiao qu 不敢长语临交衢 且为王孙立斯须 +bu gan chu yi yan yi fu 不敢出一言以复 俟其欣悦 +bu gan gao sheng yu 不敢高声语 恐惊天上人 +bu gan guo lin tao 不敢过临洮 +bu gan shao yu yue 不敢稍逾约 以是人多以书假余 +bu gan yan er gan nu 不敢言而敢怒 独夫之心 +bu gan yi xian xian 不敢倚先贤 +bu ji he hong 不霁何虹 高低冥迷 +bu ji ji yu fu gui 不汲汲于富贵 其言兹若人之俦乎 +bu ji wang lun song wo qing 不及汪伦送我情 +bu ji yuan wu yan 不击元无烟 人学始知道 +bu jian chang an jian chen wu 不见长安见尘雾 唯将旧物表深情 +bu jian shui duan 不见水端 于是焉 +bu jian xi yue 不见曦月 至于夏水襄陵 +bu jian you ren huan 不见有人还 戍客望边色 +bu jian yu yan kong si chu 不见玉颜空死处 君臣相顾尽沾衣 +bu jiao hu ma du yin shan 不教胡马度阴山 +bu jie cang zong ji 不解藏踪迹 浮萍一道开 +bu jin chang jiang gun gun lai 不尽长江滚滚来 万里悲秋常作客 +bu jin chang jiang gun gun liu 不尽长江滚滚流 年少万兜鍪 +bu ju yi ge jiang ren cai 不拘一格降人才 +bu jue bi shan mu 不觉碧山暮 秋云暗几重 +bu jue ru lv 不绝如缕 舞幽壑之潜蛟 +bu jue ya ran yi jing 不觉呀然一惊 神定 +bu jue yi ming ya 不觉已鸣鸦 +bu kan ren shi ri xiao tiao 不堪人事日萧条 +bu kan ying shou zeng 不堪盈手赠 还寝梦佳期 +bu ke bu xue 不可不学 蒙辞以军中多务 +bu ke ji qi ceng ci yi 不可计其层次矣 而楼上人往来屑屑 +bu ke jiu ju 不可久居 乃记之而去 +bu ke qu zhi ye 不可屈致也 将军宜枉驾顾之 +bu ke wei qiang 不可谓强 义不杀少而杀众 +bu ke wei ren 不可谓仁 知而不争 +bu ke wei zhi 不可谓智 宋无罪而攻之 +bu ke wei zhi lei 不可谓知类 公输盘服 +bu ke wei zhong 不可谓忠 争而不得 +bu ke zhi qi yuan 不可知其源 坐潭上 +bu ken di tou zai cao mang 不肯低头在草莽 东门酤酒饮我曹 +bu ken guo jiang dong 不肯过江东 +bu lu wen zhang shi yi jing 不露文章世已惊 未辞翦伐谁能送 +bu lun ping di yu shan jian 不论平地与山尖 无限风光尽被占 +bu man bu zhi 不蔓不枝 香远益清 +bu mu rong li 不慕荣利 好读书 +bu neng cheng qian shi zhi wen 不能称前时之闻 又七年 +bu neng jue ye 不能绝也 楚王曰 +bu neng ting zhong lei ru yu 不能听终泪如雨 洞庭连天九疑高 +bu neng yu zhi yu huai 不能喻之于怀 固知一死生为虚诞 +bu nian ju an si wei 不念居安思危 戒奢以俭 +bu po lou lan zhong bu hai 不破楼兰终不还 +bu qi qi yu pin jian 不戚戚于贫贱 不汲汲于富贵 +bu qin duo nian leng si tie 布衾多年冷似铁 娇儿恶卧踏里裂 +bu qin ting jin yao 不寝听金钥 因风想玉珂 +bu qiu shen jie 不求甚解 每有会意 +bu qiu wen da yu zhu hou 不求闻达于诸侯 先帝不以臣卑鄙 +bu ru zao huan jia 不如早还家 蜀道之难 +bu shi chen you wan wu hua 不是宸游玩物华 +bu shi lu shan zhen mian mu 不识庐山真面目 只缘身在此山中 +bu shi zhe tou shi shi feng 不是遮头是使风 +bu shou zhi ren 不受之人 且为众人 +bu si ying chuan kong shi jiu 不似颍川空使酒 贺兰山下阵如云 +bu tuo suo yi wo yue ming 不脱蓑衣卧月明 +bu wei fu yun zhe wang yan 不畏浮云遮望眼 只缘身在最高层 +bu wen cang sheng wen gui shen 不问苍生问鬼神 +bu wen hao ling 不闻号令 但闻人马之行声 +bu wen ji zhu sheng 不闻机杼声 惟闻女叹息 +bu wen ye niang huan nv sheng 不闻爷娘唤女声 但闻黄河流水鸣溅溅 +bu xin qie chang duan 不信妾肠断 归来看取明镜前 +bu xing duo de hu ma qi 步行夺得胡马骑 射杀山中白额虎 +bu xue fei zi ran 不学非自然 万事须己运 +bu yan er hua 不言而化 何必劳神苦思 +bu yao ren kua hao yan se 不要人夸好颜色 只留清气满乾坤 +bu yi ji bei 不以己悲 居庙堂之高则忧其民 +bu yi ji ye 不以疾也 春冬之时 +bu yi jun zi hu 不亦君子乎 +bu yi le hu 不亦乐乎 人不知而不愠 +bu yi ling jing nan wen jian 不疑灵境难闻见 尘心未尽思乡县 +bu yi pian si 不宜偏私 使内外异法也 +bu yi qian li cheng ye 不以千里称也 马之千里者 +bu yi wang zi fei bo 不宜妄自菲薄 引喻失义 +bu yi wu xi 不以物喜 不以己悲 +bu yi yi tong 不宜异同 若有作奸犯科及为忠善者 +bu yi yuan hu 不亦远乎 +bu yi yue hu 不亦说乎 有朋自远方来 +bu yi zhi nu 布衣之怒 亦免冠徒跣 +bu yi zhong hu 不亦重乎 死而后已 +bu ying you hen 不应有恨 何事长向别时圆 +bu yu pei lou wei lei 不与培塿为类 悠悠乎与颢气俱 +bu yu qin sai tong ren yan 不与秦塞通人烟 西当太白有鸟道 +bu zhi dong fang zhi ji bai 不知东方之既白 +bu zhi he chu chui lu guan 不知何处吹芦管 一夜征人尽望乡 +bu zhi kou ti zhi feng bu ruo ren ye 不知口体之奉不若人也 盖余之勤且艰若此 +bu zhi lao zhi jiang zhi 不知老之将至 及其所之既倦 +bu zhi mu lan shi nv lang 不知木兰是女郎 雄兔脚扑朔 +bu zhi ri zhi ru 不知日之入 苍然暮色 +bu zhi suo yan 不知所言 +bu zhi tian shang gong que 不知天上宫阙 今夕是何年 +bu zhi wei bu zhi 不知为不知 是知也 +bu zhi xi dong 不知西东 歌台暖响 +bu zhi xi ye shui cai chu 不知细叶谁裁出 二月春风似剪刀 +bu zhi xiang ji si 不知香积寺 数里入云峰 +bu zhi yi a shui 不知贻阿谁 出门东向看 +bu zhong sheng nan zhong sheng nv 不重生男重生女 骊宫高处入青云 +bu zhui qing yun zhi zhi 不坠青云之志 酌贪泉而觉爽 +bu zu wei wai ren dao ye 不足为外人道也 既出 +cai bai dong ying ju 采柏动盈掬 天寒翠袖薄 +cai che qu ming 彩彻区明 落霞与孤鹜齐飞 +cai de bai hua cheng mi hou 采得百花成蜜后 为谁辛苦为谁甜 +cai feng ji yuan dao 裁缝寄远道 几日到临洮 +cai ju dong li xia 采菊东篱下 悠然见南山 +cai kui chi zuo geng 采葵持作羹 羹饭一时熟 +cai le can sang you cha tian 才了蚕桑又插田 +cai mei bu wai jian 才美不外见 且欲与常马等不可得 +cai nv xi zao hu jia sheng 蔡女昔造胡笳声 一弹一十有八拍 +cai ru xing dian 裁如星点 又其上 +cai sang jing li feng ying 采桑径里逢迎 疑怪昨宵春梦好 +cai sang lv shui bian 采桑绿水边 素手青条上 +cai shi song chun gui 才始送春归 又送君归去 +can cong ji yu fu 蚕丛及鱼凫 开国何茫然 +can ji qie yu qu 蚕饥妾欲去 五马莫留连 +can xiao you de meng yi xi 残霄犹得梦依稀 玉珰缄札何由达 +can yang xi ru yan 残阳西入崦 茅屋访孤僧 +can yue xia zhang tai 残月下章台 芳草已云暮 +can yun gui tai hua 残云归太华 疏雨过中条 +can zhong guang ling shu 残钟广陵树 今朝此为别 +cang cang heng cui wei 苍苍横翠微 相携及田家 +cang cang zhu lin si 苍苍竹林寺 杳杳钟声晚 +cang hai yue ming zhu you lei 沧海月明珠有泪 蓝田日暖玉生烟 +cang jiang hao yan yue 沧江好烟月 门系钓鱼船 +cang mang gu mu lian qiong xiang 苍茫古木连穷巷 寥落寒山对虚牖 +cang mang yun hai jian 苍茫云海间 长风几万里 +cang ran mu se 苍然暮色 自远而至 +cang shan jia luan liu 苍山夹乱流 云中君不见 +cang yan bai fa 苍颜白发 颓然乎其间者 +cang ying ji yu dian shang 仓鹰击于殿上 此三子者 +cao cao bi yu yuan shao 曹操比于袁绍 则名微而众寡 +cao cao qie qie cuo za tan 嘈嘈切切错杂弹 大珠小珠落玉盘 +cao fu zhi er se bian 草拂之而色变 木遭之而叶脱 +cao mu wu qing 草木无情 有时飘零 +cao mu you ben xin 草木有本心 何求美人折 +cao pu heng ye liu qi li 草铺横野六七里 笛弄晚风三四声 +cao se ru lian qing 草色入帘青 谈笑有鸿儒 +cao se yao kan jin que wu 草色遥看近却无 最是一年春好处 +cao she zhi shen wen zhi 操蛇之神闻之 惧其不已也 +cao sheng dou miao xi 草盛豆苗稀 晨兴理荒秽 +cao zhang ying fei er yue tian 草长莺飞二月天 拂堤杨柳醉春烟 +ce jian shuang cui niao 侧见双翠鸟 巢在三珠树 +ce shen xi wang chang zi jie 侧身西望长咨嗟 +ce xun shi er zhuan 策勋十二转 赏赐百千强 +ce zhi bu yi qi dao 策之不以其道 食之不能尽其材 +ce zuo mei tai cao ying shen 侧坐莓苔草映身 路人借问遥招手 +cen ci lian qu mo 参差连曲陌 迢递送斜晖 +cen ci pi fu 参差披拂 潭中鱼可百许头 +cen ci xing cai 参差荇菜 左右流之 +ceng bu lin qing qu liu 曾不吝情去留 环堵萧然 +ceng bu neng hui shan zhi yi mao 曾不能毁山之一毛 其如土石何 +ceng bu neng sun kui fu zhi qiu 曾不能损魁父之丘 如太行 +ceng bu ruo shuang qi ruo zi 曾不若孀妻弱子 虽我之死 +ceng bu shi nong sang 曾不事农桑 吏禄三百石 +ceng ceng zhi shu 层层指数 楼愈高 +ceng lin jin ran 层林尽染 漫江碧透 +ceng luan song cui 层峦耸翠 上出重霄 +ceng shi ji liao jin jin an 曾是寂寥金烬暗 断无消息石榴红 +cha na ya yan 察纳雅言 深追先帝遗诏 +chai bo huang jin he fen dian 钗擘黄金合分钿 但教心似金钿坚 +chai lang zai yi long zai ye 豺狼在邑龙在野 王孙善保千金躯 +chai liu yi gu he yi shan 钗留一股合一扇 钗擘黄金合分钿 +chan fang hua mu shen 禅房花木深 山光悦鸟性 +chan wei zan zhu 襜帷暂驻 十旬休假 +chan xiu lu man zhi 蝉休露满枝 永怀当此节 +chan yu lie huo zhao lang shan 单于猎火照狼山 山川萧条极边土 +chan yu ye dun tao 单于夜遁逃 欲将轻骑逐 +chan yu yi zai jin shan xi 单于已在金山西 戍楼西望烟尘黑 +chan ze qian zhuan bu qiong 蝉则千转不穷 猿则百叫无绝 +chang an bu jian shi ren chou 长安不见使人愁 +chang an cheng lian dong ye yuan 长安城连东掖垣 凤凰池对青琐门 +chang an cheng tou tou bai wu 长安城头头白乌 夜飞延秋门上呼 +chang an hui wang xiu cheng dui 长安回望绣成堆 山顶千门次第开 +chang an shui bian duo li ren 长安水边多丽人 态浓意远淑且真 +chang an yi pian yue 长安一片月 万户捣衣声 +chang cun bao zhu xin 常存抱柱信 岂上望夫台 +chang de jun wang dai xiao kan 常得君王带笑看 解得春风无限恨 +chang dian ying feng zao 长簟迎风早 空城澹月华 +chang duan bai pin zhou 肠断白蘋洲 +chang duan wei ren sao 肠断未忍扫 眼穿仍欲归 +chang e ying hui tou ling yao 嫦娥应悔偷灵药 碧海青天夜夜心 +chang feng chui lin yu duo wa 长风吹林雨堕瓦 迸泉飒飒飞木末 +chang feng ji wan li 长风几万里 吹度玉门关 +chang feng po lang hui you shi 长风破浪会有时 直挂云帆济沧海 +chang feng wan li song qiu yan 长风万里送秋雁 对此可以酣高楼 +chang ge chu tian bi 长歌楚天碧 +chang ge huai cai wei 长歌怀采薇 +chang ge yin song feng 长歌吟松风 曲尽河星稀 +chang gui er xie zhi yue 长跪而谢之曰 先生坐 +chang he jian luo xiao xing chen 长河渐落晓星沉 嫦娥应悔偷灵药 +chang he lang tou lian tian hei 长河浪头连天黑 津口停舟渡不得 +chang he luo ri yuan 长河落日圆 萧关逢候骑 +chang ji xi ting ri mu 常记溪亭日暮 沉醉不知归路 +chang jiang du zhi jin 长江独至今 +chang kong qiu jie zhi 常恐秋节至 焜黄华叶衰 +chang qiao wo bo 长桥卧波 未云何龙 +chang qu bai li wai 尝趋百里外 从乡之先达执经叩问 +chang sha zhe qu gu jin lian 长沙谪去古今怜 +chang shi ying xiong lei man jin 长使英雄泪满襟 +chang ting jiu yi piao 长亭酒一瓢 残云归太华 +chang wang qian qiu yi sa lei 怅望千秋一洒泪 萧条异代不同时 +chang wo xin chun bai jia yi 怅卧新春白袷衣 白门寥落意多违 +chang yan luo ri gu cheng bi 长烟落日孤城闭 浊酒一杯家万里 +chang ye zhan shi he you che 长夜沾湿何由彻 安得广厦千万间 +chang yi su sha er wei xin 常以肃杀而为心 天之于物 +chang yi yu he zhou yi 尝贻余核舟一 盖大苏泛赤壁云 +chang you gao yuan chang xiao 常有高猿长啸 属引凄异 +chang zhu wen zhang zi yu 常著文章自娱 颇示己志 +chao ba xu cai wu se zhao 朝罢须裁五色诏 佩声归到凤池头 +chao ji er xi she ban yan 朝济而夕设版焉 君之所知也 +chao luo ye jiang xie yue li 潮落夜江斜月里 两三星火是瓜州 +chao ping liang an kuo 潮平两岸阔 风正一帆悬 +chao zai san zhu shu 巢在三珠树 矫矫珍木巅 +che ma qu xian xian 车马去闲闲 流水如有意 +che ma sai tu 车马塞途 饮食百物皆倍穹常时 +che shi xi men zhu xian jie 车师西门伫献捷 +che zou lei sheng yu wei tong 车走雷声语未通 曾是寂寥金烬暗 +chen ai bu jian xian yang qiao 尘埃不见咸阳桥 牵衣顿足拦道哭 +chen ben bu yi 臣本布衣 躬耕于南阳 +chen bu sheng shou en gan ji 臣不胜受恩感激 今当远离 +chen hou li shen he tan dang 陈侯立身何坦荡 虬须虎眉仍大颡 +chen jiang xing zi rao chen shan 郴江幸自绕郴山 为谁流下潇湘去 +chen lin jing yue 沉鳞竞跃 实是欲界之仙都 +chen qi dong zheng duo 晨起动征铎 客行悲故乡 +chen qi mang mang ran 尘气莽莽然 城市依稀而已 +chen sui xia yu 臣虽下愚 知其不可 +chen tai qiu yu you qi xing 陈太丘与友期行 期日中 +chen wang xi shi yan ping le 陈王昔时宴平乐 斗酒十千恣欢谑 +chen wei chang wen ye 臣未尝闻也 秦王曰 +chen xin wei jin si xiang xian 尘心未尽思乡县 出洞无论隔山水 +chen xing li huang hui 晨兴理荒秽 带月荷锄归 +chen yao yu pei qu jin dian 晨摇玉佩趋金殿 夕奉天书拜琐闱 +chen yi wang li zhi gong song ye 臣以王吏之攻宋也 为与此同类 +chen yin fang bo cha xian zhong 沉吟放拨插弦中 整顿衣裳起敛容 +chen zhi zhuang ye 臣之壮也 犹不如人 +chen zhou ce pan qian fan guo 沉舟侧畔千帆过 病树前头万木春 +chen zui bu zhi gui lu 沉醉不知归路 兴尽晚回舟 +cheng cheng bian jin gu 澄澄变今古 美人清江畔 +cheng chun cao mu shen 城春草木深 感时花溅泪 +cheng en bu zai mao 承恩不在貌 教妾若为容 +cheng fei bu gao ye 城非不高也 池非不深也 +cheng huan shi yan wu xian xia 承欢侍宴无闲暇 春从春游夜专夜 +cheng ji si han 成吉思汗 只识弯弓射大雕 +cheng jiang yi dao yue fen ming 澄江一道月分明 朱弦已为佳人绝 +cheng ming yi jiu rong 称名忆旧容 别来沧海事 +cheng neng jian ke yu 诚能见可欲 则思知足以自戒 +cheng que fu san qin 城阙辅三秦 风烟望五津 +cheng shan bei zhi sai 惩山北之塞 出入之迂也 +cheng shang gao lou jie da huang 城上高楼接大荒 海天愁思正茫茫 +cheng shi yi xi er yi 城市依稀而已 既而风定天清 +cheng tian jing ming 承天景命 莫不殷忧而道著 +cheng xia mei quan shi 城下没全师 蕃汉断消息 +cheng xiang ci tang he chu xun 丞相祠堂何处寻 锦官城外柏森森 +cheng xing nan you bu jie yan 乘兴南游不戒严 九重谁省谏书函 +cheng yi kai zhang sheng ting 诚宜开张圣听 以光先帝遗德 +cheng zhong zeng mu han 城中增暮寒 +chi chi zhong gu chu chang ye 迟迟钟鼓初长夜 耿耿星河欲曙天 +chi ci xie gao niao 持此谢高鸟 因之传远情 +chi cun qian li 尺寸千里 攒蹙累积 +chi er liao que gong jia shi 痴儿了却公家事 快阁东西倚晚晴 +chi fei bu shen ye 池非不深也 兵革非不坚利也 +chi huang bu gan gu 池潢不敢顾 侧见双翠鸟 +chi ji bai gou du li li 赤鸡白狗赌梨栗 弹剑作歌奏苦声 +chi jie yun zhong 持节云中 何日遣冯唐 +chi ling yue jia ming wu jun 耻令越甲鸣吾军 莫嫌旧日云中守 +chi mei xi ren guo 魑魅喜人过 应共冤魂语 +chi ri jiang shan li 迟日江山丽 春风花草香 +chi shang bi tai san si dian 池上碧苔三四点 叶底黄鹂一两声 +chong gu chi zuo fan 舂谷持作饭 采葵持作羹 +chong ru xie wang 宠辱偕忘 把酒临风 +chong sheng xin tou lv chuang sha 虫声新透绿窗纱 +chong yan die zhang 重岩叠嶂 隐天蔽日 +chong zhen wu nian shi er yue 崇祯五年十二月 余住西湖 +chou chang nan chao shi 惆怅南朝事 长江独至今 +chou dao duan shui shui geng liu 抽刀断水水更流 举杯消愁愁更愁 +chou yun can dan wan li ning 愁云惨淡万里凝 中军置酒饮归客 +chu bu zhi hu ji qian wan luo 矗不知乎几千万落 长桥卧波 +chu chu ban chou yan 处处伴愁颜 +chu chu wen ti niao 处处闻啼鸟 夜来风雨声 +chu chu zhi zhi 处处志之 及郡下 +chu dong wu lun ge shan shui 出洞无论隔山水 辞家终拟长游衍 +chu er yuan shi 俶尔远逝 往来翕忽 +chu guo xiang fu jiang 出郭相扶将 阿姊闻妹来 +chu he ri dang wu 锄禾日当午 汗滴禾下土 +chu he zhe yi you huan 处涸辙以犹欢 北海虽赊 +chu jiang hu zhi yuan ze you qi jun 处江湖之远则忧其君 是进亦忧 +chu jiang wei yu li 楚江微雨里 建业暮钟时 +chu ke xiang si yi miao ran 楚客相思益渺然 汉口夕阳斜渡鸟 +chu men dong xiang kan 出门东向看 泪落沾我衣 +chu men kan huo ban 出门看火伴 火伴皆惊忙 +chu men sao bai shou 出门搔白首 若负平生志 +chu mo feng bo li 出没风波里 +chu mo yu jing bo wan ren zhong 出没于鲸波万仞中 腾身百变 +chu ping shan ren 初平山人 其色丹 +chu ren yi ju 楚人一炬 可怜焦土 +chu ren you ye 滁人游也 临溪而渔 +chu ri ying men shan 初日郢门山 江上几人在 +chu ri zhao gao lin 初日照高林 曲径通幽处 +chu ru gao xia qiong yan fei 出入高下穷烟霏 山红涧碧纷烂漫 +chu ru wu wan qun 出入无完裙 老妪力虽衰 +chu ru zhi yu ye 出入之迂也 聚室而谋曰 +chu sai san xiang jie 楚塞三湘接 荆门九派通 +chu shan quan shui zhuo 出山泉水浊 侍婢卖珠回 +chu shi wei jie shen xian si 出师未捷身先死 长使英雄泪满襟 +chu tan lu shui hou chu fei 初弹渌水后楚妃 一声已动物皆静 +chu tian qian li qing qiu 楚天千里清秋 水随天去秋无际 +chu wang wen qi gu 楚王问其故 子墨子曰 +chu wei ni chang hou liu yao 初为霓裳后六幺 大弦嘈嘈如急雨 +chu wen ti lei man yi shang 初闻涕泪满衣裳 却看妻子愁何在 +chu xi li yi xiao sa 初淅沥以萧飒 忽奔腾而砰湃 +chu yin bi di qu ren jian 初因避地去人间 及至成仙遂不还 +chu ze wu di guo wai huan zhe 出则无敌国外患者 国恒亡 +chu zhi jing ying 楚之精英 几世几年 +chuan bu xi hu 传不习乎 +chuan jin hong si ji wan tiao 穿尽红丝几万条 +chuan tou zuo san ren 船头坐三人 中峨冠而多髯者为东坡 +chuan wei jing qi bo 川为静其波 鸟亦罢其鸣 +chuan wen zhi ci hui 传闻至此回 我行殊未已 +chuan yi xiang xiu cai guan zhi 传一乡秀才观之 自是指物作诗立就 +chuan ze yu qi hai zhu 川泽纡其骇瞩 闾阎扑地 +chuang fei jie dong kai 窗扉皆洞开 一行有五点明处 +chuang han xi ling qian qiu xue 窗含西岭千秋雪 门泊东吴万里船 +chuang jin fa gu xia yu guan 摐金伐鼓下榆关 旌旆逶迤碣石间 +chuang qian ming yue guang 床前明月光 疑是地上霜 +chuang tou wu lou wu gan chu 床头屋漏无干处 雨脚如麻未断绝 +chui du yu men guan 吹度玉门关 汉下白登道 +chui shang le na jia 吹伤了那家 只吹的水尽鹅飞罢 +chui tou er shui 垂头而睡 但闻四壁虫声唧唧 +chun can dao si si fang jin 春蚕到死丝方尽 蜡炬成灰泪始干 +chun cao bi xian men 春草闭闲门 过雨看松色 +chun cao ming nian lv 春草明年绿 王孙归不归 +chun chao dai yu wan lai ji 春潮带雨晚来急 野渡无人舟自横 +chun cheng wu chu bu fei hua 春城无处不飞花 寒食东风御柳斜 +chun chou an an du cheng mian 春愁黯黯独成眠 身多疾病思田里 +chun cong chun you ye zhuan ye 春从春游夜专夜 后宫佳丽三千人 +chun dong zhi shi 春冬之时 则素湍绿潭 +chun feng bu du yu men guan 春风不度玉门关 +chun feng bu xiang shi 春风不相识 何事入罗帏 +chun feng chui you sheng 春风吹又生 +chun feng fu kan lu hua nong 春风拂槛露华浓 若非群玉山头见 +chun feng hua cao xiang 春风花草香 泥融飞燕子 +chun feng ju guo cai gong jin 春风举国裁宫锦 半作障泥半作帆 +chun feng song nuan ru tu su 春风送暖入屠苏 千门万户曈曈日 +chun feng tao li hua kai ri 春风桃李花开日 秋雨梧桐叶落时 +chun feng you lv jiang nan an 春风又绿江南岸 明月何时照我还 +chun guang lan kun yi wei feng 春光懒困倚微风 桃花一簇开无主 +chun guang rong rong 春光融融 舞殿冷袖 +chun gui zai ke xian 春归在客先 岭猿同旦暮 +chun han ci yu hua qing chi 春寒赐浴华清池 温泉水滑洗凝脂 +chun hua qiu yue he shi liao 春花秋月何时了 往事知多少 +chun jiang hua zhao qiu yue ye 春江花朝秋月夜 往往取酒还独倾 +chun jiang shui nuan ya xian zhi 春江水暖鸭先知 蒌蒿满地芦芽短 +chun jiao kou zao hu bu de 唇焦口燥呼不得 归来倚杖自叹息 +chun lai bian shi tao hua shui 春来遍是桃花水 不辨仙源何处寻 +chun lai fa ji zhi 春来发几枝 愿君多采撷 +chun lai jiang shui lv ru lan 春来江水绿如蓝 能不忆江南 +chun mian bu jue xiao 春眠不觉晓 处处闻啼鸟 +chun qu hua hai zai 春去花还在 人来鸟不惊 +chun ri ning zhuang shang cui lou 春日凝妆上翠楼 忽见陌头杨柳色 +chun se man yuan guan bu zhu 春色满园关不住 一枝红杏出墙来 +chun se wei ceng kan 春色未曾看 晓战随金鼓 +chun sheng qiu shi 春生秋实 故其在乐也 +chun xiao ku duan ri gao qi 春宵苦短日高起 从此君王不早朝 +chun xin mo gong hua zheng fa 春心莫共花争发 一寸相思一寸灰 +chun zhong yi li su 春种一粒粟 秋收万颗子 +ci chen suo yi bao xian di er zhong bi xia zhi zhi fen ye 此臣所以报先帝而忠陛下之职分也 至于斟酌损益 +ci cheng bu ke yu zheng feng 此诚不可与争锋 孙权据有江东 +ci cheng wei ji cun wang zhi qiu ye 此诚危急存亡之秋也 然侍卫之臣不懈于内 +ci dai tian suo yi zi jiang jun 此殆天所以资将军 将军岂有意乎 +ci dao xi gui shun 此道昔归顺 西郊胡正繁 +ci di kong yu huang he lou 此地空余黄鹤楼 黄鹤一去不复返 +ci di yi wei bie 此地一为别 孤蓬万里征 +ci di you chong shan jun ling 此地有崇山峻岭 茂林修竹 +ci fei cao meng de zhi shi hu 此非曹孟德之诗乎 西望夏口 +ci fei meng de zhi kun yu zhou lang zhe hu 此非孟德之困于周郎者乎 方其破荆州 +ci gen san zuo jiu qiu peng 辞根散作九秋蓬 共看明月应垂泪 +ci he sheng ye 此何声也 汝出视之 +ci hen mian mian wu jue qi 此恨绵绵无绝期 +ci hou han suo yi qing tui ye 此后汉所以倾颓也 先帝在时 +ci jia zhong ni chang you yan 辞家终拟长游衍 自谓经过旧不迷 +ci jie liang shi 此皆良实 志虑忠纯 +ci ke ai shi qie wei huan 词客哀时且未还 庾信平生最萧瑟 +ci ke yi wei yuan er bu ke tu ye 此可以为援而不可图也 荆州北据汉 +ci le he ji 此乐何极 登斯楼也 +ci lou xia dian 辞楼下殿 辇来于秦 +ci ming da guo guo yu qin 赐名大国虢与秦 紫驼之峰出翠釜 +ci mu qi wu yin 此木岂无阴 +ci mu shou zhong xian 慈母手中线 游子身上衣 +ci qing ke dai cheng zhui yi 此情可待成追忆 只是当时已惘然 +ci qiu sheng ye 此秋声也 胡为而来哉 +ci qu jing nian 此去经年 应是良辰好景虚设 +ci qu sui suo ou 此去随所偶 晚风吹行舟 +ci qu you yi wu ren chuan 此曲有意无人传 愿随春风寄燕然 +ci qu zhi ying tian shang you 此曲只应天上有 人间能得几回闻 +ci ren ke jiu jian 此人可就见 不可屈致也 +ci ren yi yi wei ju yan suo wen 此人一一为具言所闻 皆叹惋 +ci ri deng lin shu se kai 此日登临曙色开 三晋云山皆北向 +ci san zi zhe 此三子者 皆布衣之士也 +ci shi gu nan quan 此事古难全 但愿人长久 +ci shi wu sheng sheng you sheng 此时无声胜有声 银瓶乍破水浆迸 +ci tu yan mi li 雌兔眼迷离 双兔傍地走 +ci wei he ruo ren 此为何若人 王曰 +ci wu zui xiang si 此物最相思 +ci xi zhen liang si jie zhi chen 此悉贞良死节之臣 愿陛下亲之 +ci xian han suo yi xing long ye 此先汉所以兴隆也 亲小人 +ci xin wu yu bai ou meng 此心吾与白鸥盟 +ci ye qu zhong wen zhe liu 此夜曲中闻折柳 何人不起故园情 +ci yong fu zhi nu ye 此庸夫之怒也 非士之怒也 +ci yong wu zhi guo 此用武之国 而其主不能守 +ci you jin xiu zhi yu duan he ye 此犹锦绣之与短褐也 臣以王吏之攻宋也 +ci you liang rou zhi yu kang zao ye 此犹粱肉之与糠糟也 荆有长松文梓楩楠豫章 +ci you wen xuan zhi yu bi yu ye 此犹文轩之与敝舆也 荆有云梦 +ci ze yue yang lou zhi da guan ye 此则岳阳楼之大观也 前人之述备矣 +ci zhi wei shi qi ben xin 此之谓失其本心 +ci zhong ren yu yun 此中人语云 不足为外人道也 +ci zhong you shi liang xin zhi 词中有誓两心知 七月七日长生殿 +ci zhong you zhen yi 此中有真意 欲辨已忘言 +cong ci jun wang bu zao chao 从此君王不早朝 承欢侍宴无闲暇 +cong ci ti ye zheng 从此替爷征 东市买骏马 +cong cong zheng zheng 鏦鏦铮铮 金铁皆鸣 +cong jin ruo xu xian cheng yue 从今若许闲乘月 拄杖无时夜叩门 +cong jin you ji nian 从今又几年 +cong liu piao dang 从流飘荡 任意东西 +cong xian ren huan jia 从先人还家 于舅家见之 +cong xiang zhi xian da zhi jing kou wen 从乡之先达执经叩问 先达德隆望尊 +cong xiao qiu xi xing bai er shi bu 从小丘西行百二十步 隔篁竹 +cu er er yu zhi 蹴尔而与之 乞人不屑也 +cu jin kong que yin qi lin 蹙金孔雀银麒麟 头上何所有 +cuan liang hong yu hai qu 窜梁鸿于海曲 岂乏明时 +cuan zhu man huang xing bu si 窜逐蛮荒幸不死 衣食才足甘长终 +cui hua yao yao xing fu zhi 翠华摇摇行复止 西出都门百余里 +cui jiu tang qian ji du wen 崔九堂前几度闻 正是江南好风景 +cui qiao jin que yu sao tou 翠翘金雀玉搔头 君王掩面救不得 +cui shi er xiao sheng 崔氏二小生 曰恕己 +cui wei he ye chui bin chun 翠微盍叶垂鬓唇 背后何所见 +cui wei zhi gan jiao yuan gu 崔嵬枝干郊原古 窈窕丹青户牖空 +cun nan cun bei xiang sao che 村南村北响缲车 牛衣古柳卖黄瓜 +cun zhe qie tou sheng 存者且偷生 死者长已矣 +cun zhong wen you ci ren 村中闻有此人 咸来问讯 +cun zhuang er nv ge dang jia 村庄儿女各当家 童孙未解供耕织 +da bi tian xia han shi ju huan yan 大庇天下寒士俱欢颜 风雨不动安如山 +da dao ru qing tian 大道如青天 我独不得出 +da dao zhi xing ye 大道之行也 天下为公 +da er chu dou xi dong 大儿锄豆溪东 中儿正织鸡笼 +da he shang xia 大河上下 顿失滔滔 +da huang shen shen fei xue bai 大荒沈沈飞雪白 先拂商弦后角羽 +da jiang dong qu 大江东去 浪淘尽 +da mo gu yan zhi 大漠孤烟直 长河落日圆 +da mo qiong qiu sai cao fei 大漠穷秋塞草腓 孤城落日斗兵稀 +da qi huang ying er 打起黄莺儿 莫教枝上啼 +da ren zhi ming 达人知命 老当益壮 +da sha ru qing yao liang dong 大厦如倾要梁栋 万牛回首丘山重 +da sheng ru lei ting 大声如雷霆 震撼激射 +da wang chang wen bu yi zhi nu hu 大王尝闻布衣之怒乎 秦王曰 +da wang jia hui 大王加惠 以大易小 +da xian cao cao ru ji yu 大弦嘈嘈如急雨 小弦切切如私语 +da xiong he jian shi zhi wan hu 大兄何见事之晚乎 肃遂拜蒙母 +da xue man gong dao 大雪满弓刀 +da xue san ri 大雪三日 湖中人鸟声俱绝 +da xue shen shu chi 大雪深数尺 足肤皲裂而不知 +da yu han yin 达于汉阴 可乎 +da zhu xiao zhu luo yu pan 大珠小珠落玉盘 间关莺语花底滑 +dai dao chong yang ri 待到重阳日 还来就菊花 +dai dao shan hua lan man shi 待到山花烂漫时 她在丛中笑 +dai jun jiu bu zhi 待君久不至 已去 +dai se can tian er qian chi 黛色参天二千尺 君臣已与时际会 +dai xia si zhi 代下司职 役聪明之耳目 +dai xiao tang qian bai jiu gu 待晓堂前拜舅姑 妆罢低声问夫婿 +dai yue he chu gui 带月荷锄归 道狭草木长 +dai zhu ying bao shi zhi mao 戴朱缨宝饰之帽 腰白玉之环 +dai zong fu ru he 岱宗夫如何 齐鲁青未了 +dan ai lu yu mei 但爱鲈鱼美 君看一叶舟 +dan bei bu jian jiu zhou tong 但悲不见九州同 王师北定中原日 +dan che yu wen bian 单车欲问边 属国过居延 +dan ci huang he qu 旦辞黄河去 暮至黑山头 +dan ci ye niang qu 旦辞爷娘去 暮宿黄河边 +dan dang she lie 但当涉猎 见往事耳 +dan dao kun ku qi wei nu 但道困苦乞为奴 已经百日窜荆棘 +dan jian bei niao hao gu mu 但见悲鸟号古木 雄飞雌从绕林间 +dan jian qun ou ri ri lai 但见群鸥日日来 花径不曾缘客扫 +dan jian xin ren xiao 但见新人笑 那闻旧人哭 +dan jiao xin si jin dian jian 但教心似金钿坚 天上人间会相见 +dan piao lv kong 箪瓢屡空 晏如也 +dan ran kong shui dui xie hui 澹然空水对斜晖 曲岛苍茫接翠微 +dan sao e mei chao zhi zun 淡扫蛾眉朝至尊 +dan shao xian ren ru wu liang ren zhe er 但少闲人如吾两人者耳 +dan shi long cheng fei jiang zai 但使龙城飞将在 不教胡马度阴山 +dan shi yuan wu wei 但使愿无违 +dan wen huang he liu shui ming jian jian 但闻黄河流水鸣溅溅 旦辞黄河去 +dan wen ren ma zhi xing sheng 但闻人马之行声 予谓童子 +dan wen ren yu xiang 但闻人语响 返景入深林 +dan wen si bi chong sheng ji ji 但闻四壁虫声唧唧 如助予之叹息 +dan wen yan shan hu qi ming jiu jiu 但闻燕山胡骑鸣啾啾 万里赴戎机 +dan xi xia ri chang 但惜夏日长 复有贫妇人 +dan yu zhong qing yin 但余钟磬音 +dan yuan chang zui bu fu xing 但愿长醉不复醒 古来圣贤皆寂寞 +dan yuan ren chang jiu 但愿人长久 千里共婵娟 +dan zhuang nong mo zong xiang yi 淡妆浓抹总相宜 +dang chuang li yun bin 当窗理云鬓 对镜帖花黄 +dang chun nai fa sheng 当春乃发生 随风潜入夜 +dang hu li hong zhuang 当户理红妆 小弟闻姊来 +dang jiang lv san jun 当奖率三军 北定中原 +dang jun huai gui ri 当君怀归日 是妾断肠时 +dang liu chi zu ta jian shi 当流赤足踏涧石 水声激激风吹衣 +dang lu shui xiang jia 当路谁相假 知音世所稀 +dang qi xin yu suo yu 当其欣于所遇 暂得于己 +dang shi she chi jin he chu 当时奢侈今何处 只见草萧疏 +dang shi zhi ji ru shan shen 当时只记入山深 青溪几度到云林 +dang xiong sheng zeng yun 荡胸生曾云 决眦入归鸟 +dang xuan xia ma ru jin yin 当轩下马入锦茵 杨花雪落覆白蘋 +dang yu meng shi tong 当与梦时同 +dang yu zhi cong shi ye 当余之从师也 负箧曳屣 +dao bang guo zhe wen xing ren 道傍过者问行人 行人但云点行频 +dao ci chou chu bu neng qu 到此踌躇不能去 马嵬坡下泥土中 +dao feng xiang li ren 道逢乡里人 家中有阿谁 +dao hua xiang li shuo feng nian 稻花香里说丰年 听取蛙声一片 +dao ke er si 道渴而死 弃其杖 +dao pei li dui 叨陪鲤对 今兹捧袂 +dao qie luan zei er bu zuo 盗窃乱贼而不作 故外户而不闭 +dao xia cao mu zhang 道狭草木长 夕露沾我衣 +dao xiang fan si lan ke ren 到乡翻似烂柯人 沉舟侧畔千帆过 +dao ze pi cao er zuo 到则披草而坐 倾壶而醉 +dao zhong liu ji shui 到中流击水 浪遏飞舟 +dao zu qie chang 道阻且长 溯游从之 +dao zu qie ji 道阻且跻 溯游从之 +dao zu qie you 道阻且右 溯游从之 +de bu chu qi hou 德不处其厚 情不胜其欲 +de bu hou er si guo zhi an 德不厚而思国之安 臣虽下愚 +de dao zhe duo zhu 得道者多助 失道者寡助 +de wei zhong ren er yi ye 得为众人而已耶 +de wu jin wan ju 得无金丸惧 美服患人指 +de wu yi hu 得无异乎 若夫霪雨霏霏 +de xian ren zhi jiu guan 得仙人之旧馆 层峦耸翠 +de xiang neng kai guo 得相能开国 生儿不象贤 +de zhi xin er yu zhi jiu ye 得之心而寓之酒也 若夫日出而林霏开 +de zhi ze sheng 得之则生 弗得则死 +deng gao zuo fu 登高作赋 是所望于群公 +deng lin wu shu heng fen di 登临吴蜀横分地 徙倚湖山欲暮时 +deng si lou ye 登斯楼也 则有去国怀乡 +deng xian shi de dong feng mian 等闲识得东风面 万紫千红总是春 +deng zhong jun zhi ruo guan 等终军之弱冠 有怀投笔 +deng zhou wang qiu yue 登舟望秋月 空忆谢将军 +di beng shan cui zhuang shi si 地崩山摧壮士死 然后天梯石栈相钩连 +di gan qi cheng 帝感其诚 命夸娥氏二子负二山 +di jie heng lu 地接衡庐 襟三江而带五湖 +di kong you duan yun 低空有断云 对棋陪谢傅 +di li bu ru ren he 地利不如人和 三里之城 +di mei xin shou xu xu tan 低眉信手续续弹 说尽心中无限事 +di nong wan feng san si sheng 笛弄晚风三四声 归来饱饭黄昏后 +di shi ji er nan ming shen 地势极而南溟深 天柱高而北辰远 +di tou si gu xiang 低头思故乡 +di tou xiang an bi 低头向暗壁 千唤不一回 +di xia dang gou qing chao ban 涤瑕荡垢清朝班 州家申名使家抑 +di xia ruo feng chen hou zhu 地下若逢陈后主 岂宜重问后庭花 +di xiang ming ri dao 帝乡明日到 犹自梦渔樵 +di xiong ji lv ge xi dong 弟兄羁旅各西东 田园寥落干戈后 +di you zou shi yi 地犹鄹氏邑 宅即鲁王宫 +di zhong wen zhe liu 笛中闻折柳 春色未曾看 +di zou cong jun a yi si 弟走从军阿姨死 暮去朝来颜色故 +dian dian di di 点点滴滴 这次第 +dian he jin chai ji jiang qu 钿合金钗寄将去 钗留一股合一扇 +dian qian shi de lu hua xin 殿前拾得露华新 至今不会天中事 +dian tou yin bi ji jie sui 钿头银篦击节碎 血色罗裙翻酒污 +diao lan xiang wang yan 雕栏相望焉 闭之 +diao lan yu qi ying you zai 雕栏玉砌应犹在 只是朱颜改 +diao ying fen wei qian li yan 吊影分为千里雁 辞根散作九秋蓬 +ding cheng yu shi 鼎铛玉石 金块珠砾 +ding shen xi shi 定神细视 以丛草为林 +ding tou lin lin 钉头磷磷 多于在庾之粟粒 +ding zhuang zai nan gang 丁壮在南冈 足蒸暑土气 +dong chuan xi fang qiao wu yan 东船西舫悄无言 唯见江心秋月白 +dong fang zuo ye ting hong zhu 洞房昨夜停红烛 待晓堂前拜舅姑 +dong feng bu yu zhou lang bian 东风不与周郎便 铜雀春深锁二乔 +dong feng shi zuo 冻风时作 作则飞沙走砾 +dong feng wu li bai hua can 东风无力百花残 春蚕到死丝方尽 +dong gao bo mu wang 东皋薄暮望 徙倚欲何依 +dong lai tuo tuo man jiu du 东来橐驼满旧都 朔方健儿好身手 +dong li ba jiu huang hun hou 东篱把酒黄昏后 有暗香盈袖 +dong lian wu hui 东连吴会 西通巴 +dong lin jie shi 东临碣石 以观沧海 +dong men gao ge ai yu hui 洞门高阁霭馀辉 桃李阴阴柳絮飞 +dong men gu jiu yin wo cao 东门酤酒饮我曹 心轻万事皆鸿毛 +dong mian er shi 东面而视 不见水端 +dong po xian you zu 东坡现右足 鲁直现左足 +dong po you shou zhi juan duan 东坡右手执卷端 左手抚鲁直背 +dong shi mai jun ma 东市买骏马 西市买鞍鞯 +dong tian shi fei 洞天石扉 訇然中开 +dong ting lian tian jiu yi gao 洞庭连天九疑高 蛟龙出没猩鼯号 +dong ting qiu shui yuan lian tian 洞庭秋水远连天 孤城背岭寒吹角 +dong ting zhi dong jiang shui xi 洞庭之东江水西 帘旌不动夕阳迟 +dong wang du men xin ma gui 东望都门信马归 归来池苑皆依旧 +dong wang wu chang 东望武昌 山川相缪 +dong yu yi shi 东隅已逝 桑榆非晚 +dong zai qing xi he chu bian 洞在清溪何处边 +dou bian zuo le tu 都变做了土 输 +dou jiu shi qian zi huan xue 斗酒十千恣欢谑 主人何为言少钱 +dou zai fu zhong qi 豆在釜中泣 本自同根生 +dou zhe she xing 斗折蛇行 明灭可见 +du chuang ran er ti xia 独怆然而涕下 +du diao han jiang xue 独钓寒江雪 +du du yan gong zhi ya wang 都督阎公之雅望 棨戟遥临 +du fu zhi xin 独夫之心 日益骄固 +du hu tie yi leng nan zhe 都护铁衣冷难着 瀚海阑干百丈冰 +du hu zai yan ran 都护在燕然 +du juan sheng li xie yang mu 杜鹃声里斜阳暮 驿寄梅花 +du juan ti xue yuan ai ming 杜鹃啼血猿哀鸣 春江花朝秋月夜 +du li han qiu 独立寒秋 湘江北去 +du li yang xin ling 独立扬新令 千营共一呼 +du lian you cao jian bian sheng 独怜幽草涧边生 上有黄鹂深树鸣 +du liu qing zhong xiang huang hun 独留青冢向黄昏 画图省识春风面 +du men zhang yin wu xu 都门帐饮无绪 留恋处 +du qiao chu ye qing 独敲初夜磬 闲倚一枝藤 +du shang gao lou 独上高楼 望尽天涯路 +du sheng ma ti xia 赌胜马蹄下 由来轻七尺 +du shu lin jiang ye bo chuan 独戍临江夜泊船 贾谊上书忧汉室 +du su jiang cheng la ju can 独宿江城蜡炬残 永夜角声悲自语 +du tou yu luo ri 渡头馀落日 墟里上孤烟 +du wang hu xin ting kan xue 独往湖心亭看雪 雾凇沆砀 +du yi wang jiang lou 独倚望江楼 过尽千帆皆不是 +du you feng huang chi shang ke 独有凤凰池上客 阳春一曲和皆难 +du yu lao weng bie 独与老翁别 +du yuan jing men wai 渡远荆门外 来从楚国游 +du zai yi xiang wei yi ke 独在异乡为异客 每逢佳节倍思亲 +du zhuo wu xiang qin 独酌无相亲 举杯邀明月 +du zi zen sheng de hei 独自怎生得黑 梧桐更兼细雨 +du zuo you huang li 独坐幽篁里 弹琴复长啸 +duan chang ren zai tian ya 断肠人在天涯 +duan he chuan jie 短褐穿结 箪瓢屡空 +duan hong sheng li 断鸿声里 江南游子 +duan ju chi sheng ming 端居耻圣明 坐观垂钓者 +duan jue hu er lian mu sheng 断绝胡儿恋母声 川为静其波 +duan qiao tou mai yu ren san 断桥头卖鱼人散 +duan wu xiao xi shi liu hong 断无消息石榴红 斑骓只系垂杨岸 +duan yan jing chou mian 断雁警愁眠 远梦归侵晓 +dui chang ting wan 对长亭晚 骤雨初歇 +dui ci ke yi han gao lou 对此可以酣高楼 蓬莱文章建安骨 +dui ci ru he bu lei chui 对此如何不泪垂 春风桃李花开日 +dui ci yu dao dong nan qing 对此欲倒东南倾 我欲因之梦吴越 +dui jing tie hua huang 对镜帖花黄 出门看火伴 +dui qi pei xie fu 对棋陪谢傅 把剑觅徐君 +dui ying cheng san ren 对影成三人 月既不解饮 +dui zi ma fu 对子骂父 则是无礼 +dun shi tao tao 顿失滔滔 山舞银蛇 +duo duo hua kai dan mo hen 朵朵花开淡墨痕 不要人夸好颜色 +duo hui yu ci 多会于此 览物之情 +duo nan shi jun chi 多难识君迟 掩泪空相向 +duo qing que si zong wu qing 多情却似总无情 唯觉樽前笑不成 +duo qing ying xiao wo 多情应笑我 早生华发 +duo qing zhi you chun ting yue 多情只有春庭月 犹为离人照落花 +duo qing zi gu shang li bie 多情自古伤离别 更那堪 +duo shao lou tai yan yu zhong 多少楼台烟雨中 +duo yu ji shang zhi gong nv 多于机上之工女 钉头磷磷 +duo yu jiu tu zhi cheng guo 多于九土之城郭 管弦呕哑 +duo yu nan mu zhi nong fu 多于南亩之农夫 架梁之椽 +duo yu shi ren zhi yan yu 多于市人之言语 使天下之人 +duo yu zai yu zhi su li 多于在庾之粟粒 瓦缝参差 +duo yu zhou shen zhi bo lv 多于周身之帛缕 直栏横槛 +duo zhu zhi zhi 多助之至 天下顺之 +e er xue zhou 俄而雪骤 公欣然曰 +e mei shan xia shao ren xing 峨嵋山下少人行 旌旗无光日色薄 +e mei shan yue ban lun qiu 峨眉山月半轮秋 影入平羌江水流 +e neng wu ji 恶能无纪 己亥之二月也 +e pang yi ju 阿房一炬 当时奢侈今何处 +e qi ti fu 饿其体肤 空乏其身 +e qing feng ding yun mo se 俄顷风定云墨色 秋天漠漠向昏黑 +er an ling yi wu shi li zhi di cun zhe 而安陵以五十里之地存者 徒以有先生也 +er bo le bu chang you 而伯乐不常有 故虽有名马 +er bu zhi qi suo qiong 而不知其所穷 引觞满酌 +er bu zhi qi suo zhi 而不知其所止 飘飘乎如遗世独立 +er bu zhi ren zhi le 而不知人之乐 人知从太守游而乐 +er bu zhi tai shou zhi le qi le ye 而不知太守之乐其乐也 醉能同其乐 +er bu zu yu min 而不足于民 杀所不足而争所有余 +er cheng ju zhe wei zhi zhi ye 而城居者未之知也 夫不能以游堕事而潇然于山石草木之间者 +er chuan rong rong 二川溶溶 流入宫墙 +er ci di shi yu yu jin 而此地适与余近 余之游将自此始 +er de zhi er wei sheng 耳得之而为声 目遇之而成色 +er fei yu bei hai 而飞于北海 非梧桐不止 +er hou ren ai zhi 而后人哀之 后人哀之而不鉴之 +er huo chang yan yi kong 而或长烟一空 皓月千里 +er ji qi zhang ceng bu ying cun 而计其长曾不盈寸 盖简桃核修狭者为之 +er jin an zai zai 而今安在哉 况吾与子渔樵于江渚之上 +er jiu lin kan mu 而僦赁看幕 虽席地不容间也 +er jun ni gua ren zhe 而君逆寡人者 轻寡人与 +er jun yi wu shi li zhi di cun zhe 而君以五十里之地存者 以君为长者 +er kuang si qi li zhi suo bu ji 而况思其力之所不及 忧其智之所不能 +er kuang yu ming zhe hu 而况于明哲乎 人君当神器之重 +er lai er shi you yi nian yi 尔来二十有一年矣 先帝知臣谨慎 +er lai si wan ba qian sui 尔来四万八千岁 不与秦塞通人烟 +er le yi wu qiong ye 而乐亦无穷也 至于负者歌于途 +er ling feng yu zi dong lai 二陵风雨自东来 关门令尹谁能识 +er lou shang ren wang lai xie xie 而楼上人往来屑屑 或凭或立 +er mo de qi ya 而莫得其涯 洋洋乎与造物者游 +er nan xin zhan si 二男新战死 存者且偷生 +er nian you zui gao 而年又最高 故自号曰醉翁也 +er nv gong zhan jin 儿女共沾巾 +er qi hou bu qi 而气候不齐 妃嫔媵嫱 +er qi wei lue bu zhan shi 而旗尾略不沾湿 以此夸能 +er qi zhu bu neng shou 而其主不能守 此殆天所以资将军 +er qing bo yi zhi yi zhe 而轻伯夷之义者 始吾弗信 +er shan bu jia zeng 而山不加增 何苦而不平 +er shi san nian qi zhi shen 二十三年弃置身 怀旧空吟闻笛赋 +er shi si qiao ming yue ye 二十四桥明月夜 玉人何处教吹箫 +er shu qi zuo xi 而竖其左膝 左臂挂念珠倚之 +er shui zhong fen bai lu zhou 二水中分白鹭洲 总为浮云能蔽日 +er tong ji zou zhui huang die 儿童急走追黄蝶 飞入菜花无处寻 +er tong jin dong zheng 儿童尽东征 请为父老歌 +er tong san xue gui lai zao 儿童散学归来早 忙趁东风放纸鸢 +er tong xiang jian bu xiang shi 儿童相见不相识 笑问客从何处来 +er wang xing yan 而望幸焉 有不得见者 +er wei chang wang ye 而未尝往也 盈虚者如彼 +er wei shi zhi xi shan zhi guai te 而未始知西山之怪特 今年九月二十八日 +er wen ke yue 而问客曰 何为其然也 +er wu che ma xuan 而无车马喧 问君何能尔 +er wu yu zi zhi suo gong shi 而吾与子之所共适 客喜而笑 +er yi lin ren zhi fu 而疑邻人之父 +er you bu yu gui 而犹不欲归 心凝形释 +er you he xian hu 而又何羡乎 且夫天地之间 +er yue chun feng si jian dao 二月春风似剪刀 +er zhe bu ke de jian 二者不可得兼 舍鱼而取熊掌者也 +er zhi shu qian duan 而智术浅短 遂用猖蹶 +er zhi yu ying 而至于郢 见公输盘 +er zu mo xiao zhang ye 而卒莫消长也 盖将自其变者而观之 +fa xin shao tan nan shan zhong 伐薪烧炭南山中 满面尘灰烟火色 +fa zhu qu dao 伐竹取道 下见小潭 +fan bai yuan shou 凡百元首 承天景命 +fan bao sha zhi niao 凡曝沙之鸟 呷浪之鳞 +fan fan ru yan wu 泛泛入烟雾 归棹洛阳人 +fan han duan xiao xi 蕃汉断消息 死生长别离 +fan hua shi san zhu xiang chen 繁华事散逐香尘 流水无情草自春 +fan jing ru shen lin 返景入深林 复照青苔上 +fan jun zui xiang jing 烦君最相警 我亦举家清 +fan shi sheng nv hao 反是生女好 生女犹得嫁比邻 +fan xing su gu guan 繁星宿故关 寒禽与衰草 +fang cao qi qi ying wu zhou 芳草萋萋鹦鹉洲 日暮乡关何处是 +fang cao wu qing 芳草无情 更在斜阳外 +fang cao xian mei 芳草鲜美 落英缤纷 +fang cao yi yun mu 芳草已云暮 故人殊未来 +fang feng jing yu chong a 访风景于崇阿 临帝子之长洲 +fang fu meng hun gui di suo 仿佛梦魂归帝所 闻天语 +fang fu ruo you guang 仿佛若有光 便舍船 +fang lang xing hai zhi wai 放浪形骸之外 虽趣舍万殊 +fang qi bai li 方七百里 高万仞 +fang qi po jing zhou 方其破荆州 下江陵 +fang qi yuan chu hai men 方其远出海门 仅如银线 +fang xin xiang chun jin 芳心向春尽 所得是沾衣 +fei cui qin han shui yu gong 翡翠衾寒谁与共 悠悠生死别经年 +fei du xian zhe you shi xin ye 非独贤者有是心也 人皆有之 +fei fu wu xia a meng 非复吴下阿蒙 蒙曰 +fei ge liu dan 飞阁流丹 下临无地 +fei ji zhe jie you suo yang 废疾者皆有所养 男有分 +fei lai shan shang qian xun ta 飞来山上千寻塔 闻说鸡鸣见日升 +fei li quan bu yin 非醴泉不饮 于是鸱得腐鼠 +fei lian shi bu shi 非练实不食 非醴泉不饮 +fei liu zhi xia san qian chi 飞流直下三千尺 疑是银河落九天 +fei niao xiang yu huan 飞鸟相与还 此中有真意 +fei pin ying qiang 妃嫔媵嫱 王子皇孙 +fei ru cai hua wu chu xun 飞入菜花无处寻 +fei ru xun chang bai xing jia 飞入寻常百姓家 +fei ruo shi ye 非若是也 安陵君受地于先王而守之 +fei shen li zi ge 飞沈理自隔 何所慰吾诚 +fei shi zhi nu ye 非士之怒也 夫专诸之刺王僚也 +fei shu qi jian 飞漱其间 清荣峻茂 +fei si fei zhu 非丝非竹 射者中 +fei tian xia ye 非天下也 嗟乎 +fei tuan pu liu zheng xuan hui 飞湍瀑流争喧豗 砯崖转石万壑雷 +fei wei tian shi 非惟天时 抑亦人谋也 +fei wu sheng zhu 非无圣主 窜梁鸿于海曲 +fei wu tong bu zhi 非梧桐不止 非练实不食 +fei xie jia zhi bao shu 非谢家之宝树 接孟氏之芳邻 +fei xue ying chun dao 飞雪迎春到 已是悬崖百丈冰 +fei zai qing yun duan 飞在青云端 仙人垂两足 +fen cao she fu la deng hong 分曹射覆蜡灯红 嗟余听鼓应官去 +fen cao xian zi wei 分曹限紫微 晓随天仗入 +fen fen mu xue xia yuan men 纷纷暮雪下辕门 风掣红旗冻不翻 +fen gu sui shen quan bu pa 粉骨碎身全不怕 要留清白在人间 +fen jiao lan ye 焚椒兰也 雷霆乍惊 +fen lie liang an 分列两岸 既而尽奔腾分合五阵之势 +fen ming yuan hen qu zhong lun 分明怨恨曲中论 +fen qiang dan zhu dong guang cai 粉墙丹柱动光彩 鬼物图画填青红 +fen tu dang nian wan hu hou 粪土当年万户侯 曾记否 +fen wai yao rao 分外妖娆 江山如此多娇 +fen ye zhong feng bian 分野中峰变 阴晴众壑殊 +feng cao lv ru er zheng mao 丰草绿缛而争茂 佳木葱茏而可悦 +feng che hong qi dong bu fan 风掣红旗冻不翻 轮台东门送君去 +feng chen he chu qi 风尘何处期 +feng chen hun yu wan li 奉晨昏于万里 非谢家之宝树 +feng chen ren ran yin shu jue 风尘荏苒音书绝 关塞萧条行路难 +feng cheng han jin pa chun xiao 凤城寒尽怕春宵 无端嫁得金龟婿 +feng chui cao di jian niu yang 风吹草低见牛羊 +feng chui liu hua man dian xiang 风吹柳花满店香 吴姬压酒唤客尝 +feng chui xian mei piao piao ju 风吹仙袂飘飘举 犹似霓裳羽衣舞 +feng duo za gu sheng 风多杂鼓声 宁为百夫长 +feng fang shui wo 蜂房水涡 矗不知乎几千万落 +feng guang bu yu si shi tong 风光不与四时同 接天莲叶无穷碧 +feng hua zheng mao 风华正茂 书生意气 +feng huang chi dui qing suo men 凤凰池对青琐门 高才脱略名与利 +feng huang tai shang feng huang you 凤凰台上凤凰游 凤去台空江自流 +feng hui lu zhuan 峰回路转 有亭翼然临于泉上者 +feng huo lian san yue 烽火连三月 家书抵万金 +feng huo yang zhou lu 烽火扬州路 可堪回首 +feng huo zhao xi jing 烽火照西京 心中自不平 +feng ji tian gao yuan xiao ai 风急天高猿啸哀 渚清沙白鸟飞回 +feng jing jiu ceng an 风景旧曾谙 日出江花红胜火 +feng juan jiang hu yu an cun 风卷江湖雨暗村 四山声作海涛翻 +feng lang ju xu 封狼居胥 赢得仓皇北顾 +feng li sui shang jin 风力虽尚劲 然徒步则汗出浃背 +feng liu ru ya yi wu shi 风流儒雅亦吾师 怅望千秋一洒泪 +feng liu zong bei 风流总被 雨打风吹去 +feng luan ru ju 峰峦如聚 波涛如怒 +feng ming yu wei nan zhi jian 奉命于危难之间 尔来二十有一年矣 +feng nian liu ke zu ji tun 丰年留客足鸡豚 山重水复疑无路 +feng nuan niao sheng sui 风暖鸟声碎 日高花影重 +feng qu tai kong jiang zi liu 凤去台空江自流 吴宫花草埋幽径 +feng quan man qing ting 风泉满清听 樵人归欲尽 +feng sheng yi he sheng 风声一何盛 松枝一何劲 +feng shuang gao jie 风霜高洁 水落而石出者 +feng song gong pin xiao yu he 风送宫嫔笑语和 月殿影开闻夜漏 +feng tang yi lao 冯唐易老 李广难封 +feng tou ru dao mian ru ge 风头如刀面如割 马毛带雪汗气蒸 +feng wei xiang luo bo ji chong 凤尾香罗薄几重 碧文圆顶夜深缝 +feng wu qi qi su yu shou 风物凄凄宿雨收 山色遥连秦树晚 +feng xuan shi yi he nian 奉宣室以何年 嗟乎 +feng yan ju jing 风烟俱净 天山共色 +feng yan wang wu jin 风烟望五津 与君离别意 +feng ye di hua qiu se se 枫叶荻花秋瑟瑟 主人下马客在船 +feng ye luo fen fen 枫叶落纷纷 +feng yu bu dong an ru shan 风雨不动安如山 呜呼 +feng yu qi qi 风雨凄凄 一日之内 +feng yu song chun gui 风雨送春归 飞雪迎春到 +feng yu zhou zhi 风雨骤至 其触于物也 +feng yun chang wei hu chu xu 风云常为护储胥 徒令上将挥神笔 +feng zheng yi fan xuan 风正一帆悬 海日生残夜 +feng zhou ping ming jin dian kai 奉帚平明金殿开 且将团扇共徘徊 +feng zhu chen xiang hua yi jin 风住尘香花已尽 日晚倦梳头 +fo yin ju you 佛印居右 鲁直居左 +fo yin jue lei mi le 佛印绝类弥勒 袒胸露乳 +fu bu neng yi you duo shi er xiao ran yu shan shi cao mu zhi jian zhe 夫不能以游堕事而潇然于山石草木之间者 惟此官也 +fu cha pin lei zhi sheng 俯察品类之盛 所以游目骋怀 +fu chi zi shi shen ming li 扶持自是神明力 正直原因造化功 +fu dao heng kong 複道行空 不霁何虹 +fu dao jiu jia wen yan 复到舅家问焉 曰 +fu de ze si 弗得则死 呼尔而与之 +fu di yang liu zui chun yan 拂堤杨柳醉春烟 儿童散学归来早 +fu fei liu zhen wei wang cai 宓妃留枕魏王才 春心莫共花争发 +fu gu he dan si 妇姑荷箪食 童稚携壶浆 +fu guang yue jin 浮光跃金 静影沉璧 +fu huan er gong zhi 夫环而攻之 必有得天时者矣 +fu kong cong cong shuo bu jin 复恐匆匆说不尽 行人临发又开封 +fu lao si wu ren 父老四五人 问我久远行 +fu li qi ran ye 父利其然也 日扳仲永环谒于邑人 +fu ling yun er zi xi 抚凌云而自惜 钟期既遇 +fu ping yi dao kai 浮萍一道开 +fu qie ye xi 负箧曳屣 行深山巨谷中 +fu ren zhi xiang yu 夫人之相与 俯仰一世 +fu rong jing qi yan wu luo 芙蓉旌旗烟雾落 影动倒景摇潇湘 +fu rong ru mian liu ru mei 芙蓉如面柳如眉 对此如何不泪垂 +fu rong tang wai you qing lei 芙蓉塘外有轻雷 金蟾啮锁烧香入 +fu rong zhang nuan du chun xiao 芙蓉帐暖度春宵 春宵苦短日高起 +fu shen qing er yi qing 俯身倾耳以请 或遇其叱咄 +fu shi bai wan 伏尸百万 流血千里 +fu shi er ren 伏尸二人 流血五步 +fu shi jing shang 负势竞上 互相轩邈 +fu shuo ju yu ban zhu zhi jian 傅说举于版筑之间 胶鬲举于鱼盐之中 +fu ti yi he ku 妇啼一何苦 听妇前致词 +fu tian cang hai yuan 浮天沧海远 去世法舟轻 +fu xing shu shi bu 复行数十步 豁然开朗 +fu xu qing bo er 夫婿轻薄儿 新人美如玉 +fu ya san bai yu li 覆压三百余里 隔离天日 +fu yan man hui tang 凫雁满回塘 +fu yang yi shi 俯仰一世 或取诸怀抱 +fu yang zhi jian 俯仰之间 已为陈迹 +fu yao ke jie 扶摇可接 东隅已逝 +fu you pin fu ren 复有贫妇人 抱子在其旁 +fu yuan chu fa yu nan hai 夫鹓鶵发于南海 而飞于北海 +fu yun yi bie hou 浮云一别后 流水十年间 +fu yun you zi yi 浮云游子意 落日故人情 +fu yun zhong ri xing 浮云终日行 游子久不至 +fu zai yin you bi jie cheng yi dai xia 夫在殷忧必竭诚以待下 既得志则纵情以傲物 +fu zhao qing tai shang 复照青苔上 +fu zhi jie yu zui 复值接舆醉 狂歌五柳前 +fu zhong zhu shu yi wan juan 腹中贮书一万卷 不肯低头在草莽 +fu zhuan zhu zhi ci wang liao ye 夫专诸之刺王僚也 彗星袭月 +fu zi he ming yan wei 夫子何命焉为 子墨子曰 +fu zi he wei zhe 夫子何为者 栖栖一代中 +gai da su fan chi bi yun 盖大苏泛赤壁云 舟首尾长约八分有奇 +gai fu qiu zhi wei zhuang ye 盖夫秋之为状也 其色惨淡 +gai jian tao he xiu xia zhe wei zhi 盖简桃核修狭者为之 嘻 +gai jiang zi qi bian zhe er guan zhi 盖将自其变者而观之 则天地曾不能以一瞬 +gai yi lai ha ma 盖一癞蛤蟆 舌一吐而二虫尽为所吞 +gai yu zhi qin qie jian ruo ci 盖余之勤且艰若此 +gai zhu bai ying ye 盖竹柏影也 何夜无月 +gai zhui xian di zhi shu yu 盖追先帝之殊遇 欲报之于陛下也 +gan ci shang qie xin 感此伤妾心 坐愁红颜老 +gan gao yun shan cong ci shi 敢告云山从此始 +gan ge liao luo si zhou xing 干戈寥落四周星 山河破碎风飘絮 +gan ji er bei zhe yi 感极而悲者矣 至若春和景明 +gan jiang shi zhi kua pian qiao 敢将十指夸偏巧 不把双眉斗画长 +gan jie bi huai 敢竭鄙怀 恭疏短引 +gan kai xi zhi yi 感慨系之矣 向之所欣 +gan shi hua jian lei 感时花溅泪 恨别鸟惊心 +gan wo ci yan liang jiu li 感我此言良久立 却坐促弦弦转急 +gan yi fan zhi shi 敢以烦执事 越国以鄙远 +gan zheng shu zhi shi 感征戍之事 因而和焉 +gao cai tuo lue ming yu li 高才脱略名与利 日夕望君抱琴至 +gao cha qing ming 高插青冥 相顾惊疑 +gao chu bu sheng han 高处不胜寒 起舞弄清影 +gao di ming mi 高低冥迷 不知西东 +gao di zi sun jin long zhun 高帝子孙尽隆准 龙种自与常人殊 +gao feng han yang du 高风汉阳渡 初日郢门山 +gao feng ru yun 高峰入云 清流见底 +gao gao han ri sheng yu dong 杲杲寒日生于东 +gao ge ke jing qu 高阁客竟去 小园花乱飞 +gao gui chang ju cu 告归常局促 苦道来不易 +gao ke er shu xu 高可二黍许 中轩敞者为舱 +gao liu jia di 高柳夹堤 土膏微润 +gao lou dang ci ye 高楼当此夜 叹息未应闲 +gao ming bi shen e 高明逼神恶 今我游冥冥 +gao peng man zuo 高朋满座 腾蛟起凤 +gao shang shi ye 高尚士也 闻之 +gao tang ming jing bei bai fa 高堂明镜悲白发 朝如青丝暮成雪 +gao wo nan zhai shi 高卧南斋时 开帷月初吐 +gao yuan pi ni 高垣睥睨 连亘六七里 +gao zhe gua juan chang lin shao 高者挂罥长林梢 下者飘转沉塘坳 +gao zhi yu di 告之于帝 帝感其诚 +gao zu yin zhi yi cheng di ye 高祖因之以成帝业 刘璋暗弱 +ge ba yang tian tan 歌罢仰天叹 四座泪纵横 +ge bao di shi 各抱地势 钩心斗角 +ge dao hui kan shang yuan hua 阁道回看上苑花 云里帝城双凤阙 +ge jian mi jin 舸舰迷津 青雀黄龙之舳 +ge jiang you chang hou ting hua 隔江犹唱后庭花 +ge ju qing tai 各具情态 尝贻余核舟一 +ge li hu qu jin yu bei 隔篱呼取尽余杯 +ge li tian ri 隔离天日 骊山北构而西折 +ge ling feng sao shu bai nian 各领风骚数百年 +ge qing lu hai yun er 各倾陆海云尔 滕王高阁临江渚 +ge shan wang nan dou 隔山望南斗 潭烟飞溶溶 +ge sheng zhen lin yue 歌声振林樾 意欲捕鸣蝉 +ge shu ye dai dao 哥舒夜带刀 至今窥牧马 +ge shui wen qiao fu 隔水问樵夫 +ge tai nuan xiang 歌台暖响 春光融融 +ge tian shi zhi min yu 葛天氏之民欤 +ge yao tiao zhi zhang 歌窈窕之章 少焉 +ge ye huang li kong hao yin 隔叶黄鹂空好音 三顾频烦天下计 +ge yi yong zhi 歌以咏志 +ge yin juan di yi zhe zhong 各隐卷底衣褶中 佛印绝类弥勒 +ge zhong di zi jin he zai 阁中帝子今何在 槛外长江空自流 +ge zuo song gou chun jiu nuan 隔座送钩春酒暖 分曹射覆蜡灯红 +gen bu gu er qiu mu zhi chang 根不固而求木之长 德不厚而思国之安 +geng fan yi shi shu 羹饭一时熟 不知贻阿谁 +geng ge peng shan ji wan zhong 更隔蓬山几万重 +geng geng xing he yu shu tian 耿耿星河欲曙天 鸳鸯瓦冷霜华重 +geng kan jiang shang gu pi sheng 更堪江上鼓鼙声 +geng shang yi ceng lou 更上一层楼 +geng shen yue se ban ren jia 更深月色半人家 北斗阑干南斗斜 +geng wu shan yu qi 更无山与齐 举头红日近 +geng you chi si xiang gong zhe 更有痴似相公者 +geng you ming zhao hen 更有明朝恨 离杯惜共传 +geng yu he ren shuo 更与何人说 +geng zai xie yang wai 更在斜阳外 黯乡魂 +geng zhu feng he yu 更著风和雨 无意苦争春 +gong che guo ye 宫车过也 辘辘远听 +gong cheng bu tui jie yun shen 功成不退皆殒身 子胥既弃吴江上 +gong cheng er de shuai 功成而德衰 有善始者实繁 +gong chou jiao cuo 觥筹交错 起坐而喧哗者 +gong da xiao le 公大笑乐 即公大兄无奕女 +gong feng sheng jian 躬逢胜饯 时维九月 +gong gai san fen guo 功盖三分国 名成八阵图 +gong geng yu nan yang 躬耕于南阳 苟全性命于乱世 +gong hua ji mo hong 宫花寂寞红 白头宫女在 +gong kan ming yue ying chui lei 共看明月应垂泪 一夜乡心五处同 +gong lai bai yue wen shen di 共来百越文身地 犹自音书滞一乡 +gong lian shi shi jian shu zhuang 共怜时世俭梳妆 敢将十指夸偏巧 +gong qi fa kun 共其乏困 君亦无所害 +gong qin qi zhi suo pan 攻亲戚之所畔 故君子有不战 +gong que wan jian dou zuo le tu 宫阙万间都做了土 兴 +gong ran bao mao ru zhu qu 公然抱茅入竹去 唇焦口燥呼不得 +gong ru pi li xian jing 弓如霹雳弦惊 了却君王天下事 +gong shi qu jiang xi bu de 宫使驱将惜不得 半匹红绡一丈绫 +gong shu duan yin 恭疏短引 一言均赋 +gong shu pan bu shuo 公输盘不说 子墨子曰 +gong shu pan fu 公输盘服 子墨子曰 +gong shu pan jiu she gong cheng zhi ji bian 公输盘九设攻城之机变 子墨子九距之 +gong shu pan qu 公输盘诎 而曰 +gong shu pan wei chu zao yun ti zhi xie 公输盘为楚造云梯之械 成 +gong shu pan wei wo wei yun ti 公输盘为我为云梯 必取宋 +gong shu pan yue 公输盘曰 夫子何命焉为 +gong shu pan zhi gong xie jin 公输盘之攻械尽 子墨子之守圉有余 +gong shu zi zhi yi bu guo yu sha chen 公输子之意不过欲杀臣 杀臣 +gong xin ran yue 公欣然曰 白雪纷纷何所似 +gong yi chang wen tian zi zhi nu hu 公亦尝闻天子之怒乎 唐雎对曰 +gong zhong fu zhong 宫中府中 俱为一体 +gou fei wu zhi suo you 苟非吾之所有 虽一毫而莫取 +gou hua le le 钩画了了 其色墨 +gou quan xing ming yu luan shi 苟全性命于乱世 不求闻达于诸侯 +gou xin dou jiao 钩心斗角 盘盘焉 +gu bi ye seng lin 孤壁野僧邻 寄卧郊扉久 +gu bu cuo yi ye 故不错意也 今吾以十倍之地 +gu bu duo de liang li 孤不度德量力 欲信大义于天下 +gu bu ke che 固不可彻 曾不若孀妻弱子 +gu bu wei gou de ye 故不为苟得也 死亦我所恶 +gu chang du shu 孤常读书 自以为大有所益 +gu cheng bei ling han chui jiao 孤城背岭寒吹角 独戍临江夜泊船 +gu cheng dang luo hui 孤城当落晖 吾谋适不用 +gu cheng luo ri dou bing xi 孤城落日斗兵稀 身当恩遇常轻敌 +gu cheng yao wang yu men guan 孤城遥望玉门关 黄沙百战穿金甲 +gu ci gao shu liang mang ran 古祠高树两茫然 云边雁断胡天月 +gu cun luo ri can xia 孤村落日残霞 轻烟老树寒鸦 +gu dao xi feng shou ma 古道西风瘦马 夕阳西下 +gu deng bu ming si yu jue 孤灯不明思欲绝 卷帷望月空长叹 +gu deng han zhao yu 孤灯寒照雨 深竹暗浮烟 +gu deng tiao jin wei cheng mian 孤灯挑尽未成眠 迟迟钟鼓初长夜 +gu deng wen chu jiao 孤灯闻楚角 残月下章台 +gu diao sui zi ai 古调虽自爱 今人多不弹 +gu fan tian ji kan 孤帆天际看 迷津欲有问 +gu fan yi pian ri bian lai 孤帆一片日边来 +gu fan yuan ying bi kong jin 孤帆远影碧空尽 唯见长江天际流 +gu fu xiang qin shi zao chao 辜负香衾事早朝 +gu guan shuai cao bian 故关衰草遍 离别正堪悲 +gu guo bu kan hui shou yue ming zhong 故国不堪回首月明中 雕栏玉砌应犹在 +gu guo bu yi shan xi zhi xian 固国不以山溪之险 威天下不以兵革之利 +gu guo san qian li 故国三千里 深宫二十年 +gu guo shen you 故国神游 多情应笑我 +gu hong hai shang lai 孤鸿海上来 池潢不敢顾 +gu huan you suo bu bi ye 故患有所不辟也 如使人之所欲莫甚于生 +gu jun zi you bu zhan 故君子有不战 战必胜矣 +gu ke zhou mian zhi lang jing 估客昼眠知浪静 舟人夜语觉潮生 +gu lai bai gu wu ren shou 古来白骨无人收 新鬼烦冤旧鬼哭 +gu lai cai da nan wei yong 古来材大难为用 +gu lai gong tan 古来共谈 高峰入云 +gu lai qing shi shui bu jian 古来青史谁不见 今见功名胜古人 +gu lai sheng xian jie ji mo 古来圣贤皆寂寞 惟有饮者留其名 +gu lai wan shi dong liu shui 古来万事东流水 别君去兮何时还 +gu lai zheng zhan ji ren hui 古来征战几人回 +gu lei xi bian 故垒西边 人道是 +gu lie xu shi ren 故列叙时人 录其所述 +gu lin beng ji chen yi da shi ye 故临崩寄臣以大事也 受命以来 +gu mu wu ren jing 古木无人径 深山何处钟 +gu peng wan li zheng 孤蓬万里征 浮云游子意 +gu qi wei sheng ye 故其为声也 凄凄切切 +gu qi yu qing zhi jing wei bo shi xie 孤岂欲卿治经为博士邪 但当涉猎 +gu qi zai le ye 故其在乐也 商声主西方之音 +gu qin hou luo jing 孤琴候萝径 +gu ren bu du qin qi qin 故人不独亲其亲 不独子其子 +gu ren jiang hai bie 故人江海别 几度隔山川 +gu ren ju ji shu 故人具鸡黍 邀我至田家 +gu ren ru wo meng 故人入我梦 明我长相忆 +gu ren shu wei lai 故人殊未来 乡书不可寄 +gu ren xi ci huang he lou 故人西辞黄鹤楼 烟花三月下扬州 +gu rou bu dai tong chi qu 骨肉不待同驰驱 腰下宝玦青珊瑚 +gu rou liu li dao lu zhong 骨肉流离道路中 吊影分为千里雁 +gu shan si bei jia ting xi 孤山寺北贾亭西 水面初平云脚低 +gu shi you wu wai zhi qu 故时有物外之趣 夏蚊成雷 +gu shu cang cang feng huo han 古戍苍苍烽火寒 大荒沈沈飞雪白 +gu su cheng wai han shan si 姑苏城外寒山寺 夜半钟声到客船 +gu sui you ming ma 故虽有名马 祇辱于奴隶人之手 +gu tai yao luo hou 古台摇落后 秋日望乡心 +gu tian jiang jiang da ren yu shi ren ye 故天将降大任于是人也 必先苦其心志 +gu wai hu er bu bi 故外户而不闭 是谓大同 +gu wei zhi wen yi zhi 故为之文以志 是岁 +gu wo wu yi sou hua qie 顾我无衣搜画箧 泥他沽酒拔金钗 +gu wu yue du lu 故五月渡泸 深入不毛 +gu yi shi zhi xiong ye 固一世之雄也 而今安在哉 +gu ying wu ru bai fa he 顾影无如白发何 今日龙钟人共弃 +gu you ming gui shi yun 故又名鬼市云 +gu yu sui yu 故余虽愚 卒获有所闻 +gu yu zhe ge yue 故渔者歌曰 巴东三峡巫峡长 +gu yuan dong wang lu man man 故园东望路漫漫 双袖龙钟泪不干 +gu yuan wu ci sheng 故园无此声 +gu yuan wu yi ping 故园芜已平 烦君最相警 +gu yun du qu xian 孤云独去闲 相看两不厌 +gu yun jiang ye he 孤云将野鹤 岂向人间住 +gu zhi yi si sheng wei xu dan 固知一死生为虚诞 齐彭殇为妄作 +gu zhi you kong ming 孤之有孔明 犹鱼之有水也 +gu zhou suo li weng 孤舟蓑笠翁 独钓寒江雪 +gu zi hao yue zui weng ye 故自号曰醉翁也 醉翁之意不在酒 +gua ren yi wu bai li zhi di yi an ling 寡人以五百里之地易安陵 安陵君不听寡人 +gua ren yu yi 寡人谕矣 夫韩 +gua ren yu yi wu bai li zhi di yi an ling 寡人欲以五百里之地易安陵 安陵君其许寡人 +gua zhu zhi zhi 寡助之至 亲戚畔之 +guai sheng wu yu dou zhang san 怪生无雨都张伞 不是遮头是使风 +guan chuan lai wang luan ru ma 官船来往乱如麻 全仗你抬声价 +guan gai man jing hua 冠盖满京华 斯人独憔悴 +guan gao he zu lun 官高何足论 不得收骨肉 +guan guan ju jiu 关关雎鸠 在河之洲 +guan men ling yin shui neng shi 关门令尹谁能识 河上仙翁去不回 +guan sai xiao tiao xing lu nan 关塞萧条行路难 已忍伶俜十年事 +guan shan du ruo fei 关山度若飞 朔气传金柝 +guan shan nan yue 关山难越 谁悲失路之人 +guan tan pi pa jie ge wu 惯弹琵琶解歌舞 今为羌笛出塞声 +guan xian ou ya 管弦呕哑 多于市人之言语 +guan yi wu ju yu shi 管夷吾举于士 孙叔敖举于海 +guan ying lao bing xiu 官应老病休 飘飘何所似 +guan yue you cai yuan bu tian 管乐有才原不忝 关张无命欲何如 +guan zhang wu ming yu he ru 关张无命欲何如 他年锦里经祠庙 +guan zhong xi sang luan 关中昔丧乱 兄弟遭杀戮 +guang ze sheng ming yue 广泽生明月 苍山夹乱流 +guang zhao lin chuan zhi bi 光照临川之笔 四美具 +gui dian lan gong 桂殿兰宫 即冈峦之体势 +gui hua qiu jiao jie 桂华秋皎洁 欣欣此生意 +gui lai bao fan huang hun hou 归来饱饭黄昏后 不脱蓑衣卧月明 +gui lai chi yuan jie yi jiu 归来池苑皆依旧 太液芙蓉未央柳 +gui lai jian tian zi 归来见天子 天子坐明堂 +gui lai kan qu ming jing qian 归来看取明镜前 +gui lai qie bi guan 归来且闭关 +gui lai tou bai hai shu bian 归来头白还戍边 边亭流血成海水 +gui lai yi zhang zi tan xi 归来倚杖自叹息 俄顷风定云墨色 +gui lai zhu bai shi 归来煮白石 欲持一瓢酒 +gui ma shi can qi 归马识残旗 欲祭疑君在 +gui po chu sheng qiu lu wei 桂魄初生秋露微 轻罗已薄未更衣 +gui qu yue wang jia 归去越王家 +gui shan shen qian qu 归山深浅去 须尽丘壑美 +gui shu he tuan tuan 桂树何团团 白兔捣药成 +gui wu tu hua tian qing hong 鬼物图画填青红 升阶伛偻荐脯酒 +gui yan luo yang bian 归雁洛阳边 +gui yan ru hu tian 归雁入胡天 大漠孤烟直 +gui zhao luo yang ren 归棹洛阳人 残钟广陵树 +gui zhao xi lan jiang 桂棹兮兰桨 击空明兮溯流光 +gui zhong shao fu bu zhi chou 闺中少妇不知愁 春日凝妆上翠楼 +gui zhong zhi du kan 闺中只独看 遥怜小儿女 +guo guo fu ren cheng zhu en 虢国夫人承主恩 平明骑马入宫门 +guo jia cheng bai wu qi gan 国家成败吾岂敢 色难腥腐餐枫香 +guo jiang qian chi lang 过江千尺浪 入竹万竿斜 +guo jin qian fan jie bu shi 过尽千帆皆不是 斜晖脉脉水悠悠 +guo po shan he zai 国破山河在 城春草木深 +guo ran he ye 果然鹤也 昂首观之 +guo ru he li yun duan 果如鹤唳云端 为之怡然称快 +guo sui xiang xin meng bu cheng 聒碎乡心梦不成 故园无此声 +guo xian er min fu 国险而民附 贤能为之用 +guo yu kan song se 过雨看松色 随山到水源 +guo zhong bu zhi 过中不至 太丘舍去 +hai cong wu wai qi tian yuan 还从物外起田园 月明松下房栊静 +hai kan jin zhao 还看今朝 +hai ke tan ying zhou 海客谈瀛洲 烟涛微茫信难求 +hai lai jiu ju hua 还来就菊花 +hai men shen bu jian 海门深不见 浦树远含滋 +hai nei cun zhi ji 海内存知己 天涯若比邻 +hai nei feng chen zhu di ge 海内风尘诸弟隔 天涯涕泪一身遥 +hai qi shi zhe xun xing sao 海气湿蛰熏腥臊 昨者州前捶大鼓 +hai qin meng jia qi 还寝梦佳期 +hai ri sheng can ye 海日生残夜 江春入旧年 +hai shang sheng ming yue 海上生明月 天涯共此时 +hai tian chou si zheng mang mang 海天愁思正茫茫 惊风乱飐芙蓉水 +hai yan gu yuan fei 还掩故园扉 +hai yong yin wei guo 海涌银为郭 江横玉系腰 +hai zi yang zhou 还自扬州 复到舅家问焉 +han bing fen xun ru pi li 汉兵奋迅如霹雳 虏骑崩腾畏蒺藜 +han bing tun zai lun tai bei 汉兵屯在轮台北 上将拥旄西出征 +han chan qi qie 寒蝉凄切 对长亭晚 +han chao gong qing ji jia sheng 汉朝公卿忌贾生 君不见昔时燕家重郭隗 +han dan fa he hua 菡萏发荷花 五月西施采 +han deng du ye ren 寒灯独夜人 空园白露滴 +han deng si jiu shi 寒灯思旧事 断雁警愁眠 +han di he xia tu 汗滴禾下土 谁知盘中餐 +han guang hun shi gui wu ming 含光混世贵无名 何用孤高比云月 +han guang zhao tie yi 寒光照铁衣 将军百战死 +han hai lan gan bai zhang bing 瀚海阑干百丈冰 愁云惨淡万里凝 +han huang zhong se si qing guo 汉皇重色思倾国 御宇多年求不得 +han jia da jiang xi chu shi 汉家大将西出师 将军金甲夜不脱 +han jia yan chen zai dong bei 汉家烟尘在东北 汉将辞家破残贼 +han jiang ci jia po can zei 汉将辞家破残贼 男儿本自重横行 +han kou xi yang xie du niao 汉口夕阳斜渡鸟 洞庭秋水远连天 +han lin kong jian ri xie shi 寒林空见日斜时 汉文有道恩犹薄 +han mei zhuo hua wei 寒梅著花未 +han nv shu tong bu 汉女输橦布 巴人讼芋田 +han qin yu shuai cao 寒禽与衰草 处处伴愁颜 +han qing man kong lin 寒磬满空林 惆怅南朝事 +han qing ning di xie jun wang 含情凝睇谢君王 一别音容两渺茫 +han qing yu shuo gong zhong shi 含情欲说宫中事 鹦鹉前头不敢言 +han shan zhuan cang cui 寒山转苍翠 秋水日潺湲 +han sheng yi ye chuan diao dou 寒声一夜传刁斗 相看白刃血纷纷 +han shi dong feng yu liu xie 寒食东风御柳斜 日暮汉宫传蜡烛 +han shi duan chang dui gui ke 汉使断肠对归客 古戍苍苍烽火寒 +han shi ke xing yi 汉室可兴矣 先主曰 +han shi qing tui 汉室倾颓 奸臣窃命 +han shu yi jie 寒暑易节 始一反焉 +han wen huang di you gao tai 汉文皇帝有高台 此日登临曙色开 +han wen you dao en you bo 汉文有道恩犹薄 湘水无情吊岂知 +han xia bai deng dao 汉下白登道 胡窥青海湾 +han xu hun tai qing 涵虚混太清 气蒸云梦泽 +han yu lian jiang ye ru wu 寒雨连江夜入吴 平明送客楚山孤 +han yun lu ji ceng 寒云路几层 独敲初夜磬 +hang dang fu gui zhao 行当浮桂棹 未几拂荆扉 +hang dao chi chi 行道迟迟 载渴载饥 +hao dang li chou bai ri xie 浩荡离愁白日斜 吟鞭东指即天涯 +hao feng sui chu gai 好峰随处改 幽径独行迷 +hao hao hu ru feng xu yu feng 浩浩乎如冯虚御风 而不知其所止 +hao hao shang shang 浩浩汤汤 横无际涯 +hao jie bing qi 豪杰并起 跨州连郡者不可胜数 +hao meng liu ren shui 好梦留人睡 明月楼高休独倚 +hao niao xiang ming 好鸟相鸣 嘤嘤成韵 +hao ran li gu guan 浩然离故关 高风汉阳渡 +hao shou wu gu zhang jiang bian 好收吾骨瘴江边 +hao wei liang fu yin 好为梁父吟 身长八尺 +hao yu zhi shi jie 好雨知时节 当春乃发生 +hao yue qian li 皓月千里 浮光跃金 +he bi lao shen ku si 何必劳神苦思 代下司职 +he bo shi xuan qi mian mu 河伯始旋其面目 望洋向若而叹曰 +he bo xin ran zi xi 河伯欣然自喜 以天下之美为尽在己 +he bu wei ye 何不为也 由是则生而有不用也 +he bu yong ye 何不用也 使人之所恶莫甚于死者 +he chu hai xiang yu 何处还相遇 世事波上舟 +he chu mi zhi yin 何处觅知音 青衫湿 +he chu wang shen zhou 何处望神州 满眼风光北固楼 +he chu wu zhu bai 何处无竹柏 但少闲人如吾两人者耳 +he chu xi nan ren hao feng 何处西南任好风 +he chu xun xing ji 何处寻行迹 +he dang chong xiang jian 何当重相见 樽酒慰离颜 +he dang gong jian xi chuang zhu 何当共剪西窗烛 却话巴山夜雨时 +he hun shang zhi shi 合昏尚知时 鸳鸯不独宿 +he jin yi wu qing yu gai 荷尽已无擎雨盖 菊残犹有傲霜枝 +he ku er bu ping 何苦而不平 河曲智叟亡以应 +he lan shan xia zhen ru yun 贺兰山下阵如云 羽檄交驰日夕闻 +he li dai xie yang 荷笠带斜阳 青山独归远 +he lou zhi you 何陋之有 +he nian zhi ci shen 何年致此身 +he qiu mei ren zhe 何求美人折 +he qu zhi sou wang yi ying 河曲智叟亡以应 操蛇之神闻之 +he qu zhi sou xiao er zhi zhi yue 河曲智叟笑而止之曰 甚矣 +he ren bu qi gu yuan qing 何人不起故园情 +he ri fu gui lai 何日复归来 江静潮初落 +he ri ping hu lu 何日平胡虏 良人罢远征 +he ri qian feng tang 何日遣冯唐 会挽雕弓如满月 +he ru ci chu xue zhang sheng 何如此处学长生 +he shan bei zhen qin guan xian 河山北枕秦关险 驿路西连汉畤平 +he shang xian weng qu bu hui 河上仙翁去不回 且欲近寻彭泽宰 +he sheng long mu wu dong xi 禾生陇亩无东西 况复秦兵耐苦战 +he sheng ru hai yao 河声入海遥 帝乡明日到 +he shi chang xiang bie shi yuan 何事长向别时圆 人有悲欢离合 +he shi fan pei le yan ran 何时反旆勒燕然 +he shi fu xi gui 何时复西归 少壮不努力 +he shi ru luo wei 何事入罗帏 +he shi yan qian tu wu jian ci wu 何时眼前突兀见此屋 吾庐独破受冻死亦足 +he shi yi xu huang 何时倚虚幌 双照泪痕干 +he si zai ren jian 何似在人间 转朱阁 +he suo wei wu cheng 何所慰吾诚 +he ting fu zhu 鹤汀凫渚 穷岛屿之萦回 +he wei qi ran ye 何为其然也 客曰 +he xu shen hou qian zai ming 何须身后千载名 +he yan zhi you 何厌之有 既东封郑 +he yang zhi bei 河阳之北 北山愚公者 +he ye wu yue 何夜无月 何处无竹柏 +he yi you yu yi 何以有羽翼 恐非平生魂 +he yin bei gui qu 何因北归去 淮上对秋山 +he yong bie xun fang wai qu 何用别寻方外去 人间亦自有丹丘 +he yong gu gao bi yun yue 何用孤高比云月 吾观自古贤达人 +he zhi yu ci 何至于此 寡人谕矣 +hei yun fan mo wei zhe shan 黑云翻墨未遮山 白雨跳珠乱入船 +hei yun ya cheng cheng yu cui 黑云压城城欲摧 甲光向日金鳞开 +hen bie niao jing xin 恨别鸟惊心 烽火连三月 +heng kan cheng ling ce cheng feng 横看成岭侧成峰 远近高低各不同 +heng ke shang bi 横柯上蔽 在昼犹昏 +heng shuo fu shi 横槊赋诗 固一世之雄也 +heng wu ji ya 横无际涯 朝晖夕阴 +heng yang gui yan ji feng shu 衡阳归雁几封书 青枫江上秋帆远 +heng yang yan qu wu liu yi 衡阳雁去无留意 四面边声连角起 +hong bo yong qi 洪波涌起 日月之行 +hong dou sheng nan guo 红豆生南国 春来发几枝 +hong du xin fu 洪都新府 星分翼轸 +hong fei ming ming ri yue bai 鸿飞冥冥日月白 青枫叶赤天雨霜 +hong jin cui xiu 红巾翠袖 揾英雄泪 +hong lou ge yu xiang wang leng 红楼隔雨相望冷 珠箔飘灯独自归 +hong ni xiao huo lu 红泥小火炉 晚来天欲雪 +hong ran zhong kai 訇然中开 青冥浩荡不见底 +hong yan ji shi dao 鸿雁几时到 江湖秋水多 +hong yan wei lao en xian duan 红颜未老恩先断 斜倚薰笼坐到明 +hong ye wan xiao xiao 红叶晚萧萧 长亭酒一瓢 +hong zhang bo qing bo 红掌拨清波 +hong zhuang bai ri xian 红妆白日鲜 蚕饥妾欲去 +hong zhuang er jian zhe 红装而蹇者 亦时时有 +hong zi jiu de 宏兹九德 简能而任之 +hou bu jian lai zhe 后不见来者 念天地之悠悠 +hou gong jia li san qian ren 后宫佳丽三千人 三千宠爱在一身 +hou lai an ma he qun xun 后来鞍马何逡巡 当轩下马入锦茵 +hou ren ai zhi er bu jian zhi 后人哀之而不鉴之 亦使后人而复哀后人也 +hou sui wu wen jin zhe 后遂无问津者 +hou tian xia zhi le er le 后天下之乐而乐 乎 +hou wang jiang xiang wang jiu jue 侯王将相望久绝 神纵欲福难为功 +hou zhi lan zhe 后之览者 亦将有感于斯文 +hou zhi qing fu 后值倾覆 受任于败军之际 +hou zhi shi jin 后之视今 亦犹今之视昔 +hu ben teng er peng pai 忽奔腾而砰湃 如波涛夜惊 +hu bu jian wo yu wang 胡不见我于王 公输盘曰 +hu da feng qi 忽大风起 尘气莽莽然 +hu dao pang gong qi yin chu 忽到庞公栖隐处 岩扉松径长寂寥 +hu er er yu zhi 呼尔而与之 行道之人弗受 +hu er jiang chu huan mei jiu 呼儿将出换美酒 与尔同销万古愁 +hu feng tao hua lin 忽逢桃花林 夹岸数百步 +hu fu cheng zhou meng ri bian 忽复乘舟梦日边 行路难 +hu gu se xi luan hui che 虎鼓瑟兮鸾回车 仙之人兮列如麻 +hu guang qiu yue liang xiang he 湖光秋月两相和 潭面无风镜未磨 +hu hao fen fa 呼号愤发 丰草绿缛而争茂 +hu hun ji yi po dong 忽魂悸以魄动 恍惊起而长嗟 +hu jian mo tou yang liu se 忽见陌头杨柳色 悔教夫婿觅封侯 +hu jian shan tou you gu ta song qi 忽见山头有孤塔耸起 高插青冥 +hu kui qing hai wan 胡窥青海湾 由来征战地 +hu nian shan zhong ke 忽念山中客 涧底束荆薪 +hu qi ping ling za feng yu 胡骑凭陵杂风雨 战士军前半死生 +hu qin pi pa yu qiang di 胡琴琵琶与羌笛 纷纷暮雪下辕门 +hu qiu bu nuan jin qin bo 狐裘不暖锦衾薄 将军角弓不得控 +hu ran bi kou li 忽然闭口立 +hu ren luo lei zhan bian cao 胡人落泪沾边草 汉使断肠对归客 +hu ru yi ye chun feng lai 忽如一夜春风来 千树万树梨花开 +hu shang ying zi 湖上影子 惟长堤一痕 +hu ti qiu zhi 忽啼求之 父异焉 +hu tian ba yue ji fei xue 胡天八月即飞雪 忽如一夜春风来 +hu wei er lai zai 胡为而来哉 盖夫秋之为状也 +hu wen an shang ta ge sheng 忽闻岸上踏歌声 桃花潭水深千尺 +hu wen hai shang you xian shan 忽闻海上有仙山 山在虚无缥渺间 +hu wen shui shang pi pa sheng 忽闻水上琵琶声 主人忘归客不发 +hu xiang xuan miao 互相轩邈 争高直指 +hu xiao yuan ti 虎啸猿啼 登斯楼也 +hu xin ting yi dian 湖心亭一点 与余舟一芥 +hu ye luo shan lu 槲叶落山路 枳花明驿墙 +hu you pang ran da wu 忽有庞然大物 拔山倒树而来 +hu yue zhao wo ying 湖月照我影 送我至剡溪 +hu zhong ren niao sheng ju jue 湖中人鸟声俱绝 是日更定矣 +hu zhong yan de geng you ci ren 湖中焉得更有此人 拉余同饮 +hu zuo bai yu pan 呼作白玉盘 又疑瑶台镜 +hua dian wei di wu ren shou 花钿委地无人收 翠翘金雀玉搔头 +hua dong zhao fei nan pu yun 画栋朝飞南浦云 珠帘暮卷西山雨 +hua guan bu zheng xia tang lai 花冠不整下堂来 风吹仙袂飘飘举 +hua jian yi hu jiu 花间一壶酒 独酌无相亲 +hua jin gao lou shang ke xin 花近高楼伤客心 万方多难此登临 +hua jing bu ceng yuan ke sao 花径不曾缘客扫 蓬门今始为君开 +hua kai kan zhe zhi xu zhe 花开堪折直须折 莫待无花空折枝 +hua lou xi pan gui tang dong 画楼西畔桂堂东 身无彩凤双飞翼 +hua lu ru xi kou 花路入溪口 际夜转西壑 +hua luo zhi duo shao 花落知多少 +hua mei shen qian ru shi wu 画眉深浅入时无 +hua men li mian qing xue chi 花门剺面请雪耻 慎勿出口他人狙 +hua mu cheng qi shou zi zai 花木成畦手自栽 一水护田将绿绕 +hua tai xiao cao cong za chu 花台小草丛杂处 蹲其身 +hua ting he li ju ke wen 华亭鹤唳讵可闻 上蔡苍鹰何足道 +hua tu sheng shi chun feng mian 画图省识春风面 环佩空归夜月魂 +hua wei deng lin 化为邓林 +hua wei lang yu chai 化为狼与豺 朝避猛虎 +hua yin ye yuan mu 花隐掖垣暮 啾啾栖鸟过 +hua ying jian pei xing chu luo 花迎剑珮星初落 柳拂旌旗露未干 +hua zhao jie hou 花朝节后 余寒犹厉 +hua zhi fu gui zhe ye 花之富贵者也 莲 +hua zhi jun zi zhe ye 花之君子者也 噫 +hua zhi yin yi zhe ye 花之隐逸者也 牡丹 +hua zhong jin guan cheng 花重锦官城 +hua zuo chun ni geng hu hua 化作春泥更护花 +hua zuo xiang si lei 化作相思泪 +huai di hun er bu jian 怀帝阍而不见 奉宣室以何年 +huai jiu kong yin wen di fu 怀旧空吟闻笛赋 到乡翻似烂柯人 +huai jun shu qiu ye 怀君属秋夜 散步咏凉天 +huai min yi wei qin 怀民亦未寝 相与步于中庭 +huai nan mu luo chu shan duo 淮南木落楚山多 寄身且喜沧洲近 +huai nu wei fa 怀怒未发 休祲降于天 +huai shang dui qiu shan 淮上对秋山 +huai yin shi jing xiao han xin 淮阴市井笑韩信 汉朝公卿忌贾生 +huan chu jie shan ye 环滁皆山也 其西南诸峰 +huan du xiao ran 环堵萧然 不蔽风日 +huan er gong zhi er bu sheng 环而攻之而不胜 夫环而攻之 +huan ge man wu ning si zhu 缓歌慢舞凝丝竹 尽日君王看不足 +huan pei kong gui ye yue hun 环佩空归夜月魂 千载琵琶作胡语 +huan shan shan shi 奂山山市 邑八景之一也 +huan xiao qing ru jiu 欢笑情如旧 萧疏鬓已斑 +huan yan de suo qi 欢言得所憩 美酒聊共挥 +huan yu jiu du 还于旧都 此臣所以报先帝而忠陛下之职分也 +huang ai san man feng xiao suo 黄埃散漫风萧索 云栈萦纡登剑阁 +huang chen zu jin gu 黄尘足今古 白骨乱蓬蒿 +huang cheng lin gu du 荒城临古渡 落日满秋山 +huang fa chui tiao 黄发垂髫 并怡然自乐 +huang gong yue yi shou juan 黄共阅一手卷 东坡右手执卷端 +huang he ru hai liu 黄河入海流 欲穷千里目 +huang he yi qu bu fu fan 黄鹤一去不复返 白云千载空悠悠 +huang he yuan shang bai yun jian 黄河远上白云间 一片孤城万仞山 +huang he zhi fei shang bu de guo 黄鹤之飞尚不得过 猿猱欲度愁攀援 +huang he zhi shui tian shang lai 黄河之水天上来 奔流到海不复回 +huang hua kai bian 黄花开遍 秋容如拭 +huang hun dao si bian fu fei 黄昏到寺蝙蝠飞 升堂坐阶新雨足 +huang jing qi er zhang jie 恍惊起而长嗟 惟觉时之枕席 +huang kong tan tou shuo huang kong 惶恐滩头说惶恐 零丁洋里叹零丁 +huang lu ku zhu rao zhai sheng 黄芦苦竹绕宅生 其间旦暮闻何物 +huang mei shi jie jia jia yu 黄梅时节家家雨 青草池塘处处蛙 +huang men fei kong bu dong chen 黄门飞鞚不动尘 御厨络绎送八珍 +huang sha bai zhan chuan jin jia 黄沙百战穿金甲 不破楼兰终不还 +huang shan jiu rao han gong xie 黄山旧绕汉宫斜 銮舆迥出千门柳 +huang shi ta qian jiang shui dong 黄师塔前江水东 春光懒困倚微风 +huang shu luo huang ye 荒戍落黄叶 浩然离故关 +huang si niang jia hua man xi 黄四娘家花满蹊 千朵万朵压枝低 +huang ye reng feng yu 黄叶仍风雨 青楼自管弦 +huang yi shi zhe bai shan er 黄衣使者白衫儿 手把文书口称敕 +huang yun long di bai xue fei 黄云陇底白雪飞 未得报恩不能归 +huang zhu ge sheng dong di ai 黄竹歌声动地哀 八骏日行三万里 +hui che chi niu qian xiang bei 回车叱牛牵向北 一车炭 +hui chi fang qiu 挥斥方遒 指点江山 +hui dang ling jue ding 会当凌绝顶 一览众山小 +hui feng he chang 惠风和畅 仰观宇宙之大 +hui hong zhi shi zhi qi 恢弘志士之气 不宜妄自菲薄 +hui jiao fu xu mi feng hou 悔教夫婿觅封侯 +hui kan tian ji xia zhong liu 回看天际下中流 岩上无心云相逐 +hui kan xue lei xiang he liu 回看血泪相和流 黄埃散漫风萧索 +hui le feng qian sha si xue 回乐峰前沙似雪 受降城外月如霜 +hui ming bian hua zhe 晦明变化者 山间之朝暮也 +hui mou yi xiao bai mei sheng 回眸一笑百媚生 六宫粉黛无颜色 +hui nv zhi zhi hu 诲女知之乎 知之为知之 +hui qing dao ying 回清倒影 绝巘多生怪柏 +hui ri lou tai fei jia zhang 回日楼台非甲帐 去时冠剑是丁年 +hui shou bai yun di 回首白云低 +hui shou zi zi qu 挥手自兹去 萧萧班马鸣 +hui tou xia wang ren huan chu 回头下望人寰处 不见长安见尘雾 +hui wan diao gong ru man yue 会挽雕弓如满月 西北望 +hui xiang yao tai yue xia feng 会向瑶台月下逢 +hui xing xi yue 彗星袭月 聂政之刺韩傀也 +hui xu yi yin san bai bei 会须一饮三百杯 岑夫子 +hui yu hui ji shan yin zhi lan ting 会于会稽山阴之兰亭 修禊事也 +hui zhou bu dai yue 回舟不待月 归去越王家 +hui zi xiang liang 惠子相梁 庄子往见之 +hun fan guan sai hei 魂返关塞黑 落月满屋梁 +hun lai feng lin qing 魂来枫林青 魂返关塞黑 +hun po bu ceng lai ru meng 魂魄不曾来入梦 临邛道士鸿都客 +hun yu bu sheng zan 浑欲不胜簪 +huo ban jie jing mang 火伴皆惊忙 同行十二年 +huo cong shi wu bei fang he 或从十五北防河 便至四十西营田 +huo e qi qi yu di ye 货恶其弃于地也 不必藏于己 +huo kong shi tong xiang 或恐是同乡 +huo ping huo li 或凭或立 不一状 +huo qi qi lin yi feng huang 或骑麒麟翳凤凰 芙蓉旌旗烟雾落 +huo qu zhu huai bao 或取诸怀抱 悟言一室之内 +huo ran kai lang 豁然开朗 土地平旷 +huo wang ming ji xuan 或王命急宣 有时朝发白帝 +huo wei di huang zu yao guai 火维地荒足妖怪 天假神柄专其雄 +huo wei hui zi yue 或谓惠子曰 庄子来 +huo yi er zhe zhi wei 或异二者之为 何哉 +huo yi qian bi qi zhi 或以钱币乞之 父利其然也 +huo yin ji suo tuo 或因寄所托 放浪形骸之外 +huo yu qi chi duo 或遇其叱咄 色愈恭 +huo zhi jiu er zhao zhi 或置酒而招之 造饮辄尽 +ji ben yun yu bo hai zhi wei 箕畚运于渤海之尾 邻人京城氏之孀妻有遗男 +ji bo yu qiong nian 羁泊欲穷年 黄叶仍风雨 +ji chu zao ying zheng nuan shu 几处早莺争暖树 谁家新燕啄春泥 +ji cong ba xia chuan wu xia 即从巴峡穿巫峡 便下襄阳向洛阳 +ji de zhi ze zong qing yi ao wu 既得志则纵情以傲物 竭诚则吴 +ji dong feng zheng 既东封郑 又欲肆其西封 +ji du ge shan chuan 几度隔山川 乍见翻疑梦 +ji er feng ding tian qing 既而风定天清 一切乌有 +ji er jian jin 既而渐近 则玉城雪岭际天而来 +ji er jin ben teng fen he wu zhen zhi shi 既而尽奔腾分合五阵之势 并有乘骑弄旗标枪舞刀于水面者 +ji fu you yu tian di 寄蜉蝣于天地 渺沧海之一粟 +ji gang luan zhi ti shi 即冈峦之体势 披绣闼 +ji geng gua mu xiang dai 即更刮目相待 大兄何见事之晚乎 +ji gong da xiong wu yi nv 即公大兄无奕女 左将军王凝之妻也 +ji hai zhi er yue ye 己亥之二月也 +ji ji fu ji ji 唧唧复唧唧 木兰当户织 +ji ji hua shi bi yuan men 寂寂花时闭院门 美人相并立琼轩 +ji ji jiang shan yao luo chu 寂寂江山摇落处 怜君何事到天涯 +ji ji jing he dai 寂寂竟何待 朝朝空自归 +ji ju er ao 箕踞而遨 则凡数州之土壤 +ji kong ming xi su liu guang 击空明兮溯流光 渺渺兮予怀 +ji li xi ni gu rou yun 肌理细腻骨肉匀 绣罗衣裳照暮春 +ji liao wu ren 寂寥无人 凄神寒骨 +ji lu su guo xun yang 及鲁肃过寻阳 与蒙论议 +ji ming zi mo shu guang han 鸡鸣紫陌曙光寒 莺啭皇州春色阑 +ji mo kai wu zhu 寂寞开无主 已是黄昏独自愁 +ji mo kong ting chun yu wan 寂寞空庭春欲晚 梨花满地不开门 +ji mo shen hou shi 寂寞身后事 +ji mo wu tong shen yuan suo qing qiu 寂寞梧桐深院锁清秋 剪不断 +ji qi suo zhi ji juan 及其所之既倦 情随事迁 +ji quan xiang wen 鸡犬相闻 其中往来种作 +ji ri dao lin tao 几日到临洮 +ji ri yi huan 计日以还 天大寒 +ji shen qie xi cang zhou jin 寄身且喜沧洲近 顾影无如白发何 +ji sheng mao dian yue 鸡声茅店月 人迹板桥霜 +ji shi ji nian 几世几年 摽掠其人 +ji shi nai you huo 击石乃有火 不击元无烟 +ji shu chang bu da 寄书长不达 况乃未休兵 +ji shu shi si ju 即书诗四句 并自为其名 +ji suo bu yu 己所不欲 勿施于人 +ji tuan shen jian 急湍甚箭 猛浪若奔 +ji wo jiao fei jiu 寄卧郊扉久 何年致此身 +ji xue fu yun duan 积雪浮云端 林表明霁色 +ji yang wen zi 激扬文字 粪土当年万户侯 +ji ye zhuan xi he 际夜转西壑 隔山望南斗 +ji yi ling guai yi zai 技亦灵怪矣哉 +ji ying gui wei 季鹰归未 求田问舍 +ji ying he yang yi 急应河阳役 犹得备晨炊 +ji yu you yu xia ri 极娱游于暇日 天高地迥 +ji zhi cheng xian sui bu huan 及至成仙遂不还 峡里谁知有人事 +ji zhi jin men yuan 既至金门远 孰云吾道非 +ji zhong jin zi lun chang hen 机中锦字论长恨 楼上花枝笑独眠 +ji zui er tui 既醉而退 曾不吝情去留 +ji zuo you zhou zi ge yi ren 楫左右舟子各一人 居右者椎髻仰面 +jia an gao shan 夹岸高山 皆生寒树 +jia an shu bai bu 夹岸数百步 中无杂树 +jia de qu tang gu 嫁得瞿塘贾 朝朝误妾期 +jia guang xiang ri jin lin kai 甲光向日金鳞开 角声满天秋色里 +jia ji wu wang gao nai weng 家祭无忘告乃翁 +jia jia qi qiao wang qiu yue 家家乞巧望秋月 穿尽红丝几万条 +jia jie you chong yang 佳节又重阳 玉枕纱厨 +jia jun zuo zai 家君作宰 路出名区 +jia liang zhi chuan 架梁之椽 多于机上之工女 +jia lin jiu jiang shui 家临九江水 来去九江侧 +jia mu cong long er ke yue 佳木葱茏而可悦 草拂之而色变 +jia mu xiu er fan yin 佳木秀而繁阴 风霜高洁 +jia pin bu neng chang de 家贫不能常得 亲旧知其如此 +jia sheng cai diao geng wu lun 贾生才调更无伦 可怜夜半虚前席 +jia shi kui lian han yuan shao 贾氏窥帘韩掾少 宓妃留枕魏王才 +jia shu dao ge nian 家书到隔年 沧江好烟月 +jia shu di wan jin 家书抵万金 白头搔更短 +jia tian shu shui jin 家田输税尽 拾此充饥肠 +jia tong sao luo jing 家童扫萝径 昨与故人期 +jia yi shang shu you han shi 贾谊上书忧汉室 长沙谪去古今怜 +jia yi ye zhi pian zhou 驾一叶之扁舟 举匏樽以相属 +jia yu nong chao er 嫁与弄潮儿 +jia yu qian lou bai shi guai 嫁与黔娄百事乖 顾我无衣搜画箧 +jia zai xia ma ling xia zhu 家在虾蟆陵下住 十三学得琵琶成 +jia zhong you a shui 家中有阿谁 遥看是君家 +jia zhu ceng cheng lin han yuan 家住层城临汉苑 心随明月到胡天 +jia zhu wu men 家住吴门 久作长安旅 +jian bu xian er nei zi xing ye 见不贤而内自省也 +jian chen qie ming 奸臣窃命 主上蒙尘 +jian di shu jing xin 涧底束荆薪 归来煮白石 +jian er chong dou cao jian 见二虫斗草间 观之 +jian ge zheng rong er cui wei 剑阁峥嵘而崔嵬 一夫当关 +jian gong dian shu shi suo 见宫殿数十所 碧瓦飞甍 +jian gong shu pan 见公输盘 公输盘曰 +jian guan ying yu hua di hua 间关莺语花底滑 幽咽泉流冰下难 +jian he feng ji xue pian kuo 剑河风急雪片阔 沙口石冻马蹄脱 +jian jia cai cai 蒹葭采采 白露未已 +jian jia cang cang 蒹葭苍苍 白露为霜 +jian jia qi qi 蒹葭萋萋 白露未晞 +jian ju chou yan lan qi lu 槛菊愁烟兰泣露 罗幕轻寒 +jian miao xiao zhi wu bi xi cha qi wen li 见藐小之物必细察其纹理 故时有物外之趣 +jian nan ku hen fan shuang bin 艰难苦恨繁霜鬓 潦倒新停浊酒杯 +jian nan kui shen qing 艰难愧深情 歌罢仰天叹 +jian neng er ren zhi 简能而任之 择善而从之 +jian shan shang ren yan shi si 见山上人烟市肆 与世无别 +jian wai chang jiang kong zi liu 槛外长江空自流 +jian wai hu chuan shou ji bei 剑外忽传收蓟北 初闻涕泪满衣裳 +jian wang shi er 见往事耳 卿言多务 +jian wen shui sheng chan chan er xie chu yu liang feng zhi jian zhe 渐闻水声潺潺而泻出于两峰之间者 酿泉也 +jian xian si qi yan 见贤思齐焉 见不贤而内自省也 +jian ye mu zhong shi 建业暮钟时 漠漠帆来重 +jiang chong ji tian zhi jun 将崇极天之峻 永保无疆之休 +jiang chuan huo du ming 江船火独明 晓看红湿处 +jiang chun ru jiu nian 江春入旧年 乡书何处达 +jiang deng tai hang xue man shan 将登太行雪满山 闲来垂钓碧溪上 +jiang feng yu huo dui chou mian 江枫渔火对愁眠 姑苏城外寒山寺 +jiang gan shang xia shi yu li jian 江干上下十余里间 珠翠罗绮溢目 +jiang han ceng wei ke 江汉曾为客 相逢每醉还 +jiang han zhi yu bie yuan tuo wei tian xia fu 江汉之鱼鳖鼋鼍为天下富 宋所谓无雉兔鲋鱼者也 +jiang heng yu xi yao 江横玉系腰 者是也 +jiang hu duo feng bo 江湖多风波 舟楫恐失坠 +jiang hu qiu shui duo 江湖秋水多 文章憎命达 +jiang huai du han shi 江淮度寒食 京洛缝春衣 +jiang jing chao chu luo 江静潮初落 林昏瘴不开 +jiang jun bai fa zheng fu lei 将军白发征夫泪 +jiang jun bai zhan si 将军百战死 壮士十年归 +jiang jun ji di shi zhi zhou 将军既帝室之胄 信义著于四海 +jiang jun jiao gong bu de kong 将军角弓不得控 都护铁衣冷难着 +jiang jun jin jia ye bu tuo 将军金甲夜不脱 半夜军行戈相拨 +jiang jun qi you yi hu 将军岂有意乎 益州险塞 +jiang jun qi yuan jian zhi hu 将军岂愿见之乎 先主曰 +jiang jun shen lv yi zhou zhi zhong chu yu qin chuan 将军身率益州之众出于秦川 百姓孰敢不箪食壶浆以迎将军者乎 +jiang jun xiang chong 将军向宠 性行淑均 +jiang jun ye yin gong 将军夜引弓 平明寻白羽 +jiang jun yi wang jia gu zhi 将军宜枉驾顾之 由是先主遂诣亮 +jiang lao shen fan lei 将老身反累 千秋万岁名 +jiang liu gong feng yan 江柳共风烟 已似长沙傅 +jiang liu qu si jiu hui chang 江流曲似九回肠 共来百越文身地 +jiang liu shi bu zhuan 江流石不转 遗恨失吞吴 +jiang liu tian di wai 江流天地外 山色有无中 +jiang nan ke cai lian 江南可采莲 莲叶何田田 +jiang nan you dan ju 江南有丹橘 经冬犹绿林 +jiang nan you zi 江南游子 把吴钩看了 +jiang nan zhang li di 江南瘴疠地 逐客无消息 +jiang qing yue jin ren 江清月近人 +jiang ru da huang liu 江入大荒流 月下飞天镜 +jiang shan dai you cai ren chu 江山代有才人出 各领风骚数百年 +jiang shan gu zhai kong wen zao 江山故宅空文藻 云雨荒台岂梦思 +jiang shan ru ci duo jiao 江山如此多娇 引无数英雄竞折腰 +jiang shan ru hua 江山如画 一时多少豪杰 +jiang shang ji ren zai 江上几人在 天涯孤棹还 +jiang shang qiu feng dong ke qing 江上秋风动客情 知有儿童挑促织 +jiang shang wang lai ren 江上往来人 但爱鲈鱼美 +jiang shang yue ming hu yan guo 江上月明胡雁过 淮南木落楚山多 +jiang wang fu xuan ru you qing 将往复旋如有情 空山百鸟散还合 +jiang wo gu cun bu zi ai 僵卧孤村不自哀 尚思为国戍轮台 +jiang xin xiu mu 讲信修睦 故人不独亲其亲 +jiang yan qu zhi 将焉取之 阙秦以利晋 +jiang yi gong song 将以攻宋 子墨子闻之 +jiang yin wo bing jie chao yi 将因卧病解朝衣 +jiang yu fei fei jiang cao qi 江雨霏霏江草齐 六朝如梦鸟空啼 +jiang ze ji ren song xiao chou 绛帻鸡人送晓筹 尚衣方进翠云裘 +jiang zhou si ma qing shan shi 江州司马青衫湿 +jiao er e wo ta li lie 娇儿恶卧踏里裂 床头屋漏无干处 +jiao fang a jian qing e lao 椒房阿监青娥老 夕殿萤飞思悄然 +jiao ge ju yu yu yan zhi zhong 胶鬲举于鱼盐之中 管夷吾举于士 +jiao jiao zhen mu dian 矫矫珍木巅 得无金丸惧 +jiao long chu mo xing wu hao 蛟龙出没猩鼯号 十生九死到官所 +jiao qie ruo wei rong 教妾若为容 风暖鸟声碎 +jiao sheng man tian qiu se li 角声满天秋色里 塞上燕脂凝夜紫 +jiao shou ang shi 矫首昂视 神情与苏 +jiao zhu xie gong ji 脚著谢公屐 身登青云梯 +jie bu yi zhi shi ye 皆布衣之士也 怀怒未发 +jie cheng ze wu 竭诚则吴 越为一体 +jie chu jiu shi 皆出酒食 停数日 +jie de chun feng wu xian hen 解得春风无限恨 沈香亭北倚阑干 +jie er yuan dao zhi ren hu wei hu lai zai 嗟尔远道之人胡为乎来哉 剑阁峥嵘而崔嵬 +jie hou kan ying wan 节候看应晚 心期卧亦赊 +jie hu shi zhu zhong wu lai 羯胡事主终无赖 词客哀时且未还 +jie jun ci bie yi he ru 嗟君此别意何如 驻马衔杯问谪居 +jie lu zai ren jing 结庐在人境 而无车马喧 +jie luo san qiu ye 解落三秋叶 能开二月花 +jie meng shi zhi fang lin 接孟氏之芳邻 他日趋庭 +jie pang jin yu zhi 借旁近与之 即书诗四句 +jie pi fa wen shen 皆披发文身 手持十幅大彩旗 +jie ruo kong you wu suo yi 皆若空游无所依 日光下澈 +jie she yi jian 戒奢以俭 德不处其厚 +jie sheng han shu 皆生寒树 负势竞上 +jie shi san he mu nian shao 节使三河募年少 诏书五道出将军 +jie tian lian ye wu qiong bi 接天莲叶无穷碧 映日荷花别样红 +jie wen han gong shui de si 借问汉宫谁得似 可怜飞燕倚新妆 +jie wen jiu jia he chu you 借问酒家何处有 牧童遥指杏花村 +jie wen lu bang ming li ke 借问路傍名利客 何如此处学长生 +jie wo you ye 皆我有也 而未始知西山之怪特 +jie yi yu shui 解衣欲睡 月色入户 +jie you er bie 结友而别 +jie yu ting gu ying guan qu 嗟余听鼓应官去 走马兰台类转蓬 +jie zai ren xi zhi xia 皆在衽席之下 其高下之势 +jie zai wu dang er san zi 嗟哉吾党二三子 安得至老不更归 +jin bian duan zhe jiu ma si 金鞭断折九马死 骨肉不待同驰驱 +jin cao yi yong bai wan zhi zhong 今操已拥百万之众 挟天子而令诸侯 +jin chan nie suo shao xiang ru 金蟾啮锁烧香入 玉虎牵丝汲井回 +jin cheng liu lei quan 今成流泪泉 不信妾肠断 +jin cheng si guan ri fen fen 锦城丝管日纷纷 半入江风半入云 +jin cheng sui yun le 锦城虽云乐 不如早还家 +jin dai yi guan cheng gu qiu 晋代衣冠成古丘 三山半落青天外 +jin dang yuan li 今当远离 临表涕零 +jin de gui jing yi 近得归京邑 移官岂至尊 +jin fan ying shi dao tian ya 锦帆应是到天涯 于今腐草无萤火 +jin fu bu shou zhi tian 今夫不受之天 固众人 +jin ge tie ma 金戈铁马 气吞万里如虎 +jin guan cheng wai bai sen sen 锦官城外柏森森 映阶碧草自春色 +jin ji er qiu zi 今急而求子 是寡人之过也 +jin jian gong ming sheng gu ren 今见功名胜古人 +jin jiang chun se lai tian di 锦江春色来天地 玉垒浮云变古今 +jin jin zhong yan 进尽忠言 则攸之 +jin jun han ling 晋军函陵 秦军氾南 +jin kan liang ying dian 今看两楹奠 当与梦时同 +jin kou ting zhou du bu de 津口停舟渡不得 郑国游人未及家 +jin kuai zhu li 金块珠砾 弃掷逦迤 +jin lei wu gan tu 近泪无干土 低空有断云 +jin li shu zhong guan she wan 禁里疏钟官舍晚 省中啼鸟吏人稀 +jin lin you yong 锦鳞游泳 岸芷汀兰 +jin ling jin du xiao shan lou 金陵津渡小山楼 一宿行人自可愁 +jin ling zi di lai xiang song 金陵子弟来相送 欲行不行各尽觞 +jin mao diao qiu 锦帽貂裘 千骑卷平冈 +jin men gong shu yue hen guo 禁门宫树月痕过 媚眼惟看宿鹭窠 +jin nan fang yi ding 今南方已定 兵甲已足 +jin nian huan xiao fu ming nian 今年欢笑复明年 秋月春风等闲度 +jin nian jiu yue er shi ba ri 今年九月二十八日 因坐法华西亭 +jin que xi xiang kou yu jiong 金阙西厢叩玉扃 转教小玉报双成 +jin que xiao zhong kai wan hu 金阙晓钟开万户 玉阶仙仗拥千官 +jin ren duo bu tan 今人多不弹 +jin ri bu neng wang 尽日不能忘 +jin ri chui yang sheng zuo zhou 今日垂杨生左肘 路旁时卖故侯瓜 +jin ri feng qian guo shi wan 今日俸钱过十万 与君营奠复营斋 +jin ri hua kai yi yi nian 今日花开已一年 世事茫茫难自料 +jin ri jun wang kan bu zu 尽日君王看不足 渔阳鼙鼓动地来 +jin ri long zhong ren gong qi 今日龙钟人共弃 愧君犹遣慎风波 +jin ri shi ye 今日是也 挺剑而起 +jin ri ting jun ge yi qu 今日听君歌一曲 暂凭杯酒长精神 +jin ri you nan guan 今日又南冠 无限山河泪 +jin ri zhi ri duo fan you 今日之日多烦忧 长风万里送秋雁 +jin ru qian jia san hua zhu 近入千家散花竹 樵客初传汉姓名 +jin ru yin xian 仅如银线 既而渐近 +jin san jiang er dai wu hu 襟三江而带五湖 控蛮荆而引瓯越 +jin se wu duan wu shi xian 锦瑟无端五十弦 一弦一柱思华年 +jin shan xi jian yan chen fei 金山西见烟尘飞 汉家大将西出师 +jin shang yue yang lou 今上岳阳楼 吴楚东南坼 +jin shi ta xiang zhi ke 尽是他乡之客 怀帝阍而不见 +jin shou zhi er bu zu 今守之而不足 何也 +jin tai ji yan 尽态极妍 缦立远视 +jin tai yuan zhong 晋太元中 武陵人捕鱼为业 +jin tao yuan ming du ai ju 晋陶渊明独爱菊 自李唐来 +jin tian xia san fen 今天下三分 益州疲弊 +jin tie jie ming 金铁皆鸣 又如赴敌之兵 +jin ting shui wu sheng 近听水无声 春去花还在 +jin wei gong shi zhi mei wei zhi 今为宫室之美为之 乡为身死而不受 +jin wei qi qie zhi feng wei zhi 今为妻妾之奉为之 乡为身死而不受 +jin wei qiang di chu sai sheng 今为羌笛出塞声 使我三军泪如雨 +jin wei suo shi qiong fa zhe de wo er wei zhi 今为所识穷乏者得我而为之 是亦不可以已乎 +jin wo bu le si yue yang 今我不乐思岳阳 身欲奋飞病在床 +jin wo he gong de 今我何功德 曾不事农桑 +jin wo lai si 今我来思 雨雪霏霏 +jin wo you ming ming 今我游冥冥 弋者何所慕 +jin wu du zi zhi nan qiong ye 今吾睹子之难穷也 吾非至于子之门 +jin wu wu ren jian lei hen 金屋无人见泪痕 寂寞空庭春欲晚 +jin wu yi shi bei zhi di 今吾以十倍之地 请广于君 +jin wu zhuang cheng jiao shi ye 金屋妆成娇侍夜 玉楼宴罢醉和春 +jin xi min fang zhong yong 金溪民方仲永 世隶耕 +jin xi shi he nian 今夕是何年 我欲乘风归去 +jin xiao jiu xing he chu 今宵酒醒何处 杨柳岸 +jin ye fu zhou yue 今夜鄜州月 闺中只独看 +jin ye pian zhi chun qi nuan 今夜偏知春气暖 虫声新透绿窗纱 +jin ye wen jun pi pa yu 今夜闻君琵琶语 如听仙乐耳暂明 +jin you ren yu ci 今有人于此 舍其文轩 +jin zhao ci wei bie 今朝此为别 何处还相遇 +jin zhao jun zhai leng 今朝郡斋冷 忽念山中客 +jin zhao xi zi fei 今朝蟢子飞 铅华不可弃 +jin zi peng mei 今兹捧袂 喜托龙门 +jin zi yu yi zi zhi liang guo er xia wo xie 今子欲以子之梁国而吓我邪 +jin zun qing jiu dou shi qian 金樽清酒斗十千 玉盘珍羞直万钱 +jing dong you lu lin 经冬犹绿林 岂伊地气暖 +jing feng luan zhan fu rong shui 惊风乱飐芙蓉水 密雨斜侵薜荔墙 +jing guo you yu yu di 荆国有余于地 而不足于民 +jing hu san bai li 镜湖三百里 菡萏发荷花 +jing jing ran ru jing zhi xin kai er leng guang zhi zha chu yu xia ye 晶晶然如镜之新开而冷光之乍出于匣也 山峦为晴雪所洗 +jing kou gua zhou yi shui jian 京口瓜洲一水间 钟山只隔数重山 +jing liu zhi da 泾流之大 两涘渚崖之间 +jing lun shi wu zhe 经纶世务者 窥谷忘反 +jing luo feng chun yi 京洛缝春衣 置酒长安道 +jing men jiu pai tong 荆门九派通 江流天地外 +jing pei wei yi jie shi jian 旌旆逶迤碣石间 校尉羽书飞瀚海 +jing po ni chang yu yi qu 惊破霓裳羽衣曲 九重城阙烟尘生 +jing qi bi kong 旌旗蔽空 酾酒临江 +jing qi wu guang ri se bo 旌旗无光日色薄 蜀江水碧蜀山青 +jing qi yi tan ou lu 惊起一滩鸥鹭 +jing shang sheng lv kui 井上生旅葵 舂谷持作饭 +jing tao pai an 惊涛拍岸 卷起千堆雪 +jing ting song feng han 静听松风寒 古调虽自爱 +jing wen su ke zheng lai ji 惊闻俗客争来集 竞引还家问都邑 +jing wu yu ning ye 竟无语凝噎 念去去 +jing xi qi xiang si 竟夕起相思 灭烛怜光满 +jing xi zi bei qiu 竟夕自悲秋 +jing xu gu qu dui jun zhuo 径须沽取对君酌 五花马 +jing yang gong jing you he ren 景阳宫井又何人 +jing yin huan jia wen du yi 竞引还家问都邑 平明闾巷扫花开 +jing ying chen bi 静影沉璧 渔歌互答 +jing you chang song wen zi pian nan yu zhang 荆有长松文梓楩楠豫章 宋无长木 +jing you yun meng 荆有云梦 犀兕麋鹿满之 +jing zao bu tong 静躁不同 当其欣于所遇 +jing zhi di fang wu qian li 荆之地方五千里 宋之地方五百里 +jing zhou bei ju han 荆州北据汉 沔 +jiu chong cheng que yan chen sheng 九重城阙烟尘生 千乘万骑西南行 +jiu chong shui sheng jian shu han 九重谁省谏书函 春风举国裁宫锦 +jiu er nai he 久而乃和 寓逆旅 +jiu guo jian qing shan 旧国见青山 晓月过残垒 +jiu han xiong dan shang kai zhang 酒酣胸胆尚开张 鬓微霜 +jiu hao ge liang yuan 旧好隔良缘 心断新丰酒 +jiu hua zhang li meng hun jing 九华帐里梦魂惊 揽衣推枕起徘徊 +jiu jiu qi niao guo 啾啾栖鸟过 星临万户动 +jiu kun lu chang wei yu shui 酒困路长惟欲睡 日高人渴漫思茶 +jiu ling jin pu gu 鹫翎金仆姑 燕尾绣蝥弧 +jiu qu huang he wan li sha 九曲黄河万里沙 浪淘风簸自天涯 +jiu ru chou chang 酒入愁肠 化作相思泪 +jiu shi mao dian she lin bian 旧时茅店社林边 路转溪桥忽见 +jiu shi wang xie tang qian yan 旧时王谢堂前燕 飞入寻常百姓家 +jiu tian chang he kai gong dian 九天阊阖开宫殿 万国衣冠拜冕旒 +jiu wan li feng peng zheng ju 九万里风鹏正举 风休住 +jiu wei zan zu lei 久为簪组累 幸此南夷谪 +jiu ye yi sui zheng zhan jin 旧业已随征战尽 更堪江上鼓鼙声 +jiu zhong yun mu jiao fang qin 就中云幕椒房亲 赐名大国虢与秦 +jiu zhou sheng qi shi feng lei 九州生气恃风雷 万马齐喑究可哀 +jiu zuo chang an lv 久作长安旅 五月渔郎相忆否 +ju bei xiao chou chou geng chou 举杯消愁愁更愁 人生在世不称意 +ju bei yao ming yue 举杯邀明月 对影成三人 +ju can you you ao shuang zhi 菊残犹有傲霜枝 一年好景君须记 +ju cu yi shi zhi nei 局促一室之内 欲出不得 +ju huai yi xing zhuang si fei 俱怀逸兴壮思飞 欲上青天览明月 +ju jiu shu ke 举酒属客 诵明月之诗 +ju jiu yu yin wu guan xian 举酒欲饮无管弦 醉不成欢惨将别 +ju miao tang zhi gao ze you qi min 居庙堂之高则忧其民 处江湖之远则忧其君 +ju pao zun yi xiang zhu 举匏樽以相属 寄蜉蝣于天地 +ju qi bu yi ye 惧其不已也 告之于帝 +ju ran cheng guo yi 居然城郭矣 中有楼若者 +ju ren gong zhu wu ling yuan 居人共住武陵源 还从物外起田园 +ju ren wei gai qin yi fu 居人未改秦衣服 居人共住武陵源 +ju shi er mou yue 聚室而谋曰 吾与汝毕力平险 +ju tou hong ri jin 举头红日近 回首白云低 +ju tou wang ming yue 举头望明月 低头思故乡 +ju wei yi ti 俱为一体 陟罚臧否 +ju xin yue yi gan en fen 剧辛乐毅感恩分 输肝剖胆效英才 +ju you zhe zhui ji yang mian 居右者椎髻仰面 左手倚一衡木 +ju yu zhong zhi da 居域中之大 将崇极天之峻 +ju zi zhou tou 橘子洲头 看万山红遍 +ju zuo zhe you shou zhi pu kui shan 居左者右手执蒲葵扇 左手抚炉 +juan di feng lai hu chui san 卷地风来忽吹散 望湖楼下水如天 +juan juan you ye ming 卷卷有爷名 阿爷无大儿 +juan qi qian dui xue 卷起千堆雪 江山如画 +juan ran ru shi 娟然如拭 鲜妍明媚 +juan shi di yi chu 卷石底以出 为坻 +juan wei wang yue kong chang tan 卷帷望月空长叹 美人如花隔云端 +juan wo wu shang san chong mao 卷我屋上三重茅 茅飞渡江洒江郊 +jue dai you jia ren 绝代有佳人 幽居在空谷 +jue sheng yan liu man huang du 绝胜烟柳满皇都 +jue yan duo sheng guai bai 绝巘多生怪柏 悬泉瀑布 +jue yu cang mang geng he you 绝域苍茫更何有 杀气三时作阵云 +jue yu zhou zhi wu qiong 觉宇宙之无穷 兴尽悲来 +jue zi ru gui niao 决眦入归鸟 会当凌绝顶 +jun bu jian sha chang zheng zhan ku 君不见沙场征战苦 至今犹忆李将军 +jun bu jian wu zhong zhang han cheng da sheng 君不见吴中张翰称达生 秋风忽忆江东行 +jun bu jian xi shi yan jia zhong guo wei 君不见昔时燕家重郭隗 拥篲折节无嫌猜 +jun bu jian zou ma chuan xing xue hai bian 君不见走马川行雪海边 平沙莽莽黄入天 +jun bu wen han jia shan dong er bai zhou 君不闻汉家山东二百州 千村万落生荆杞 +jun cai xing chi 俊采星驰 台隍枕夷夏之交 +jun chen wu shi 君臣无事 可以尽豫游之乐 +jun chen xiang gu jin zhan yi 君臣相顾尽沾衣 东望都门信马归 +jun chen yi yu shi ji hui 君臣已与时际会 树木犹为人爱惜 +jun ge qie xiu ting wo ge 君歌且休听我歌 我歌今与君殊科 +jun ge sheng suan ci qie ku 君歌声酸辞且苦 不能听终泪如雨 +jun jia he chu zhu 君家何处住 妾住在横塘 +jun jin zai luo wang 君今在罗网 何以有羽翼 +jun kan yi ye zhou 君看一叶舟 出没风波里 +jun shu shi er juan 军书十二卷 卷卷有爷名 +jun ting le jun chou 军听了军愁 民听了民怕 +jun wang yan mian jiu bu de 君王掩面救不得 回看血泪相和流 +jun wei ji jiang an chu 君谓计将安出 亮答曰 +jun wen gui qi wei you qi 君问归期未有期 巴山夜雨涨秋池 +jun yi fu qian pu 郡邑浮前浦 波澜动远空 +jun yi wu suo hai 君亦无所害 且君尝为晋君赐矣 +jun yu jia jun qi ri zhong 君与家君期日中 日中不至 +jun yu ju lai 君与俱来 庶曰 +jun zhi bo ye 君之薄也 若舍郑以为东道主 +jun zhi qi nan ye 君知其难也 焉用亡郑以陪邻 +jun zhi suo zhi ye 君之所知也 夫晋 +jun zi gu xiang lai 君自故乡来 应知故乡事 +jun zi hao qiu 君子好逑 参差荇菜 +jun zi yi ru he 君子意如何 鸿雁几时到 +kai guo he mang ran 开国何茫然 尔来四万八千岁 +kai wei yue chu tu 开帷月初吐 清辉淡水木 +kai wo dong ge men 开我东阁门 坐我西阁床 +kai xuan mian chang pu 开轩面场圃 把酒话桑麻 +kai yuan er shi liu nian 开元二十六年 客有从御史大夫张公出塞而还者 +kai zhuang jing ye 开妆镜也 绿云扰扰 +kan hong zhuang su guo 看红妆素裹 分外妖娆 +kan ke zhi de yi jing man 坎轲只得移荆蛮 判司卑官不堪说 +kan wan shan hong bian 看万山红遍 层林尽染 +ke ai shen hong ai qian hong 可爱深红爱浅红 +ke ai zhe shen fan 可爱者甚蕃 晋陶渊明独爱菊 +ke de yong nian 可得永年 幸甚至哉 +ke han da dian bing 可汗大点兵 军书十二卷 +ke han wen suo yu 可汗问所欲 木兰不用尚书郎 +ke ji ri er dai ye 可计日而待也 臣本布衣 +ke jian qi ding 可见其顶 又渐如常楼 +ke kan gu guan bi chun han 可堪孤馆闭春寒 杜鹃声里斜阳暮 +ke kan hui shou 可堪回首 佛狸祠下 +ke lian bai fa sheng 可怜白发生 +ke lian fei yan yi xin zhuang 可怜飞燕倚新妆 +ke lian guang cai sheng men hu 可怜光彩生门户 遂令天下父母心 +ke lian gui li yue 可怜闺里月 长在汉家营 +ke lian hou zhu huan ci miao 可怜后主还祠庙 日暮聊为梁甫吟 +ke lian jiao tu 可怜焦土 呜呼 +ke lian shen shang yi zheng dan 可怜身上衣正单 心忧炭贱愿天寒 +ke lian wang sun qi lu yu 可怜王孙泣路隅 问之不肯道姓名 +ke lian wu ding he bian gu 可怜无定河边骨 犹是春闺梦里人 +ke lian ye ban xu qian xi 可怜夜半虚前席 不问苍生问鬼神 +ke lu qing shan wai 客路青山外 行舟绿水前 +ke qu bo ping kan 客去波平槛 蝉休露满枝 +ke ru qing tong gen ru shi 柯如青铜根如石 霜皮溜雨四十围 +ke she qing qing liu se xin 客舍青青柳色新 劝君更尽一杯酒 +ke tang xian jin ren shi fu yu qi shang 刻唐贤今人诗赋于其上 属予作文以记之 +ke wei wei ren 可畏惟人 载舟覆舟 +ke wen yuan fang 客问元方 尊君在不 +ke xi er xiao 客喜而笑 洗盏更酌 +ke xi liu nian 可惜流年 忧愁风雨 +ke xin xi liu shui 客心洗流水 馀响入霜钟 +ke xing bei gu xiang 客行悲故乡 鸡声茅店月 +ke yi heng jue e mei dian 可以横绝峨眉巅 地崩山摧壮士死 +ke yi jian jia ke 可以荐嘉客 奈何阻重深 +ke yi jin yu you zhi le 可以尽豫游之乐 可以养松乔之寿 +ke yi tiao su qin 可以调素琴 阅金经 +ke yi wei shi yi 可以为师矣 +ke yi yang song qiao zhi shou 可以养松乔之寿 鸣琴垂拱 +ke yi zhi fu shui yu yue hu 客亦知夫水与月乎 逝者如斯 +ke you chui dong xiao zhe 客有吹洞箫者 倚歌而和之 +ke you cong yu shi da fu zhang gong chu sai er hai zhe 客有从御史大夫张公出塞而还者 作燕歌行以示适 +ke yuan guan er bu ke xie wan yan 可远观而不可亵玩焉 予谓菊 +ke zhi ji dou zheng 客至鸡斗争 驱鸡上树木 +ken jiang shuai xiu xi can nian 肯将衰朽惜残年 云横秦岭家何在 +ken shu ye xia huang xu er 肯数邺下黄须儿 一身转战三千里 +ken yu lin weng xiang dui yin 肯与邻翁相对饮 隔篱呼取尽余杯 +kong cheng dan yue hua 空城澹月华 星河秋一雁 +kong fa qi shen 空乏其身 行拂乱其所为 +kong fei ping sheng hun 恐非平生魂 路远不可测 +kong gu chuan xiang 空谷传响 哀转久绝 +kong jing tian shang ren 恐惊天上人 +kong man jing er yin ou yue 控蛮荆而引瓯越 物华天宝 +kong ming miao qian you lao bai 孔明庙前有老柏 柯如青铜根如石 +kong shan bai niao san huan he 空山百鸟散还合 万里浮云阴且晴 +kong shan bu jian ren 空山不见人 但闻人语响 +kong shan song zi luo 空山松子落 幽人应未眠 +kong shan xin yu hou 空山新雨后 天气晚来秋 +kong shi han dai han zhang liang 恐是汉代韩张良 昔随刘氏定长安 +kong tuo fu bu xiao 恐托付不效 以伤先帝之明 +kong xiang qiu bo ku shi chuan 空向秋波哭逝川 +kong yi xie jiang jun 空忆谢将军 余亦能高咏 +kong yu bao guo zhi qing 空余报国之情 阮籍猖狂 +kong yuan bai lu di 空园白露滴 孤壁野僧邻 +kong zhong wen tian ji 空中闻天鸡 千岩万转路不定 +kou shi ken rang 叩石垦壤 箕畚运于渤海之尾 +kou xian er ge zhi 扣舷而歌之 歌曰 +ku dao lai bu yi 苦道来不易 江湖多风波 +ku hen nian nian ya jin xian 苦恨年年压金线 为他人作嫁衣裳 +ku sheng zhi shang gan yun xiao 哭声直上干云霄 道傍过者问行人 +ku song dao gua yi jue bi 枯松倒挂倚绝壁 飞湍瀑流争喧豗 +ku teng lao shu hun ya 枯藤老树昏鸦 小桥流水人家 +ku xin qi mian rong lou yi 苦心岂免容蝼蚁 香叶终经宿鸾凤 +kua fu yu ri zhu zou 夸父与日逐走 入日 +kua ma chu jiao shi ji mu 跨马出郊时极目 不堪人事日萧条 +kua zhou lian jun zhe bu ke sheng shu 跨州连郡者不可胜数 曹操比于袁绍 +kuai ge dong xi yi wan qing 快阁东西倚晚晴 落木千山天远大 +kuai ran zi zu 快然自足 不知老之将至 +kuang fu qin bing nai ku zhan 况复秦兵耐苦战 被驱不异犬与鸡 +kuang ge wu liu qian 狂歌五柳前 +kuang nai wei xiu bing 况乃未休兵 +kuang wu yu zi yu qiao yu jiang zhu zhi shang 况吾与子渔樵于江渚之上 侣鱼虾而友麋鹿 +kuang xiu duan sui hua 况修短随化 终期于尽 +kui gu wang fan 窥谷忘反 横柯上蔽 +kui jun you qian shen feng bo 愧君犹遣慎风波 +kui wu wei zhi da dao zai 亏无为之大道哉 +kun huang hua ye shuai 焜黄华叶衰 百川东到海 +la ju cheng hui lei shi gan 蜡炬成灰泪始干 晓镜但愁云鬓改 +la yu tong yin 拉余同饮 余强饮三大白而别 +la zhao ban long jin fei cui 蜡照半笼金翡翠 麝熏微度绣芙蓉 +la zhu you xin hai xi bie 蜡烛有心还惜别 替人垂泪到天明 +lai cong chu guo you 来从楚国游 山随平野尽 +lai qu jiu jiang ce 来去九江侧 同是长干人 +lai ri qi chuang qian 来日绮窗前 寒梅著花未 +lai shi kong yan qu jue zong 来是空言去绝踪 月斜楼上五更钟 +lai tu ruo meng xing 来途若梦行 浮天沧海远 +lai wang bu feng ren 来往不逢人 长歌楚天碧 +lai wu wei gong qian 来舞魏宫前 +lan gan pai bian 栏杆拍遍 无人会 +lan tian ri nuan yu sheng yan 蓝田日暖玉生烟 此情可待成追忆 +lan ting yi yi 兰亭已矣 梓泽丘墟 +lan wu zhi qing 览物之情 得无异乎 +lan ye chun wei rui 兰叶春葳蕤 桂华秋皎洁 +lan yi tui zhen qi pai huai 揽衣推枕起徘徊 珠箔银屏迤逦开 +lan zhou cui fa 兰舟催发 执手相看泪眼 +lang e fei zhou 浪遏飞舟 +lang qi zhu ma lai 郎骑竹马来 绕床弄青梅 +lang tao feng bo zi tian ya 浪淘风簸自天涯 如今直上银河去 +lang yao man hui 廊腰缦回 檐牙高啄 +lao bing you gu zhou 老病有孤舟 戎马关山北 +lao da jia zuo shang ren fu 老大嫁作商人妇 商人重利轻别离 +lao da tu shang bei 老大徒伤悲 +lao dang yi zhuang 老当益壮 宁移白首之心 +lao fu chu men kan 老妇出门看 吏呼一何怒 +lao fu liao fa shao nian kuang 老夫聊发少年狂 左牵黄 +lao ji fu li 老骥伏枥 志在千里 +lao mu cang bo wu xian bei 老木沧波无限悲 +lao qi jin gu 劳其筋骨 饿其体肤 +lao weng yu qiang zou 老翁逾墙走 老妇出门看 +lao yu li sui shuai 老妪力虽衰 请从吏夜归 +lao zhi ju ren xia 老至居人下 春归在客先 +le you yuan shang wang zhao ling 乐游原上望昭陵 +lei er ge zhe 罍而歌者 红装而蹇者 +lei luo zhan wo yi 泪落沾我衣 +lei shi luo jin meng bu cheng 泪湿罗巾梦不成 夜深前殿按歌声 +lei ting zha jing 雷霆乍惊 宫车过也 +leng leng qing qing 冷冷清清 凄凄惨惨戚戚 +leng luo qing qiu jie 冷落清秋节 今宵酒醒何处 +li bai cheng zhou jiang yu xing 李白乘舟将欲行 忽闻岸上踏歌声 +li bei xi gong chuan 离杯惜共传 +li bie zheng kan bei 离别正堪悲 路出寒云外 +li du shi pian wan kou chuan 李杜诗篇万口传 至今已觉不新鲜 +li e qi bu chu yu shen ye 力恶其不出于身也 不必为己 +li er cong zhe 隶而从者 崔氏二小生 +li gen yuan zai po yan zhong 立根原在破岩中 千磨万击还坚劲 +li gong gao chu ru qing yun 骊宫高处入青云 仙乐风飘处处闻 +li guang nan feng 李广难封 屈贾谊于长沙 +li guang wu gong yuan shu qi 李广无功缘数奇 自从弃置便衰朽 +li hu yi he nu 吏呼一何怒 妇啼一何苦 +li hua luo hou qing ming 梨花落后清明 池上碧苔三四点 +li hua man di bu kai men 梨花满地不开门 +li hua yi zhi chun dai yu 梨花一枝春带雨 含情凝睇谢君王 +li jin bu zhi re 力尽不知热 但惜夏日长 +li jin guan shan wei jie wei 力尽关山未解围 铁衣远戍辛勤久 +li jin nan hai 利尽南海 东连吴会 +li li jie xin ku 粒粒皆辛苦 +li li yuan shang cao 离离原上草 一岁一枯荣 +li li zai mu 历历在目 以亿万计 +li lu san bai dan 吏禄三百石 岁晏有余粮 +li luo shu shu yi jing shen 篱落疏疏一径深 树头花落未成阴 +li shan bei gou er xi zhe 骊山北构而西折 直走咸阳 +li shan si gu 骊山四顾 阿房一炬 +li shen lin xi jing ceng dian 栗深林兮惊层巅 云青青兮欲雨 +li si shui jia ku bu zao 李斯税驾苦不早 华亭鹤唳讵可闻 +li yuan di zi bai fa xin 梨园弟子白发新 椒房阿监青娥老 +lian bu qu dan bi 联步趋丹陛 分曹限紫微 +lian dong xia yu zhou 莲动下渔舟 随意春芳歇 +lian feng qu tian bu ying chi 连峰去天不盈尺 枯松倒挂倚绝壁 +lian gen liu qi li 连亘六七里 居然城郭矣 +lian jing bu dong xi yang chi 帘旌不动夕阳迟 登临吴蜀横分地 +lian juan xi feng 帘卷西风 人比黄花瘦 +lian jun he shi dao tian ya 怜君何事到天涯 +lian po lao yi 廉颇老矣 尚能饭否 +lian shan jie hai yu 连山接海隅 白云回望合 +lian wai chun han ci jin pao 帘外春寒赐锦袍 +lian ye he tian tian 莲叶何田田 鱼戏莲叶间 +lian yue bu kai 连月不开 阴风怒号 +liang an lian shan 两岸连山 略无阙处 +liang an qing shan xiang dui chu 两岸青山相对出 孤帆一片日边来 +liang an shi bi 两岸石壁 五色交辉 +liang an tao hua jia gu jin 两岸桃花夹古津 坐看红树不知远 +liang an yuan sheng ti bu zhu 两岸猿声啼不住 轻舟已过万重山 +liang bin cang cang shi zhi hei 两鬓苍苍十指黑 卖炭得钱何所营 +liang chao kai ji lao chen xin 两朝开济老臣心 出师未捷身先死 +liang chu mang mang jie bu jian 两处茫茫皆不见 忽闻海上有仙山 +liang duo qu wei 良多趣味 每至晴初霜旦 +liang feng qi tian mo 凉风起天末 君子意如何 +liang fu yin cheng hen you yu 梁父吟成恨有余 +liang ge huang li ming cui liu 两个黄鹂鸣翠柳 一行白鹭上青天 +liang gong geng long mu 亮躬耕陇亩 好为梁父吟 +liang ren ba yuan zheng 良人罢远征 +liang ren zuo ye qing 良人昨夜情 谁能将旗鼓 +liang san dian yu shan qian 两三点雨山前 旧时茅店社林边 +liang san hang wei ceng zhe an 两三航未曾着岸 落花水香茅舍晚 +liang san xing huo shi gua zhou 两三星火是瓜州 +liang shan pai ta song qing lai 两山排闼送青来 +liang si zhu ya zhi jian 两涘渚崖之间 不辩牛马 +liang xiao wu xian cai 两小无嫌猜 十四为君妇 +liao chi bao jian dong xing wen 聊持宝剑动星文 愿得燕弓射大将 +liao dao xin ting zhuo jiu bei 潦倒新停浊酒杯 +liao dong xiao fu nian shi wu 辽东小妇年十五 惯弹琵琶解歌舞 +liao luo gu xing gong 寥落古行宫 宫花寂寞红 +liao luo han shan dui xu you 寥落寒山对虚牖 誓令疏勒出飞泉 +liao que jun wang tian xia shi 了却君王天下事 赢得生前身后名 +liao shui jin er han tan qing 潦水尽而寒潭清 烟光凝而暮山紫 +liao zhi duan bing bu gan jie 料知短兵不敢接 车师西门伫献捷 +lie guo zhou qi qin han chu 列国周齐秦汉楚 赢 +lie huo fen shao ruo deng xian 烈火焚烧若等闲 粉骨碎身全不怕 +lie ma dai qin gui 猎马带禽归 相顾无相识 +lie que pi li 列缺霹雳 丘峦崩摧 +lie shi mu nian 烈士暮年 壮心不已 +lie zuo qi ci 列坐其次 虽无丝竹管弦之盛 +lin an cao jing feng 林暗草惊风 将军夜引弓 +lin biao ming ji se 林表明霁色 城中增暮寒 +lin biao ti ling 临表涕零 不知所言 +lin bie yin qin chong ji ci 临别殷勤重寄词 词中有誓两心知 +lin bie zeng yan 临别赠言 幸承恩于伟饯 +lin di zi zhi chang zhou 临帝子之长洲 得仙人之旧馆 +lin feng ting mu chan 临风听暮蝉 渡头馀落日 +lin han jian su 林寒涧肃 常有高猿长啸 +lin he you mei 林壑尤美 望之蔚然而深秀者 +lin hun zhang bu kai 林昏瘴不开 明朝望乡处 +lin jin shui yuan 林尽水源 便得一山 +lin kong lu yin xi 林空鹿饮溪 人家在何许 +lin lang ceng ceng 鳞浪层层 清澈见底 +lin qiong dao shi hong dou ke 临邛道士鸿都客 能以精诚致魂魄 +lin ren jing cheng shi zhi shuang qi you yi nan 邻人京城氏之孀妻有遗男 始龀 +lin xi er yu 临溪而渔 溪深而鱼肥 +lin xing mi mi feng 临行密密缝 意恐迟迟归 +lin you bi yu er yu qie zhi 邻有敝舆而欲窃之 舍其锦绣 +lin you duan he er yu qie zhi 邻有短褐而欲窃之 舍其粱肉 +lin you kang zao er yu qie zhi 邻有糠糟而欲窃之 此为何若人 +lin yue di xiang hou 林月低向后 生事且弥漫 +ling ding yang li tan ling ding 零丁洋里叹零丁 人生自古谁无死 +ling ling qi xian shang 泠泠七弦上 静听松风寒 +ling ling zuo xiang 泠泠作响 好鸟相鸣 +ling long wang qiu yue 玲珑望秋月 +ling luo cheng ni nian zuo chen 零落成泥碾作尘 只有香如故 +ling luo yi cao mu 零落依草木 关中昔丧乱 +ling qi kong ji kan 灵旗空际看 +ling shu chong zhe qian li mu 岭树重遮千里目 江流曲似九回肠 +ling wan qing zhi mang ran 凌万顷之茫然 浩浩乎如冯虚御风 +ling yuan tong dan mu 岭猿同旦暮 江柳共风烟 +liu an hua ming you yi cun 柳暗花明又一村 箫鼓追随春社近 +liu bian ren xie dai chuan gui 柳边人歇待船归 数丛沙草群鸥散 +liu chao ru meng niao kong ti 六朝如梦鸟空啼 无情最是台城柳 +liu fu jing qi lu wei gan 柳拂旌旗露未干 独有凤凰池上客 +liu gong fen dai wu yan se 六宫粉黛无颜色 春寒赐浴华清池 +liu jun bu fa wu nai he 六军不发无奈何 宛转蛾眉马前死 +liu lang cai qi 刘郎才气 可惜流年 +liu lang yi hen peng shan yuan 刘郎已恨蓬山远 更隔蓬山几万重 +liu lian xi die shi shi wu 留连戏蝶时时舞 自在娇莺恰恰啼 +liu qu dan xin zhao han qing 留取丹心照汗青 +liu ru gong qiang 流入宫墙 五步一楼 +liu shui ru you yi 流水如有意 暮禽相与还 +liu shui shi nian jian 流水十年间 欢笑情如旧 +liu shui wu qing cao zi chun 流水无情草自春 日暮东风怨啼鸟 +liu tiao jiang shu wei shu 柳条将舒未舒 柔梢披风 +liu xue qian li 流血千里 唐雎曰 +liu xue wu bu 流血五步 天下缟素 +liu zhang an ruo 刘璋暗弱 张鲁在北 +liu zui yu shan weng 留醉与山翁 +long gai si ye 笼盖四野 天苍苍 +long guang she niu dou zhi xu 龙光射牛斗之墟 人杰地灵 +long shang yang gui sai cao yan 陇上羊归塞草烟 回日楼台非甲帐 +long zhong zi yu chang ren shu 龙种自与常人殊 豺狼在邑龙在野 +lou ge ling long wu yun qi 楼阁玲珑五云起 其中绰约多仙子 +lou hao man di lu ya duan 蒌蒿满地芦芽短 正是河豚欲上时 +lou shang hua zhi xiao du mian 楼上花枝笑独眠 为问元戎窦车骑 +lou wai tian ye 楼外天也 层层指数 +lu chu han yun wai 路出寒云外 人归暮雪时 +lu chu ming qu 路出名区 童子何知 +lu cong jin ye bai 露从今夜白 月是故乡明 +lu ji xiong cai qi zi bao 陆机雄才岂自保 李斯税驾苦不早 +lu ling ou yang xiu ye 庐陵欧阳修也 +lu lu yuan ting 辘辘远听 杳不知其所之也 +lu men yue zhao kai yan shu 鹿门月照开烟树 忽到庞公栖隐处 +lu pang shi mai gu hou gua 路旁时卖故侯瓜 门前学种先生柳 +lu qi beng teng wei ji li 虏骑崩腾畏蒺藜 卫青不败由天幸 +lu qi han guang ji 露气寒光集 微阳下楚丘 +lu qi suo shu 录其所述 虽世殊事异 +lu qi wen zhi ying dan she 虏骑闻之应胆慑 料知短兵不敢接 +lu ren jie wen yao zhao shou 路人借问遥招手 怕得鱼惊不应人 +lu sai bing qi lian yun tun 虏塞兵气连云屯 战场白骨缠草根 +lu shang xing ren yu duan hun 路上行人欲断魂 借问酒家何处有 +lu shang you hu 炉上有壶 其人视端容寂 +lu shu yi wei zhi 漉菽以为汁 萁在釜下燃 +lu shui dang yang qing yuan ti 渌水荡漾清猿啼 脚著谢公屐 +lu yuan bu ke ce 路远不可测 魂来枫林青 +lu zhi ju zuo 鲁直居左 苏 +lu zhi xian zuo zu 鲁直现左足 各微侧 +lu zhi zuo shou zhi juan mo 鲁直左手执卷末 右手指卷 +lu zhuan xi qiao hu jian 路转溪桥忽见 +luan dao lv qie kong fen lun 銮刀缕切空纷纶 黄门飞鞚不动尘 +luan hua jian yu mi ren yan 乱花渐欲迷人眼 浅草才能没马蹄 +luan shi chuan kong 乱石穿空 惊涛拍岸 +luan wo xin zhe 乱我心者 今日之日多烦忧 +luan yu jiong chu qian men liu 銮舆迥出千门柳 阁道回看上苑花 +lue shu wen cai 略输文采 唐宗宋祖 +lue wu mu yan yi 略无慕艳意 以中有足乐者 +lue wu que chu 略无阙处 重岩叠嶂 +lun tai cheng bei mao tou luo 轮台城北旄头落 羽书昨夜过渠黎 +lun tai cheng tou ye chui jiao 轮台城头夜吹角 轮台城北旄头落 +lun tai dong men song jun qu 轮台东门送君去 去时雪满天山路 +lun tai jiu yue feng ye hou 轮台九月风夜吼 一川碎石大如斗 +luo hong bu shi wu qing wu 落红不是无情物 化作春泥更护花 +luo hua shi jie you feng jun 落花时节又逢君 +luo hua shui xiang mao she wan 落花水香茅舍晚 断桥头卖鱼人散 +luo hua you si zhui lou ren 落花犹似坠楼人 +luo luo pan ju sui de di 落落盘踞虽得地 冥冥孤高多烈风 +luo mu qian shan tian yuan da 落木千山天远大 澄江一道月分明 +luo mu qing han 罗幕轻寒 燕子双飞去 +luo ri gu ren qing 落日故人情 挥手自兹去 +luo ri lou tou 落日楼头 断鸿声里 +luo ri man qiu shan 落日满秋山 迢递嵩高下 +luo suo sha chen ai yuan sheng 逻娑沙尘哀怨生 幽音变调忽飘洒 +luo wei qiu ti jin jing lan 络纬秋啼金井栏 微霜凄凄簟色寒 +luo xia yu gu wu qi fei 落霞与孤鹜齐飞 秋水共长天一色 +luo yang cheng li jian qiu feng 洛阳城里见秋风 欲作家书意万重 +luo yang hang zi kong tan xi 洛阳行子空叹息 闻道故林相识多 +luo yang qin you ru xiang wen 洛阳亲友如相问 一片冰心在玉壶 +luo ye man jie hong bu sao 落叶满阶红不扫 梨园弟子白发新 +luo ye man kong shan 落叶满空山 何处寻行迹 +luo ye qiu feng zao 落叶秋风早 八月蝴蝶来 +luo ye ren he zai 落叶人何在 寒云路几层 +luo ye ta xiang shu 落叶他乡树 寒灯独夜人 +luo ye tian xin yang gu huai 落叶添薪仰古槐 今日俸钱过十万 +luo yi yu huan geng tian xiang 罗衣欲换更添香 遥窥正殿帘开处 +luo ying bin fen 落英缤纷 渔人甚异之 +luo yue man wu liang 落月满屋梁 犹疑照颜色 +lv bian shan yuan bai man chuan 绿遍山原白满川 子规声里雨如烟 +lv guan wu liang ban 旅馆无良伴 凝情自悄然 +lv qi zi yi ren lai ci jue jing 率妻子邑人来此绝境 不复出焉 +lv shu cun bian he 绿树村边合 青山郭外斜 +lv yan pu di 闾阎扑地 钟鸣鼎食之家 +lv yang yin li bai sha di 绿杨阴里白沙堤 +lv yi xin pei jiu 绿蚁新醅酒 红泥小火炉 +lv yin bu jian lai shi lu 绿阴不减来时路 添得黄鹂四五声 +lv yu xia er you mi lu 侣鱼虾而友麋鹿 驾一叶之扁舟 +lv yun rao rao 绿云扰扰 梳晓鬟也 +lv zhu ru you jing 绿竹入幽径 青萝拂行衣 +ma mao dai xue han qi zheng 马毛带雪汗气蒸 五花连钱旋作冰 +ma shang xiang feng wu zhi bi 马上相逢无纸笔 凭君传语报平安 +ma wei po xia ni tu zhong 马嵬坡下泥土中 不见玉颜空死处 +ma yi long dui lu ji qian 马邑龙堆路几千 家住层城临汉苑 +ma zhi qian li zhe 马之千里者 一食或尽粟一石 +ma zuo di lu fei kuai 马作的卢飞快 弓如霹雳弦惊 +mai hua xue bai cai hua xi 麦花雪白菜花稀 日长篱落无人过 +mai tan de qian he suo ying 卖炭得钱何所营 身上衣裳口中食 +mai tian qian lie cun xu 麦田浅鬣寸许 游人虽未盛 +man di huang hua dui ji 满地黄花堆积 憔悴损 +man jiang bi tou 漫江碧透 百舸争流 +man juan shi shu xi yu kuang 漫卷诗书喜欲狂 白日放歌须纵酒 +man li yuan shi 缦立远视 而望幸焉 +man man er you 漫漫而游 日与其徒上高山 +man mian chen hui yan huo se 满面尘灰烟火色 两鬓苍苍十指黑 +man mu xiao ran 满目萧然 感极而悲者矣 +man shen xue bai zou jiang lai 满身雪白走将来 生平不敢轻言语 +man yan feng guang bei gu lou 满眼风光北固楼 千古兴亡多少事 +man zuo zhong wen jie yan qi 满座重闻皆掩泣 座中泣下谁最多 +mang chen dong feng fang zhi yuan 忙趁东风放纸鸢 +mao fei du jiang sa jiang jiao 茅飞渡江洒江郊 高者挂罥长林梢 +mao gong er bu xin fu 貌恭而不心服 怨不在大 +mao lin xiu zhu 茂林修竹 又有清流激湍 +mao ling bu jian feng hou yin 茂陵不见封侯印 空向秋波哭逝川 +mao ting su hua ying 茅亭宿花影 药院滋苔纹 +mao wu fang gu seng 茅屋访孤僧 落叶人何在 +mao yan di xiao 茅檐低小 溪上青青草 +mao yan zhang sao jing wu tai 茅檐长扫净无苔 花木成畦手自栽 +mao yu lin lie zhi jian jie you xi qi 毛羽鳞鬣之间皆有喜气 始知郊田之外未始无春 +mei feng jia jie bei si qin 每逢佳节倍思亲 遥知兄弟登高处 +mei fu huan ren zhi 美服患人指 高明逼神恶 +mei jia jie yu cang shu zhi jia 每假借于藏书之家 手自笔录 +mei jiu liao gong hui 美酒聊共挥 长歌吟松风 +mei lan xi ren xing gan zhi you 每览昔人兴感之由 若合一契 +mei mao feng chi xing 每冒风驰行 未百步辄返 +mei ren hu wei ge qiu shui 美人胡为隔秋水 焉得置之贡玉堂 +mei ren juan juan ge qiu shui 美人娟娟隔秋水 濯足洞庭望八荒 +mei ren qing jiang pan 美人清江畔 是夜越吟苦 +mei ren ru hua ge yun duan 美人如花隔云端 上有青冥之长天 +mei ren xiang bing li qiong xuan 美人相并立琼轩 含情欲说宫中事 +mei ren zhang xia you ge wu 美人帐下犹歌舞 大漠穷秋塞草腓 +mei sui jing yin chu zhe jiang ting jiao yue shui jun 每岁京尹出浙江亭教阅水军 艨艟数百 +mei tai jian lv hen 莓苔见履痕 白云依静渚 +mei yan wei kan su lu ke 媚眼惟看宿鹭窠 斜拔玉钗灯影畔 +mei yan ying ying chu 眉眼盈盈处 才始送春归 +mei you hui yi 每有会意 便欣然忘食 +mei yu chen lun ci shi 每与臣论此事 未尝不叹息痛恨于桓 +mei zai shi leng zhong 没在石棱中 +mei zhi qing chu shuang dan 每至晴初霜旦 林寒涧肃 +mei zi bi yu guan zhong 每自比于管仲 乐毅 +mei zi huang shi ri ri qing 梅子黄时日日晴 小溪泛尽却山行 +mei zi jin huang xing zi fei 梅子金黄杏子肥 麦花雪白菜花稀 +men bo dong wu wan li chuan 门泊东吴万里船 +men qian chi xing ji 门前迟行迹 一一生绿苔 +men qian leng luo an ma xi 门前冷落鞍马稀 老大嫁作商人妇 +men qian liu shui shang neng xi 门前流水尚能西 休将白发唱黄鸡 +men qian xue zhong xian sheng liu 门前学种先生柳 苍茫古木连穷巷 +men ren di zi tian qi shi 门人弟子填其室 未尝稍降辞色 +men shen li jing yang xie xi 扪参历井仰胁息 以手抚膺坐长叹 +men xi diao yu chuan 门系钓鱼船 +meng chang gao jie 孟尝高洁 空余报国之情 +meng chong shu bai 艨艟数百 分列两岸 +meng ci yi jun zhong duo wu 蒙辞以军中多务 权曰 +meng hui chui jiao lian ying 梦回吹角连营 八百里分麾下炙 +meng hun bu dao guan shan nan 梦魂不到关山难 长相思 +meng lang ruo ben 猛浪若奔 夹岸高山 +meng luo yao zhui 蒙络摇缀 参差披拂 +meng nai shi jiu xue 蒙乃始就学 及鲁肃过寻阳 +meng ru fu rong pu 梦入芙蓉浦 +meng ti zhuang lei hong lan gan 梦啼妆泪红阑干 我闻琵琶已叹息 +meng wei yuan bie ti nan huan 梦为远别啼难唤 书被催成墨未浓 +meng xue shi zhi ci zong 孟学士之词宗 紫电青霜 +meng yi tong qu 梦亦同趣 觉而起 +mi hua yi shi hu yi ming 迷花倚石忽已暝 熊咆龙吟殷岩泉 +mi jin yu you wen 迷津欲有问 平海夕漫漫 +mi su fei bu duo ye 米粟非不多也 委而去之 +mi yu xie qin bi li qiang 密雨斜侵薜荔墙 岭树重遮千里目 +mian shan er ju 面山而居 惩山北之塞 +miao cang hai zhi yi su 渺沧海之一粟 哀吾生之须臾 +miao ling lao ren shi shen yi 庙令老人识神意 睢盱侦伺能鞠躬 +miao miao xi yu huai 渺渺兮予怀 望美人兮天一方 +mie liu guo zhe 灭六国者 六国也 +mie zhu lian guang man 灭烛怜光满 披衣觉露滋 +min ran zhong ren yi 泯然众人矣 王子曰 +min ting le min pa 民听了民怕 哪里去辨甚么真共假 +min yin guo fu er bu zhi cun xu 民殷国富而不知存恤 智能之士思得明君 +ming cha qiu hao 明察秋毫 见藐小之物必细察其纹理 +ming cheng ba zhen tu 名成八阵图 江流石不转 +ming he zai tian 明河在天 四无人声 +ming hua qing guo liang xiang huan 名花倾国两相欢 常得君王带笑看 +ming kua e shi er zi fu er shan 命夸娥氏二子负二山 一厝朔东 +ming mie ke jian 明灭可见 其岸势犬牙差互 +ming ming gu gao duo lie feng 冥冥孤高多烈风 扶持自是神明力 +ming ming niao qu chi 冥冥鸟去迟 海门深不见 +ming qi wen zhang zhu 名岂文章著 官应老病休 +ming qin chui gong 鸣琴垂拱 不言而化 +ming ren yu you 名人与游 尝趋百里外 +ming ri ba ling dao 明日巴陵道 秋山又几重 +ming sheng shang xia 鸣声上下 游人去而禽鸟乐也 +ming shu jiao fang di yi bu 名属教坊第一部 曲罢曾教善才服 +ming tu duo chuan 命途多舛 冯唐易老 +ming wo chang xiang yi 明我长相忆 君今在罗网 +ming xing ying ying 明星荧荧 开妆镜也 +ming you qi qiao ren yue wang shu yuan 明有奇巧人曰王叔远 能以径寸之木 +ming yue bie zhi jing que 明月别枝惊鹊 清风半夜鸣蝉 +ming yue bu an li hen ku 明月不谙离恨苦 斜光到晓穿朱户 +ming yue chu tian shan 明月出天山 苍茫云海间 +ming yue he shi zhao wo huan 明月何时照我还 +ming yue ji shi you 明月几时有 把酒问青天 +ming yue lai xiang zhao 明月来相照 +ming yue lou gao xiu du yi 明月楼高休独倚 酒入愁肠 +ming yue song jian zhao 明月松间照 清泉石上流 +ming zhao gua fan xi 明朝挂帆席 枫叶落纷纷 +ming zhao san fa nong pian zhou 明朝散发弄扁舟 +ming zhao wang xiang chu 明朝望乡处 应见陇头梅 +ming zhao yi shi fa 明朝驿使发 一夜絮征袍 +ming zhao you feng shi 明朝有封事 数问夜如何 +ming zheng jin su zhu 鸣筝金粟柱 素手玉房前 +ming zhi er bu neng tong qi yi 鸣之而不能通其意 执策而临之 +ming zhi zhe shui 名之者谁 太守自谓也 +mo bu yin you er dao zhu 莫不殷忧而道著 功成而德衰 +mo chou qian lu wu zhi yi 莫愁前路无知已 天下谁人不识君 +mo ci geng zuo tan yi qu 莫辞更坐弹一曲 为君翻作琵琶行 +mo ci jiu wei bo 莫辞酒味薄 黍地无人耕 +mo dai wu hua kong zhe zhi 莫待无花空折枝 +mo dao bu xiao hun 莫道不销魂 帘卷西风 +mo dao huo huo xiang zhu yang 磨刀霍霍向猪羊 开我东阁门 +mo de dun yin 莫得遁隐 萦青缭白 +mo jiao zhi shang ti 莫教枝上啼 啼时惊妾梦 +mo mai wo zhou shan 莫买沃洲山 时人已知处 +mo mo fan lai zhong 漠漠帆来重 冥冥鸟去迟 +mo shi gao zhen gui 莫是藁砧归 +mo shi jin zun kong dui yue 莫使金尊空对月 天生我材必有用 +mo shuo xiang gong chi 莫说相公痴 更有痴似相公者 +mo xian jiu ri yun zhong shou 莫嫌旧日云中守 犹堪一战取功勋 +mo xiao nong jia la jiu hun 莫笑农家腊酒浑 丰年留客足鸡豚 +mo xue wu ling ren 莫学武陵人 暂游桃源里 +mo ya shun xue 磨牙吮血 杀人如麻 +mo yan xia ling bian wu nan 莫言下岭便无难 赚得行人错喜欢 +mo zhi wo ai 莫知我哀 +mu ai chen chen chu tian kuo 暮霭沉沉楚天阔 多情自古伤离别 +mu chun zhi chu 暮春之初 会于会稽山阴之兰亭 +mu cong bi shan xia 暮从碧山下 山月随人归 +mu dan zhi ai 牡丹之爱 宜乎众矣 +mu dao jiang ling 暮到江陵 其间千二百里 +mu er guo da wang qi cai 暮而果大亡其财 其家甚智其子 +mu lan bu yong shang shu lang 木兰不用尚书郎 愿驰千里足 +mu lan dang hu zhi 木兰当户织 不闻机杼声 +mu lan wu zhang xiong 木兰无长兄 愿为市鞍马 +mu luo yan nan du 木落雁南度 北风江上寒 +mu nian shi fu dong jiang guan 暮年诗赋动江关 +mu qin xiang yu huan 暮禽相与还 荒城临古渡 +mu qu zhao lai yan se gu 暮去朝来颜色故 门前冷落鞍马稀 +mu re yu xiang gui 暮惹御香归 白发悲花落 +mu ren qu du fan 牧人驱犊返 猎马带禽归 +mu su huang he bian 暮宿黄河边 不闻爷娘唤女声 +mu tong qi huang niu 牧童骑黄牛 歌声振林樾 +mu tong yao zhi xing hua cun 牧童遥指杏花村 +mu tou shi hao cun 暮投石壕村 有吏夜捉人 +mu wang he shi bu chong lai 穆王何事不重来 +mu wu hui yu yun jian 目吴会于云间 地势极而南溟深 +mu yu zhi er cheng se 目遇之而成色 取之无禁 +mu zao zhi er ye tuo 木遭之而叶脱 其所以摧败零落者 +mu zhi hei shan tou 暮至黑山头 不闻爷娘唤女声 +mu zhong cao xi yan shui ning 幕中草檄砚水凝 虏骑闻之应胆慑 +mu zong que zhi chang feng 慕宗悫之长风 舍簪笏于百龄 +na kan ba jian dao 那堪把剪刀 裁缝寄远道 +na li qu bian shen me zhen gong jia 哪里去辨甚么真共假 眼见的吹翻了这家 +na wen jiu ren ku 那闻旧人哭 在山泉水清 +nai bu zhi you han 乃不知有汉 无论魏晋 +nai chong xiu yue yang lou 乃重修岳阳楼 增其旧制 +nai he qu zhi jin zi zhu 奈何取之尽锱铢 用之如泥沙 +nai he yi fei jin shi zhi zhi 奈何以非金石之质 欲与草木而争荣 +nai he zu zhong shen 奈何阻重深 运命惟所遇 +nai ji zhi er qu 乃记之而去 同游者 +nai ke gong ye 乃可攻也 然臣之弟子禽滑厘等三百人 +nai qi yi qi zhi yu lie 乃其一气之余烈 夫秋 +nan chao si bai ba shi si 南朝四百八十寺 多少楼台烟雨中 +nan cun qun tong qi wo lao wu li 南村群童欺我老无力 忍能对面为盗贼 +nan er ben zi zhong heng xing 男儿本自重横行 天子非常赐颜色 +nan er shi chang zheng 男儿事长征 少小幽燕客 +nan fang you niao 南方有鸟 其名为鹓鶵 +nan fu yi yue 南抚夷越 外结好孙权 +nan ji lao ren ying shou chang 南极老人应寿昌 美人胡为隔秋水 +nan ji xiao xiang 南极潇湘 迁客骚人 +nan ling yu shi chi 南陵寓使迟 天涯占梦数 +nan nv yi zhuo 男女衣着 悉如外人 +nan pu qing jiang wan li qiao 南浦清江万里桥 海内风尘诸弟隔 +nan shi mai pei tou 南市买辔头 北市买长鞭 +nan wang wang shi you yi nian 南望王师又一年 +nan yang liu zi ji 南阳刘子骥 高尚士也 +nan yang zhu ge lu 南阳诸葛庐 西蜀子云亭 +nan yu shang qing tian 难于上青天 蚕丛及鱼凫 +nei xiu zheng li 内修政理 天下有变 +neng bu yi jiang nan 能不忆江南 +neng kai er yue hua 能开二月花 过江千尺浪 +neng ke zhong zhe gai gua 能克终者盖寡 岂其取之易守之难乎 +neng yi jing cheng zhi hun po 能以精诚致魂魄 为感君王辗转思 +neng yi jing cun zhi mu 能以径寸之木 为宫室 +neng yin yi bei wu 能饮一杯无 +neng zhang mu dui ri 能张目对日 明察秋毫 +ni rong fei yan zi 泥融飞燕子 沙暖睡鸳鸯 +ni ta gu jiu ba jin chai 泥他沽酒拔金钗 野蔬充膳甘长藿 +ni tuo liang mei yi zi shang 拟托良媒益自伤 谁爱风流高格调 +ni wei yi xi feng wei ma 霓为衣兮风为马 云之君兮纷纷而来下 +nian ci si zi kui 念此私自愧 尽日不能忘 +nian er ri tian shao he 廿二日天稍和 偕数友出东直 +nian jin zhong wu ci chan yuan 念近中无此禅院 无何 +nian lai yu qin 辇来于秦 朝歌夜弦 +nian nian yue xi nv 年年越溪女 相忆采芙蓉 +nian qie jiu shi 年且九十 面山而居 +nian shao wan dou mou 年少万兜鍪 坐断东南战未休 +nian shui wei zhi qiang zei 念谁为之戕贼 亦何恨乎秋声 +nian tian di zhi you you 念天地之悠悠 独怆然而涕下 +nian wu yu wei le zhe 念无与为乐者 遂至承天寺寻张怀民 +niang quan wei jiu 酿泉为酒 泉香而酒洌 +niao que hu qing 鸟雀呼晴 侵晓窥檐语 +niao xiang yan shang fei 鸟向檐上飞 云从窗里出 +niao yi ba qi ming 鸟亦罢其鸣 乌孙部落家乡远 +nie zheng zhi ci han kui ye 聂政之刺韩傀也 白虹贯日 +ning jue bu tong sheng zan xie 凝绝不通声暂歇 别有幽愁暗恨生 +ning qing zi qiao ran 凝情自悄然 寒灯思旧事 +ning wei bai fu zhang 宁为百夫长 胜作一书生 +ning yi bai shou zhi xin 宁移白首之心 穷且益坚 +niu kun ren ji ri yi gao 牛困人饥日已高 市南门外泥中歇 +niu yi gu liu mai huang gua 牛衣古柳卖黄瓜 酒困路长惟欲睡 +niu zhu xi jiang ye 牛渚西江夜 青天无片云 +nong fu you e si 农夫犹饿死 +nuan feng xun de you ren zui 暖风熏得游人醉 直把杭州作汴州 +nv yi wu suo si 女亦无所思 女亦无所忆 +nv yi wu suo yi 女亦无所忆 昨夜见军帖 +ou ran zhi lin sou 偶然值林叟 谈笑无还期 +ou si shan lin ke 偶似山林客 晓耕翻露草 +ou ya zhao zha nan wei ting 呕哑嘲哳难为听 今夜闻君琵琶语 +ou yang zi fang ye du shu 欧阳子方夜读书 闻有声自西南来者 +pa de yu jing bu ying ren 怕得鱼惊不应人 +pa ying xiu jian 怕应羞见 刘郎才气 +pai huai yu dou niu zhi jian 徘徊于斗牛之间 白露横江 +pai kong yu qi ben ru dian 排空驭气奔如电 升天入地求之遍 +pan can shi yuan wu jian wei 盘餐市远无兼味 樽酒家贫只旧醅 +pan si bei guan bu kan shuo 判司卑官不堪说 未免捶楚尘埃间 +pan yuan er deng 攀援而登 箕踞而遨 +pang kai xiao chuang 旁开小窗 左右各四 +pao ku gong ren sao yu chuang 袍袴宫人扫御床 +pei sheng gui dao feng chi tou 佩声归到凤池头 +pei yu ming luan ba ge wu 佩玉鸣鸾罢歌舞 画栋朝飞南浦云 +pen yun xie wu cang ban fu 喷云泄雾藏半腹 虽有绝顶谁能穷 +peng lai gong zhong ri yue chang 蓬莱宫中日月长 回头下望人寰处 +peng lai wen zhang jian an gu 蓬莱文章建安骨 中间小谢又清发 +peng men jin shi wei jun kai 蓬门今始为君开 盘餐市远无兼味 +peng men wei shi qi luo xiang 蓬门未识绮罗香 拟托良媒益自伤 +peng shan ci qu wu duo lu 蓬山此去无多路 青鸟殷勤为探看 +peng tou zhi zi xue chui lun 蓬头稚子学垂纶 侧坐莓苔草映身 +peng yang zai niu qie wei le 烹羊宰牛且为乐 会须一饮三百杯 +peng zhou chui qu san shan qu 蓬舟吹取三山去 +pi pa sheng ting yu yu chi 琵琶声停欲语迟 移船相近邀相见 +pi yi jue lu zi 披衣觉露滋 不堪盈手赠 +pian pian liang qi lai shi shui 翩翩两骑来是谁 黄衣使者白衫儿 +pian si yu cao li zhi jian 骈死于槽枥之间 不以千里称也 +piao bo xi nan tian di jian 漂泊西南天地间 三峡楼台淹日月 +piao piao he suo si 飘飘何所似 天地一沙鸥 +piao piao hu ru yi shi du li 飘飘乎如遗世独立 羽化而登仙 +pin nian bu jie bing 频年不解兵 可怜闺里月 +ping hai xi man man 平海夕漫漫 +ping jun chuan yu bao ping an 凭君传语报平安 +ping ming chui di da jun xing 平明吹笛大军行 四边伐鼓雪海涌 +ping ming lv xiang sao hua kai 平明闾巷扫花开 薄暮渔樵乘水入 +ping ming qi ma ru gong men 平明骑马入宫门 却嫌脂粉污颜色 +ping ming song ke chu shan gu 平明送客楚山孤 洛阳亲友如相问 +ping ming xun bai yu 平明寻白羽 没在石棱中 +ping sha mang mang huang ru tian 平沙莽莽黄入天 轮台九月风夜吼 +ping sha ri wei mo 平沙日未没 黯黯见临洮 +ping shui xiang feng 萍水相逢 尽是他乡之客 +ping xuan ti si liu 凭轩涕泗流 +ping ya zhuan shi wan he lei 砯崖转石万壑雷 其险也如此 +ping yang ge wu xin cheng chong 平阳歌舞新承宠 帘外春寒赐锦袍 +po shi ji zhi 颇示己志 忘怀得失 +pu chuang fu xi zhi geng fan 铺床拂席置羹饭 疏粝亦足饱我饥 +pu shu yuan han zi 浦树远含滋 相送情无限 +pu tao mei jiu ye guang bei 葡萄美酒夜光杯 欲饮琵琶马上催 +qi an shi quan ya cha hu 其岸势犬牙差互 不可知其源 +qi ba ge xing tian wai 七八个星天外 两三点雨山前 +qi bi ju shu wei ren ji 岂必局束为人鞿 嗟哉吾党二三子 +qi bu li ning han 岂不罹凝寒 松柏有本性 +qi bu shan zhe er gai zhi 其不善者而改之 +qi bu tong zai 岂不痛哉 每览昔人兴感之由 +qi cheng ci hen wu chong shu 砌成此恨无重数 郴江幸自绕郴山 +qi chu yu wu ye 其触于物也 鏦鏦铮铮 +qi chuan bei shao yi 其船背稍夷 则题名其上 +qi chuang er guan 启窗而观 雕栏相望焉 +qi fa ming shi 岂乏明时 所赖君子见机 +qi fei zheng zhi neng gan tong 岂非正直能感通 须臾静扫众峰出 +qi gao xia zhi shi 其高下之势 岈然洼然 +qi gu zhou zhi li fu 泣孤舟之嫠妇 苏子愀然 +qi ji yao lin 棨戟遥临 宇文新州之懿范 +qi jia shen zhi qi zi 其家甚智其子 而疑邻人之父 +qi jian dan mu wen he wu 其间旦暮闻何物 杜鹃啼血猿哀鸣 +qi jian qian er bai li 其间千二百里 虽乘奔御风 +qi ke hu hu 其可忽乎 君人者 +qi li zhi guo 七里之郭 环而攻之而不胜 +qi liang bao jian pian 凄凉宝剑篇 羁泊欲穷年 +qi liang shu gu ji 凄凉蜀故妓 来舞魏宫前 +qi liang xi xiang bi zhe 其两膝相比者 各隐卷底衣褶中 +qi lin ren zhi fu yi yun 其邻人之父亦云 暮而果大亡其财 +qi ling peng ze zhi zun 气凌彭泽之樽 邺水朱华 +qi lu qing wei liao 齐鲁青未了 造化钟神秀 +qi ming wei yuan chu 其名为鹓鶵 子知之乎 +qi neng zhang shao nian 岂能长少年 +qi peng shang wei wang zuo 齐彭殇为妄作 后之视今 +qi qi bu si xiang qian sheng 凄凄不似向前声 满座重闻皆掩泣 +qi qi can can qi qi 凄凄惨惨戚戚 乍暖还寒时候 +qi qi li lie 其气栗冽 砭人肌骨 +qi qi man bie qing 萋萋满别情 +qi qi qie qie 凄凄切切 呼号愤发 +qi qi qu qin ai 凄凄去亲爱 泛泛入烟雾 +qi qi qu zhi yi shou zhi nan hu 岂其取之易守之难乎 昔取之而有余 +qi qi xian yi yue 其妻献疑曰 以君之力 +qi qie zhi feng 妻妾之奉 所识穷乏者得我与 +qi ren bu xie ye 乞人不屑也 万钟则不辨礼义而受之 +qi ren shi duan rong ji 其人视端容寂 若听茶声然 +qi rong qing ming 其容清明 天高日晶 +qi ru tu shi he 其如土石何 北山愚公长息曰 +qi ru yu nu li ren zhi shou 祇辱于奴隶人之手 骈死于槽枥之间 +qi se can dan 其色惨淡 烟霏云敛 +qi shan yi shui 奇山异水 天下独绝 +qi shang wang fu tai 岂上望夫台 十六君远行 +qi shen han gu 凄神寒骨 悄怆幽邃 +qi sheng wu wu ran 其声呜呜然 如怨如慕 +qi shi yi yang fu mu 其诗以养父母 收族为意 +qi shou zhi tian ye 其受之天也 贤于材人远矣 +qi suo yi cui bai ling luo zhe 其所以摧败零落者 乃其一气之余烈 +qi tun wan li ru hu 气吞万里如虎 元嘉草草 +qi wang zhai li xun chang jian 岐王宅里寻常见 崔九堂前几度闻 +qi wen li jie you ke guan zhe 其文理皆有可观者 邑人奇之 +qi wo qu zhe 弃我去者 昨日之日不可留 +qi wu nong qing ying 起舞弄清影 何似在人间 +qi wu shan ge yu cun di 岂无山歌与村笛 呕哑嘲哳难为听 +qi xi jin xiao kan bi xiao 七夕今宵看碧霄 牵牛织女渡河桥 +qi xi nan zhu feng 其西南诸峰 林壑尤美 +qi xi yang yang zhe yi 其喜洋洋者矣 嗟夫 +qi xian ye ru ci 其险也如此 嗟尔远道之人胡为乎来哉 +qi xiang ren jian zhu 岂向人间住 莫买沃洲山 +qi xiang wan qian 气象万千 此则岳阳楼之大观也 +qi xiao qiong tu zhi ku 岂效穷途之哭 勃 +qi yan zi ruo ren zhi chou hu 其言兹若人之俦乎 衔觞赋诗 +qi yi chong wen hou ting hua 岂宜重问后庭花 +qi yi di qi nuan 岂伊地气暖 自有岁寒心 +qi yi xiao tiao 其意萧条 山川寂寥 +qi yue ji wang 七月既望 苏子与客泛舟游于赤壁之下 +qi yue qi ri chang sheng dian 七月七日长生殿 夜半无人私语时 +qi zai bi zui 期在必醉 既醉而退 +qi zai fu xia ran 萁在釜下燃 豆在釜中泣 +qi zhen bu zhi ma ye 其真不知马也 +qi zhen wu ma xie 其真无马邪 其真不知马也 +qi zheng yun meng ze 气蒸云梦泽 波撼岳阳城 +qi zhi li yi 弃掷逦迤 秦人视之 +qi zhi shui ye 弃脂水也 烟斜雾横 +qi zhi wu bai li zai 岂直五百里哉 秦王怫然怒 +qi zhi yi ye 其致一也 后之览者 +qi zhong chuo yue duo xian zi 其中绰约多仙子 中有一人字太真 +qi zhong wang lai zhong zuo 其中往来种作 男女衣着 +qi zuo er xuan hua zhe 起坐而喧哗者 众宾欢也 +qia si yi jiang chun shui xiang dong liu 恰似一江春水向东流 +qia tong xue shao nian 恰同学少年 风华正茂 +qian bai cheng feng 千百成峰 泉水激石 +qian bu jian gu ren 前不见古人 后不见来者 +qian cao cai neng mo ma ti 浅草才能没马蹄 最爱湖东行不足 +qian chui wan zao chu shen shan 千锤万凿出深山 烈火焚烧若等闲 +qian cun wan luo sheng jing qi 千村万落生荆杞 纵有健妇把锄犁 +qian duo wan duo ya zhi di 千朵万朵压枝低 留连戏蝶时时舞 +qian gu feng liu ren wu 千古风流人物 故垒西边 +qian gu jiang shan 千古江山 英雄无觅 +qian gu xing wang duo shao shi 千古兴亡多少事 悠悠 +qian he ren huan qu 倩何人唤取 红巾翠袖 +qian hu wan huan shi chu lai 千呼万唤始出来 犹抱琵琶半遮面 +qian hua bu ke qi 铅华不可弃 莫是藁砧归 +qian huan bu yi hui 千唤不一回 十五始展眉 +qian jin san jin huan fu lai 千金散尽还复来 烹羊宰牛且为乐 +qian ke sao ren 迁客骚人 多会于此 +qian kun ri ye fu 乾坤日夜浮 亲朋无一字 +qian li bing feng 千里冰封 万里雪飘 +qian li feng ying 千里逢迎 高朋满座 +qian li gong chan juan 千里共婵娟 +qian li huang yun bai ri xun 千里黄云白日曛 北风吹雁雪纷纷 +qian li jiang ling yi ri huan 千里江陵一日还 两岸猿声啼不住 +qian li ma chang you 千里马常有 而伯乐不常有 +qian li qi ru he 千里其如何 微风吹兰杜 +qian li yan bo 千里烟波 暮霭沉沉楚天阔 +qian li ying ti lv ying hong 千里莺啼绿映红 水村山郭酒旗风 +qian lou zhi qi you yan 黔娄之妻有言 不戚戚于贫贱 +qian luo bu mao wu 牵萝补茅屋 摘花不插发 +qian men wan hu tong tong ri 千门万户曈曈日 总把新桃换旧符 +qian mo jiao tong 阡陌交通 鸡犬相闻 +qian mo wan ji hai jian jin 千磨万击还坚劲 任尔东西南北风 +qian nian fa yue zhi 前年伐月支 城下没全师 +qian niu zhi nv du he qiao 牵牛织女渡河桥 家家乞巧望秋月 +qian qi juan ping gang 千骑卷平冈 为报倾城随太守 +qian qiu shang lin ran 千秋尚凛然 势分三足鼎 +qian qiu wan sui ming 千秋万岁名 寂寞身后事 +qian ren zhi shu bei yi 前人之述备矣 然则北通巫峡 +qian shan gao fu di 千山高复低 好峰随处改 +qian shan niao fei jue 千山鸟飞绝 万径人踪灭 +qian shan xiang du juan 千山响杜鹃 山中一夜雨 +qian sheng wan qi xi nan xing 千乘万骑西南行 翠华摇摇行复止 +qian shu wan shu li hua kai 千树万树梨花开 散入珠帘湿罗幕 +qian wan he chun zhu 千万和春住 +qian wan ren zhi xin ye 千万人之心也 秦爱纷奢 +qian xin mo dao ruo you ying 潜心默祷若有应 岂非正直能感通 +qian yan wan zhuan lu bu ding 千岩万转路不定 迷花倚石忽已暝 +qian yi dun zu lan dao ku 牵衣顿足拦道哭 哭声直上干云霄 +qian ying gong yi hu 千营共一呼 +qian yue fu liang mai cha qu 前月浮梁买茶去 去来江口守空船 +qian zai pi pa zuo hu yu 千载琵琶作胡语 分明怨恨曲中论 +qian zhang jian di 千丈见底 游鱼细石 +qian zhe zhui hui liu zhe hai 迁者追回流者还 涤瑕荡垢清朝班 +qiang di he xu yuan yang liu 羌笛何须怨杨柳 春风不度玉门关 +qiang guan you you shuang man di 羌管悠悠霜满地 人不寐 +qiang lu hui fei yan mie 樯虏灰飞烟灭 故国神游 +qiang pai zuo e mei 强派作娥眉 殊未屑 +qiang qing ji cui 樯倾楫摧 薄暮冥冥 +qiang yi qi xi yi zhi an 强移栖息一枝安 +qiang yu cong jun wu na lao 强欲从君无那老 将因卧病解朝衣 +qiao chuang you sui 悄怆幽邃 以其境过清 +qiao ke chu chuan han xing ming 樵客初传汉姓名 居人未改秦衣服 +qiao men shi wen ye ren jia 敲门试问野人家 +qiao ren gui yu jin 樵人归欲尽 烟鸟栖初定 +qiao xiao dong lin nv ban 巧笑东邻女伴 采桑径里逢迎 +qiao ye bu zheng chun 俏也不争春 只把春来报 +qie er yu chu ye 且贰于楚也 晋军函陵 +qie fa chu fu e 妾发初覆额 折花门前剧 +qie fang bai lu qing ya jian 且放白鹿青崖间 须行即骑访名山 +qie fu tian di zhi jian 且夫天地之间 物各有主 +qie fu wo chang wen shao zhong ni zhi wen 且夫我尝闻少仲尼之闻 而轻伯夷之义者 +qie jiang tuan shan gong pai huai 且将团扇共徘徊 玉颜不及寒鸦色 +qie jun chang wei jin jun ci yi 且君尝为晋君赐矣 许君焦 +qie le sheng qian yi bei jiu 且乐生前一杯酒 何须身后千载名 +qie qin mie han wang wei 且秦灭韩亡魏 而君以五十里之地存者 +qie ru jin nian dong 且如今年冬 未休关西卒 +qie wei wang sun li si xu 且为王孙立斯须 昨夜东风吹血腥 +qie wei zhong ren 且为众人 今夫不受之天 +qie wen tian zi yi chuan wei 窃闻天子已传位 圣德北服南单于 +qie xin jing zhong shui 妾心井中水 +qie yan zhi tu shi 且焉置土石 杂曰 +qie yu jin xun peng ze zai 且欲近寻彭泽宰 陶然共醉菊花杯 +qie yu yu chang ma deng bu ke de 且欲与常马等不可得 安求其能千里也 +qie zhu zai heng tang 妾住在横塘 停船暂借问 +qin ai fen she 秦爱纷奢 人亦念其家 +qin bo wei zheng 秦伯围郑 以其无礼于晋 +qin di luo fu nv 秦地罗敷女 采桑绿水边 +qin jiu zhi qi ru ci 亲旧知其如此 或置酒而招之 +qin jun fan nan 秦军氾南 佚之狐言于郑伯曰 +qin peng wu yi zi 亲朋无一字 老病有孤舟 +qin qi pan zhi 亲戚畔之 多助之至 +qin ren bu xia zi ai 秦人不暇自哀 而后人哀之 +qin ren shi zhi 秦人视之 亦不甚惜 +qin sang di lv zhi 秦桑低绿枝 当君怀归日 +qin se you zhi 琴瑟友之 参差荇菜 +qin shi ming yue han shi guan 秦时明月汉时关 万里长征人未还 +qin wang bu shuo 秦王不说 安陵君因使唐雎使于秦 +qin wang fu ran nu 秦王怫然怒 谓唐雎曰 +qin wang se nao 秦王色挠 长跪而谢之曰 +qin wang shi ren wei an ling jun yue 秦王使人谓安陵君曰 寡人欲以五百里之地易安陵 +qin wang wei tang ju yue 秦王谓唐雎曰 寡人以五百里之地易安陵 +qin xiao kui yan yu 侵晓窥檐语 叶上初阳干宿雨 +qing ai ru kan wu 青霭入看无 分野中峰变 +qing bu sheng qi yu 情不胜其欲 斯亦伐根以求木茂 +qing cao chi tang chu chu wa 青草池塘处处蛙 有约不来过夜半 +qing che jian di 清澈见底 晶晶然如镜之新开而冷光之乍出于匣也 +qing chen ru gu si 清晨入古寺 初日照高林 +qing chuan dai chang bo 清川带长薄 车马去闲闲 +qing chuan li li han yang shu 晴川历历汉阳树 芳草萋萋鹦鹉洲 +qing chun xu zao wei 青春须早为 岂能长少年 +qing chun zuo ban hao huan xiang 青春作伴好还乡 即从巴峡穿巫峡 +qing cong li ye gui 请从吏夜归 急应河阳役 +qing cui jie huang cheng 晴翠接荒城 又送王孙去 +qing feng ban ye ming chan 清风半夜鸣蝉 稻花香里说丰年 +qing feng chui kong yue shu bo 清风吹空月舒波 沙平水息声影绝 +qing feng jiang shang qiu fan yuan 青枫江上秋帆远 白帝城边古木疏 +qing feng xu lai 清风徐来 水波不兴 +qing feng ye chi tian yu shuang 青枫叶赤天雨霜 玉京群帝集北斗 +qing gua ren yu 轻寡人与 唐雎对曰 +qing guang you wei jun 清光犹为君 茅亭宿花影 +qing guang yu jun 请广于君 而君逆寡人者 +qing hai zhang yun an xue shan 青海长云暗雪山 孤城遥望玉门关 +qing hu er zui 倾壶而醉 醉则更相枕以卧 +qing huai feng shi qian yu li 清淮奉使千馀里 敢告云山从此始 +qing hui dan shui mu 清辉淡水木 演漾在窗户 +qing hui yu bi han 清辉玉臂寒 何时倚虚幌 +qing jin dang tu zhang shi 卿今当涂掌事 不可不学 +qing jin zhe cai lue 卿今者才略 非复吴下阿蒙 +qing jun shi wen dong liu shui 请君试问东流水 别意与之谁短长 +qing jun wei wo ce er ting 请君为我侧耳听 钟鼓馔玉不足贵 +qing ke zhuo fu qing 倾榼浊复清 莫辞酒味薄 +qing kong yi he pai yun shang 晴空一鹤排云上 便引诗情到碧霄 +qing li si nian chun 庆历四年春 滕子京谪守巴陵郡 +qing lin cui zhu 青林翠竹 四时俱备 +qing liu jian di 清流见底 两岸石壁 +qing long man nian mo fu tiao 轻拢慢捻抹复挑 初为霓裳后六幺 +qing lou zi guan xian 青楼自管弦 新知遭薄俗 +qing luo fu xing yi 青萝拂行衣 欢言得所憩 +qing luo xiao shan pu liu ying 轻罗小扇扑流萤 天阶夜色凉如水 +qing luo yi bo wei geng yi 轻罗已薄未更衣 银筝夜久殷勤弄 +qing ming hao dang bu jian di 青冥浩荡不见底 日月照耀金银台 +qing ming shi jie yu fen fen 清明时节雨纷纷 路上行人欲断魂 +qing ni he pan pan 青泥何盘盘 百步九折萦岩峦 +qing niao fei qu xian hong jin 青鸟飞去衔红巾 炙手可热势绝伦 +qing niao yin qin wei tan kan 青鸟殷勤为探看 +qing qin jian jun yi 情亲见君意 告归常局促 +qing qing yuan zhong kui 青青园中葵 朝露待日晞 +qing qiu mu fu jing wu han 清秋幕府井梧寒 独宿江城蜡炬残 +qing quan shi shang liu 清泉石上流 竹喧归浣女 +qing que huang long zhi zhu 青雀黄龙之舳 云销雨霁 +qing ren yuan yao ye 情人怨遥夜 竟夕起相思 +qing rong jun mao 清荣峻茂 良多趣味 +qing sa pan jiang 请洒潘江 各倾陆海云尔 +qing se yuan yao ye 清瑟怨遥夜 绕弦风雨哀 +qing shan du gui yuan 青山独归远 +qing shan guo wai xie 青山郭外斜 开轩面场圃 +qing shan heng bei guo 青山横北郭 白水绕东城 +qing shan lv shui 青山绿水 白草红叶黄花 +qing shan yin yin shui tiao tiao 青山隐隐水迢迢 秋尽江南草未凋 +qing shan zhao bie mu hai jian 青山朝别暮还见 嘶马出门思旧乡 +qing shi you wei shi wu neng 清时有味是无能 闲爱孤云静爱僧 +qing shu cui man 青树翠蔓 蒙络摇缀 +qing sui shi qian 情随事迁 感慨系之矣 +qing tian wu pian yun 青天无片云 登舟望秋月 +qing ting fei shang yu sao tou 蜻蜓飞上玉搔头 +qing wei fu lao ge 请为父老歌 艰难愧深情 +qing xi ji du dao yun lin 青溪几度到云林 春来遍是桃花水 +qing xi shen bu ce 清溪深不测 隐处唯孤云 +qing xian shi jin 请献十金 公输盘曰 +qing yan duo wu 卿言多务 孰若孤 +qing yan lao shu han ya 轻烟老树寒鸦 一点飞鸿影下 +qing yan liao yin mei jiu heng 青眼聊因美酒横 万里归船弄长笛 +qing yan san ru wu hou jia 轻烟散入五侯家 +qing yue chu ling guang ru fei 清月出岭光入扉 天明独去无道路 +qing yun xian niao fei 青云羡鸟飞 圣朝无阙事 +qing zhou yi guo wan chong shan 轻舟已过万重山 +qing zou ming qin guang ling ke 请奏鸣琴广陵客 月照城头乌半飞 +qiong dao yu zhi ying hui 穷岛屿之萦回 桂殿兰宫 +qiong di mian yu zhong tian 穷睇眄于中天 极娱游于暇日 +qiong dong lie feng 穷冬烈风 大雪深数尺 +qiong qie yi jian 穷且益坚 不坠青云之志 +qiong shan zhi gao er shang 穷山之高而上 攀援而登 +qiu cao du xun ren qu hou 秋草独寻人去后 寒林空见日斜时 +qiu feng chui bu jin 秋风吹不尽 总是玉关情 +qiu feng hu yi jiang dong xing 秋风忽忆江东行 且乐生前一杯酒 +qiu feng xiao se 秋风萧瑟 洪波涌起 +qiu hua luo geng chi 秋花落更迟 家童扫萝径 +qiu jin jiang nan cao wei diao 秋尽江南草未凋 二十四桥明月夜 +qiu luan beng cui 丘峦崩摧 洞天石扉 +qiu mu zhi zhang zhe 求木之长者 必固其根本 +qiu ri wang xiang xin 秋日望乡心 野寺人来少 +qiu rong ru shi 秋容如拭 四面歌残终破楚 +qiu se lian bo 秋色连波 波上寒烟翠 +qiu shan you ji chong 秋山又几重 +qiu shou wan ke zi 秋收万颗子 四海无闲田 +qiu shui gong chang tian yi se 秋水共长天一色 渔舟唱晚 +qiu shui ri chan yuan 秋水日潺湲 倚杖柴门外 +qiu shui shi zhi 秋水时至 百川灌河 +qiu tian mo mo xiang hun hei 秋天漠漠向昏黑 布衾多年冷似铁 +qiu tian wen she 求田问舍 怕应羞见 +qiu xu hu mei reng da sang 虬须虎眉仍大颡 腹中贮书一万卷 +qiu yan you nan hui 秋雁又南回 +qiu yu wu tong ye luo shi 秋雨梧桐叶落时 西宫南内多秋草 +qiu yue chun feng deng xian du 秋月春风等闲度 弟走从军阿姨死 +qiu yun an ji chong 秋云暗几重 +qiu zhi bu de 求之不得 寤寐思服 +qu ba ceng jiao shan cai fu 曲罢曾教善才服 妆成每被秋娘妒 +qu che deng gu yuan 驱车登古原 夕阳无限好 +qu dao cang mang jie cui wei 曲岛苍茫接翠微 波上马嘶看棹去 +qu er xiao qiang er da 曲儿小腔儿大 官船来往乱如麻 +qu hou nai zhi 去后乃至 元方时年七岁 +qu ji shang shu mu 驱鸡上树木 始闻叩柴荆 +qu jia yi yu chang sha 屈贾谊于长沙 非无圣主 +qu jin he xing xi 曲尽河星稀 我醉君复乐 +qu jing tong you chu 曲径通幽处 禅房花木深 +qu lai jiang kou shou kong chuan 去来江口守空船 绕船月明江水寒 +qu nian hua li feng jun bie 去年花里逢君别 今日花开已一年 +qu nian tian qi jiu ting tai 去年天气旧亭台 夕阳西下几时回 +qu shi fa zhou qing 去世法舟轻 水月通禅寂 +qu shi guan jian shi ding nian 去时冠剑是丁年 茂陵不见封侯印 +qu shi li zheng yu guo tou 去时里正与裹头 归来头白还戍边 +qu shi xue man tian shan lu 去时雪满天山路 山回路转不见君 +qu tang yan yu dui 瞿塘滟预堆 五月不可触 +qu xiang xiang tian ge 曲项向天歌 白毛浮绿水 +qu you bi zhi chuan 诎右臂支船 而竖其左膝 +qu yuan zhong tou xiang shui bin 屈原终投湘水滨 陆机雄才岂自保 +qu zhi bie yuan 驱之别院 +qu zhi wu jin 取之无禁 用之不竭 +qu zhong shou bo dang xin hua 曲终收拨当心画 四弦一声如裂帛 +quan er ming zhe 泉而茗者 罍而歌者 +quan he dai mao ci 泉壑带茅茨 云霞生薜帷 +quan jun geng jin yi bei jiu 劝君更尽一杯酒 西出阳关无故人 +quan jun mo xi jin lv yi 劝君莫惜金缕衣 劝君惜取少年时 +quan jun xi qu shao nian shi 劝君惜取少年时 花开堪折直须折 +quan sheng yan wei shi 泉声咽危石 日色冷青松 +quan shi yi wei di 全石以为底 近岸 +quan shui ji shi 泉水激石 泠泠作响 +quan wei lv meng yue 权谓吕蒙曰 卿今当涂掌事 +quan xiang er jiu lie 泉香而酒洌 山肴野蔌 +quan yan wu sheng xi xi liu 泉眼无声惜细流 树阴照水爱晴柔 +quan zhang ni tai sheng jia 全仗你抬声价 军听了军愁 +que gu suo lai jing 却顾所来径 苍苍横翠微 +que hua ba shan ye yu shi 却话巴山夜雨时 +que kan qi zi chou he zai 却看妻子愁何在 漫卷诗书喜欲狂 +que qin yi li jin 阙秦以利晋 唯君图之 +que shi jiu shi xiang shi 却是旧时相识 满地黄花堆积 +que xia shui jing lian 却下水晶帘 玲珑望秋月 +que xian zhi fen wu yan se 却嫌脂粉污颜色 淡扫蛾眉朝至尊 +que zuo cu xian xian zhuan ji 却坐促弦弦转急 凄凄不似向前声 +qun he shu yi ming 群壑倏已暝 松月生夜凉 +qun ji zheng luan jiao 群鸡正乱叫 客至鸡斗争 +qun shan wan he fu jing men 群山万壑赴荆门 生长明妃尚有村 +qun xian bi zhi 群贤毕至 少长咸集 +ran cao sui neng ke shao 然操遂能克绍 以弱为强者 +ran chen zhi di zi qin hua li deng san bai ren 然臣之弟子禽滑厘等三百人 已持臣守圉之器 +ran er bu sheng zhe 然而不胜者 是天时不如地利也 +ran er qin niao zhi shan lin zhi le 然而禽鸟知山林之乐 而不知人之乐 +ran hou neng gai 然后能改 困于心 +ran hou shi xing 然后施行 必能裨补阙漏 +ran hou tian ti shi zhan xiang gou lian 然后天梯石栈相钩连 上有六龙回日之高标 +ran hou you qian li ma 然后有千里马 千里马常有 +ran hou zhi sheng yu you huan er si yu an le ye 然后知生于忧患而死于安乐也 +ran hou zhi shi shan zhi te li 然后知是山之特立 不与培塿为类 +ran hou zhi song bai zhi hou diao ye 然后知松柏之后凋也 +ran hou zhi wu xiang zhi wei shi you 然后知吾向之未始游 游于是乎始 +ran hu bu yi hu 然胡不已乎 公输盘曰 +ran ran ji ying xu 苒苒几盈虚 澄澄变今古 +ran shi wei zhi chen bu xie yu nei 然侍卫之臣不懈于内 忠志之士忘身于外者 +ran shu nian heng bu yi jian 然数年恒不一见 孙公子禹年与同人饮楼上 +ran tu bu ze han chu jia bei 然徒步则汗出浃背 凡曝沙之鸟 +ran ze bei tong wu xia 然则北通巫峡 南极潇湘 +ran ze he shi er le ye 然则何时而乐耶 其必曰 +ran zhi you wei yi 然志犹未已 君谓计将安出 +rang chu jian xiong 攘除奸凶 兴复汉室 +rao chuan yue ming jiang shui han 绕船月明江水寒 夜深忽梦少年事 +rao chuang nong qing mei 绕床弄青梅 同居长干里 +rao xian feng yu ai 绕弦风雨哀 孤灯闻楚角 +ren bi huang hua shou 人比黄花瘦 +ren bu zhi er bu yun 人不知而不愠 不亦君子乎 +ren dao ji nu ceng zhu 人道寄奴曾住 想当年 +ren er dong xi nan bei feng 任尔东西南北风 +ren gui mu xue shi 人归暮雪时 少孤为客早 +ren ji ban qiao shuang 人迹板桥霜 槲叶落山路 +ren jia zai he xu 人家在何许 云外一声鸡 +ren jian neng de ji hui wen 人间能得几回闻 +ren jian yi zi you dan qiu 人间亦自有丹丘 +ren jie di ling 人杰地灵 徐孺下陈蕃之榻 +ren jie you zhi 人皆有之 贤者能勿丧耳 +ren jun dang shen qi zhi zhong 人君当神器之重 居域中之大 +ren kan ai ruo ye 人看隘若耶 回舟不待月 +ren lai niao bu jing 人来鸟不惊 +ren neng dui mian wei dao zei 忍能对面为盗贼 公然抱茅入竹去 +ren sheng de yi xu jin huan 人生得意须尽欢 莫使金尊空对月 +ren sheng ru ci zi ke le 人生如此自可乐 岂必局束为人鞿 +ren sheng ru meng 人生如梦 一尊还酹江月 +ren sheng you ming fei you ta 人生由命非由他 有酒不饮奈明何 +ren sheng zai shi bu chen yi 人生在世不称意 明朝散发弄扁舟 +ren sheng zi gu shui wu si 人生自古谁无死 留取丹心照汗青 +ren shi yin shu man ji liao 人事音书漫寂寥 +ren shui gan zhi jing 人谁感至精 飞沈理自隔 +ren sui sha an xiang jiang cun 人随沙岸向江村 余亦乘舟归鹿门 +ren wei dong wu 人为动物 惟物之灵 +ren wu lue bu xiang du 人物略不相睹 水爆轰震 +ren xian gui hua luo 人闲桂花落 夜静春山空 +ren xu zhi qiu 壬戌之秋 七月既望 +ren xue shi zhi dao 人学始知道 不学非自然 +ren yi dong xi 任意东西 自富阳至桐庐一百许里 +ren yi nian qi jia 人亦念其家 奈何取之尽锱铢 +ren yi wei ji ren 仁以为己任 不亦重乎 +ren ying san luan 人影散乱 太守归而宾客从也 +ren you bei huan li he 人有悲欢离合 月有阴晴圆缺 +ren zai mu lan zhou 人在木兰舟 广泽生明月 +ren zhe bo qi hui 仁者播其惠 信者效其忠 +ren zhi cong tai shou you er le 人知从太守游而乐 而不知太守之乐其乐也 +ren zhong er dao yuan 任重而道远 仁以为己任 +reng lian gu xiang shui 仍怜故乡水 万里送行舟 +ri ban zhong yong huan ye yu yi ren 日扳仲永环谒于邑人 不使学 +ri chu jiang hua hong sheng huo 日出江花红胜火 春来江水绿如蓝 +ri chu yun zhong ji quan xuan 日出云中鸡犬喧 惊闻俗客争来集 +ri gao hua ying chong 日高花影重 年年越溪女 +ri gao ren ke man si cha 日高人渴漫思茶 敲门试问野人家 +ri guang xia che 日光下澈 影布石上 +ri mu dong feng yuan ti niao 日暮东风怨啼鸟 落花犹似坠楼人 +ri mu han gong chuan la zhu 日暮汉宫传蜡烛 轻烟散入五侯家 +ri mu ke chou xin 日暮客愁新 野旷天低树 +ri mu liao wei liang fu yin 日暮聊为梁甫吟 +ri mu xiang guan he chu shi 日暮乡关何处是 烟波江上使人愁 +ri mu yan chai fei 日暮掩柴扉 春草明年绿 +ri mu yi xiu zhu 日暮倚修竹 +ri se cai lin xian zhang dong 日色才临仙掌动 香烟欲傍衮龙浮 +ri se leng qing song 日色冷青松 薄暮空潭曲 +ri se yi jin hua han yan 日色已尽花含烟 月明欲素愁不眠 +ri wan juan shu tou 日晚倦梳头 物是人非事事休 +ri xi huai kong yi 日夕怀空意 人谁感至精 +ri xi wang jun bao qin zhi 日夕望君抱琴至 +ri xing yin yao 日星隐曜 山岳潜形 +ri yi jiao gu 日益骄固 戍卒叫 +ri yu qi tu shang gao shan 日与其徒上高山 入深林 +ri yue zhao yao jin yin tai 日月照耀金银台 霓为衣兮风为马 +ri yue zhi xing 日月之行 若出其中 +ri zhang fei xu qing 日长飞絮轻 巧笑东邻女伴 +ri zhang li luo wu ren guo 日长篱落无人过 惟有蜻蜓蛱蝶飞 +ri zhao xiang lu sheng zi yan 日照香炉生紫烟 遥看瀑布挂前川 +ri zhong bu zhi 日中不至 则是无信 +rong ma guan shan bei 戎马关山北 凭轩涕泗流 +rou shao pi feng 柔梢披风 麦田浅鬣寸许 +ru bo tao ye jing 如波涛夜惊 风雨骤至 +ru chu shi zhi 汝出视之 童子曰 +ru ci qi xian ye 如此其贤也 不受之人 +ru jin you shui kan zhai 如今有谁堪摘 守着窗儿 +ru jin zhi shang yin he qu 如今直上银河去 同到牵牛织女家 +ru lv ping di 如履平地 倏尔黄烟四起 +ru ming pei huan 如鸣珮环 心乐之 +ru qi ru su 如泣如诉 余音袅袅 +ru qian nv zhi hui mian er ji huan zhi shi lue ye 如倩女之靧面而髻鬟之始掠也 柳条将舒未舒 +ru shi ren zhi suo yu mo shen yu sheng 如使人之所欲莫甚于生 则凡可以得生者 +ru ting wan he song 如听万壑松 客心洗流水 +ru ting xian yue er zan ming 如听仙乐耳暂明 莫辞更坐弹一曲 +ru wen qi you ye 如闻泣幽咽 天明登前途 +ru xin zhi gu 汝心之固 固不可彻 +ru you suo yu 如有所语 东坡现右足 +ru yuan ru mu 如怨如慕 如泣如诉 +ru ze wu fa jia bi shi 入则无法家拂士 出则无敌国外患者 +ru zhi bu hui 汝之不惠 以残年余力 +ru zhu wan gan xie 入竹万竿斜 +ru zhu yu zhi tan xi 如助予之叹息 +ruan ji chang kuang 阮籍猖狂 岂效穷途之哭 +rui nao xiao jin shou 瑞脑消金兽 佳节又重阳 +run wu xi wu sheng 润物细无声 野径云俱黑 +ruo bu que qin 若不阙秦 将焉取之 +ruo chu qi li 若出其里 幸甚至哉 +ruo chu qi zhong 若出其中 星汉灿烂 +ruo dao jiang nan gan shang chun 若到江南赶上春 千万和春住 +ruo die ruo xue 若垤若穴 尺寸千里 +ruo fei qun yu shan tou jian 若非群玉山头见 会向瑶台月下逢 +ruo fu ping sheng zhi 若负平生志 冠盖满京华 +ruo fu ri chu er lin fei kai 若夫日出而林霏开 云归而岩穴暝 +ruo fu yin yu fei fei 若夫霪雨霏霏 连月不开 +ruo he yi qi 若合一契 未尝不临文嗟悼 +ruo kua you jing 若跨有荆 益 +ruo peng fu zhi 箬篷覆之 旁开小窗 +ruo she zheng yi wei dong dao zhu 若舍郑以为东道主 行李之往来 +ruo shi bi nu 若士必怒 伏尸二人 +ruo shi zhu zhi wu jian qin jun 若使烛之武见秦君 师必退 +ruo ting cha sheng ran 若听茶声然 其船背稍夷 +ruo tuo long zhi hu 若脱笼之鹄 于时冰皮始解 +ruo wang zheng er you yi yu jun 若亡郑而有益于君 敢以烦执事 +ruo wu xing de zhi yan 若无兴德之言 则责攸之 +ruo xiao hu zhuang 若啸呼状 居左者右手执蒲葵扇 +ruo you zuo jian fan ke ji wei zhong shan zhe 若有作奸犯科及为忠善者 宜付有司论其刑赏 +sa sa dong feng xi yu lai 飒飒东风细雨来 芙蓉塘外有轻雷 +sa yan kong zhong cha ke ni 撒盐空中差可拟 兄女曰 +sai shang yan zhi ning ye zi 塞上燕脂凝夜紫 半卷红旗临易水 +sai xia qiu lai feng jing yi 塞下秋来风景异 衡阳雁去无留意 +sai yuan er yu liu chang ye 塞源而欲流长也 凡百元首 +san bei liang zhan dan jiu 三杯两盏淡酒 怎敌他 +san bu yong liang tian 散步咏凉天 空山松子落 +san chi wei ming 三尺微命 一介书生 +san chun bai xue gui qing zhong 三春白雪归青冢 万里黄河绕黑山 +san fen ge ju yu chou ce 三分割据纡筹策 万古云霄一羽毛 +san gu chen yu cao lu zhi zhong 三顾臣于草庐之中 咨臣以当世之事 +san gu pin fan tian xia ji 三顾频烦天下计 两朝开济老臣心 +san guo zhou lang chi bi 三国周郎赤壁 乱石穿空 +san jin yun shan jie bei xiang 三晋云山皆北向 二陵风雨自东来 +san jun da hu yin shan dong 三军大呼阴山动 虏塞兵气连云屯 +san li zhi cheng 三里之城 七里之郭 +san nan ye cheng shu 三男邺城戍 一男附书至 +san nian duo nan geng ping wei 三年多难更凭危 白头吊古风霜里 +san nian ji lv ke 三年羁旅客 今日又南冠 +san nian zhe huan ci qi chi 三年谪宦此栖迟 万古惟留楚客悲 +san qian chong ai zai yi shen 三千宠爱在一身 金屋妆成娇侍夜 +san ri ru chu xia 三日入厨下 洗手作羹汤 +san ru chun feng man luo cheng 散入春风满洛城 此夜曲中闻折柳 +san ru zhu lian shi luo mu 散入珠帘湿罗幕 狐裘不暖锦衾薄 +san shan ban luo qing tian wai 三山半落青天外 二水中分白鹭洲 +san shi liu nian 三十六年 燕 +san wan li he dong ru hai 三万里河东入海 五千仞岳上摩天 +san xia lou tai yan ri yue 三峡楼台淹日月 五溪衣服共云山 +san xia xing he ying dong yao 三峡星河影动摇 野哭几家闻战伐 +san xiang chou bin feng qiu se 三湘愁鬓逢秋色 万里归心对月明 +san ye pin meng jun 三夜频梦君 情亲见君意 +san yue san ri tian qi xin 三月三日天气新 长安水边多丽人 +sang yu fei wan 桑榆非晚 孟尝高洁 +se nan xing fu can feng xiang 色难腥腐餐枫香 周南留滞古所惜 +se se gu zhong feng 瑟瑟谷中风 风声一何盛 +sen ran po dong xia ma bai 森然魄动下马拜 松柏一径趋灵宫 +seng yan gu bi fo hua hao 僧言古壁佛画好 以火来照所见稀 +sha chang qiu dian bing 沙场秋点兵 马作的卢飞快 +sha chuang ri luo jian huang hun 纱窗日落渐黄昏 金屋无人见泪痕 +sha kou shi dong ma ti tuo 沙口石冻马蹄脱 亚相勤王甘苦辛 +sha nuan shui yuan yang 沙暖睡鸳鸯 +sha ou xiang ji 沙鸥翔集 锦鳞游泳 +sha ping shui xi sheng ying jue 沙平水息声影绝 一杯相属君当歌 +sha qi san shi zuo zhen yun 杀气三时作阵云 寒声一夜传刁斗 +sha ren mo gan qian 杀人莫敢前 须如猬毛磔 +sha ren ru ma 杀人如麻 锦城虽云乐 +sha suo bu zu er zheng suo you yu 杀所不足而争所有余 不可谓智 +shai jiu lin jiang 酾酒临江 横槊赋诗 +shan ai xi yang shi 山爱夕阳时 闲鹭栖常早 +shan bu zai gao 山不在高 有仙则名 +shan cai yue po xiu nan yan 扇裁月魄羞难掩 车走雷声语未通 +shan chang shui kuo zhi he chu 山长水阔知何处 +shan chuan ji liao 山川寂寥 故其为声也 +shan chuan xiang miu 山川相缪 郁乎苍苍 +shan chuan xiao tiao ji bian tu 山川萧条极边土 胡骑凭陵杂风雨 +shan chuan zhi mei 山川之美 古来共谈 +shan dao song zhi 山岛竦峙 树木丛生 +shan ding qian men ci di kai 山顶千门次第开 一骑红尘妃子笑 +shan gao yue xiao 山高月小 水落石出 +shan guang yue niao xing 山光悦鸟性 潭影空人心 +shan he biao li tong guan lu 山河表里潼关路 望西都 +shan he po sui feng piao xu 山河破碎风飘絮 身世浮沉雨打萍 +shan hong jian bi fen lan man 山红涧碧纷烂漫 时见松枥皆十围 +shan hui lu zhuan bu jian jun 山回路转不见君 雪上空留马行处 +shan ji jian lai yan 山际见来烟 竹中窥落日 +shan jian zhi si shi ye 山间之四时也 朝而往 +shan jian zhi zhao mu ye 山间之朝暮也 野芳发而幽香 +shan kai kuang wang xuan ping lu 山开旷望旋平陆 遥看一处攒云树 +shan kou qian xing shi wei yu 山口潜行始隈隩 山开旷望旋平陆 +shan luan wei qing xue suo xi 山峦为晴雪所洗 娟然如拭 +shan qi ri xi jia 山气日夕佳 飞鸟相与还 +shan se kong meng yu yi qi 山色空蒙雨亦奇 欲把西湖比西子 +shan se yao lian qin shu wan 山色遥连秦树晚 砧声近报汉宫秋 +shan se you wu zhong 山色有无中 郡邑浮前浦 +shan shan wei luo hui 山山唯落晖 牧人驱犊返 +shan shi luo que xing jing wei 山石荦确行径微 黄昏到寺蝙蝠飞 +shan shi mei feng ju 山是眉峰聚 欲问行人去那边 +shan shui zhi le 山水之乐 得之心而寓之酒也 +shan si zhong ming zhou yi hun 山寺钟鸣昼已昏 渔梁渡头争渡喧 +shan sui ping ye jin 山随平野尽 江入大荒流 +shan wai qing shan lou wai lou 山外青山楼外楼 西湖歌舞几时休 +shan wu yin she 山舞银蛇 原驰蜡象 +shan xia lan ya duan jin xi 山下兰芽短浸溪 松间沙路净无泥 +shan xing liu qi li 山行六七里 渐闻水声潺潺而泻出于两峰之间者 +shan yao ye su 山肴野蔌 杂然而前陈者 +shan ying xie yang tian jie shui 山映斜阳天接水 芳草无情 +shan you xiao kou 山有小口 仿佛若有光 +shan yuan kuang qi ying shi 山原旷其盈视 川泽纡其骇瞩 +shan yue qian xing 山岳潜形 商旅不行 +shan yue sui ren gui 山月随人归 却顾所来径 +shan zai xu wu piao miao jian 山在虚无缥渺间 楼阁玲珑五云起 +shan zhi seng zhi xian ye 山之僧智仙也 名之者谁 +shan zhong shui fu yi wu lu 山重水复疑无路 柳暗花明又一村 +shan zhong xiang song ba 山中相送罢 日暮掩柴扉 +shan zhong yi ye yu 山中一夜雨 树杪百重泉 +shang cai cang ying he zu dao 上蔡苍鹰何足道 君不见吴中张翰称达生 +shang chu chong xiao 上出重霄 飞阁流丹 +shang ci bai qian qiang 赏赐百千强 可汗问所欲 +shang guo sui yuan zhu 上国随缘住 来途若梦行 +shang jiang yong mao xi chu zheng 上将拥旄西出征 平明吹笛大军行 +shang lin yuan dao qiong 伤麟怨道穷 今看两楹奠 +shang lv bu xing 商旅不行 樯倾楫摧 +shang neng fan fou 尚能饭否 +shang nv bu zhi wang guo hen 商女不知亡国恨 隔江犹唱后庭花 +shang qiong bi luo xia huang quan 上穷碧落下黄泉 两处茫茫皆不见 +shang ren zhong li qing bie li 商人重利轻别离 前月浮梁买茶去 +shang sheng zhu xi fang zhi yin 商声主西方之音 夷则为七月之律 +shang si wei guo shu lun tai 尚思为国戍轮台 夜阑卧听风吹雨 +shang xia tian guang 上下天光 一碧万顷 +shang xia yi bai 上下一白 湖上影子 +shang xin qin han jing xing chu 伤心秦汉经行处 宫阙万间都做了土 +shang yi fang jin cui yun qiu 尚衣方进翠云裘 九天阊阖开宫殿 +shang you huang li shen shu ming 上有黄鹂深树鸣 春潮带雨晚来急 +shang you liu long hui ri zhi gao biao 上有六龙回日之高标 下有冲波逆折之回川 +shang you qing ming zhi chang tian 上有青冥之长天 下有渌水之波澜 +shao fu cheng nan yu duan chang 少妇城南欲断肠 征人蓟北空回首 +shao fu jin chun yi 少妇今春意 良人昨夜情 +shao gu wei ke zao 少孤为客早 多难识君迟 +shao nian shi wu er shi shi 少年十五二十时 步行夺得胡马骑 +shao shao bin ke qi fu 稍稍宾客其父 或以钱币乞之 +shao xiao li jia lao da hui 少小离家老大回 乡音无改鬓毛衰 +shao xiao you yan ke 少小幽燕客 赌胜马蹄下 +shao xun feng sao 稍逊风骚 一代天骄 +shao zhang xian ji 少长咸集 此地有崇山峻岭 +shao zhuang bu nu li 少壮不努力 老大徒伤悲 +she jiu sha ji zuo shi 设酒杀鸡作食 村中闻有此人 +she nan she bei jie chun shui 舍南舍北皆春水 但见群鸥日日来 +she qi jin xiu 舍其锦绣 邻有短褐而欲窃之 +she qi liang rou 舍其粱肉 邻有糠糟而欲窃之 +she qi wen xuan 舍其文轩 邻有敝舆而欲窃之 +she sha shan zhong bai e hu 射杀山中白额虎 肯数邺下黄须儿 +she sheng er qu yi zhe ye 舍生而取义者也 生亦我所欲 +she sheng yi ru ci 舍生亦如此 波澜誓不起 +she shu yi ri xing wan li 赦书一日行万里 罪从大辟皆除死 +she xun wei du xiu fu rong 麝熏微度绣芙蓉 刘郎已恨蓬山远 +she yi tu er er chong jin wei suo tun 舌一吐而二虫尽为所吞 余年幼 +she yu er qu xiong zhang zhe ye 舍鱼而取熊掌者也 生 +she zan hu yu bai ling 舍簪笏于百龄 奉晨昏于万里 +shen chang ba chi 身长八尺 每自比于管仲 +shen dang en yu chang qing di 身当恩遇常轻敌 力尽关山未解围 +shen deng qing yun ti 身登青云梯 半壁见海日 +shen duo ji bing si tian li 身多疾病思田里 邑有流亡愧俸钱 +shen gong er shi nian 深宫二十年 一声何满子 +shen gui sui shou 神龟虽寿 犹有竟时 +shen lin ren bu zhi 深林人不知 明月来相照 +shen mo jin qian cheng xiang chen 慎莫近前丞相嗔 +shen qing yu su 神情与苏 黄不属 +shen ru bu mao 深入不毛 今南方已定 +shen shan he chu zhong 深山何处钟 泉声咽危石 +shen shan qie ting lai yao jing 深山窃听来妖精 言迟更速皆应手 +shen shang wu you wan ji fu 身上无有完肌肤 高帝子孙尽隆准 +shen shang yi shang kou zhong shi 身上衣裳口中食 可怜身上衣正单 +shen shi fu chen yu da ping 身世浮沉雨打萍 惶恐滩头说惶恐 +shen suo chun guang yi yuan chou 深锁春光一院愁 行到中庭数花朵 +shen wu cai feng shuang fei yi 身无彩凤双飞翼 心有灵犀一点通 +shen wu chu kou ta ren ju 慎勿出口他人狙 哀哉王孙慎勿疏 +shen xiang ting bei yi lan gan 沈香亭北倚阑干 +shen xiang yu guan na pan xing 身向榆关那畔行 夜深千帐灯 +shen you qi zhong 神游其中 怡然自得 +shen yu fen fei bing zai chuang 身欲奋飞病在床 美人娟娟隔秋水 +shen zhu an fu yan 深竹暗浮烟 更有明朝恨 +shen zhui xian di yi zhao 深追先帝遗诏 臣不胜受恩感激 +shen zong yu fu nan wei gong 神纵欲福难为功 夜投佛寺上高阁 +sheng bie chang ce ce 生别常恻恻 江南瘴疠地 +sheng chao wu que shi 圣朝无阙事 自觉谏书稀 +sheng dai ji jin duo yu lu 圣代即今多雨露 暂时分手莫踌躇 +sheng dai wu yin zhe 圣代无隐者 英灵尽来归 +sheng dang zuo ren jie 生当作人杰 死亦为鬼雄 +sheng de bei fu nan chan yu 圣德北服南单于 花门剺面请雪耻 +sheng di bu chang 胜地不常 盛筵难再 +sheng duan heng yang zhi pu 声断衡阳之浦 遥襟甫畅 +sheng er bu xiang xian 生儿不象贤 凄凉蜀故妓 +sheng jie yu lv jian fu jiu 升阶伛偻荐脯酒 欲以菲薄明其衷 +sheng nan mai mo sui bai cao 生男埋没随百草 君不见 +sheng nv you de jia bi lin 生女犹得嫁比邻 生男埋没随百草 +sheng ping bu gan qing yan yu 生平不敢轻言语 一叫千门万户开 +sheng ri xun fang si shui bin 胜日寻芳泗水滨 无边光景一时新 +sheng ru beng shan 声如崩山 烟消波静 +sheng shi kong zi zhi 胜事空自知 行到水穷处 +sheng shi qie mi man 生事且弥漫 愿为持竿叟 +sheng tang zuo jie xin yu zu 升堂坐阶新雨足 芭蕉叶大支子肥 +sheng tian ru di qiu zhi bian 升天入地求之遍 上穷碧落下黄泉 +sheng xiao bu xiang shi 生小不相识 +sheng ya qi liao cheng you zhao 生涯岂料承优诏 世事空知学醉歌 +sheng yan nan zai 盛筵难再 兰亭已矣 +sheng yi wo suo yu 生亦我所欲 所欲有甚于生者 +sheng you ru yun 胜友如云 千里逢迎 +sheng zai shu jian 声在树间 予曰 +sheng zhang ming fei shang you cun 生长明妃尚有村 一去紫台连朔漠 +sheng zhong ti niao li ren xi 省中啼鸟吏人稀 晨摇玉佩趋金殿 +sheng zhu zhao zhao mu mu qing 圣主朝朝暮暮情 行宫见月伤心色 +sheng zi dang ru sun zhong mou 生子当如孙仲谋 +sheng zuo yi shu sheng 胜作一书生 +shi ba wu yue yan chi cheng 势拔五岳掩赤城 天台四万八千丈 +shi bi mai zhu hui 侍婢卖珠回 牵萝补茅屋 +shi bie san ri 士别三日 即更刮目相待 +shi bu ke yi bu hong yi 士不可以不弘毅 任重而道远 +shi bu yi ge 十步一阁 廊腰缦回 +shi ci chong ji chang 拾此充饥肠 今我何功德 +shi dao zhe gua zhu 失道者寡助 寡助之至 +shi di li bu ru ren he ye 是地利不如人和也 故曰 +shi er fu qi jiao wu li 侍儿扶起娇无力 始是新承恩泽时 +shi er lou zhong jin xiao zhuang 十二楼中尽晓妆 望仙楼上望君王 +shi er lou zhong yue zi ming 十二楼中月自明 +shi er san yi 十二三矣 令作诗 +shi fen san zu ding 势分三足鼎 业复五铢钱 +shi fu dong zhi zhu 使负栋之柱 多于南亩之农夫 +shi fu tie yi ru xue se 试拂铁衣如雪色 聊持宝剑动星文 +shi gu mou bi er bu xing 是故谋闭而不兴 盗窃乱贼而不作 +shi gu suo yu you shen yu sheng zhe 是故所欲有甚于生者 所恶有甚于死者 +shi gua ren zhi guo ye 是寡人之过也 然郑亡 +shi ji xi pan wen yu chuan 石矶西畔问渔船 桃花尽日随流水 +shi ji xiong hao 势极雄豪 杨诚斋诗云 +shi jian song li jie shi wei 时见松枥皆十围 当流赤足踏涧石 +shi jian xing le yi ru ci 世间行乐亦如此 古来万事东流水 +shi jiang bao zhu jing bian chen 誓将报主静边尘 古来青史谁不见 +shi jie wei chen li 世界微尘里 吾宁爱与憎 +shi jin ling ren 是金陵人 客此 +shi jin yi you 是进亦忧 退亦忧 +shi lang guo you zhi 侍郎郭攸之 费祎 +shi lao you suo zhong 使老有所终 壮有所用 +shi lin teng zhi dui zhu rong 石廪腾掷堆祝融 森然魄动下马拜 +shi ling shu le chu fei quan 誓令疏勒出飞泉 不似颍川空使酒 +shi liu guo ge ai qi ren 使六国各爱其人 则足以拒秦 +shi liu jun yuan xing 十六君远行 瞿塘滟预堆 +shi liu nian jiu yue shi wu ri 时六年九月十五日 +shi luan tong nan qu 世乱同南去 时清独北还 +shi ma zhe bu zhi qi neng qian li er shi ye 食马者不知其能千里而食也 是马也 +shi ming chun jian zhong 时鸣春涧中 +shi nan men wai ni zhong xie 市南门外泥中歇 翩翩两骑来是谁 +shi nan nian huang shi ye kong 时难年荒世业空 弟兄羁旅各西东 +shi nei wai yi fa ye 使内外异法也 侍中 +shi nian li luan hou 十年离乱后 长大一相逢 +shi qi suo yu 失其所与 不知 +shi qie duan chang shi 是妾断肠时 春风不相识 +shi qin fu ai liu guo zhi ren 使秦复爱六国之人 则递三世可至万世而为君 +shi qing du bei huan 时清独北还 他乡生白发 +shi qing shen zhi 石青糁之 船头坐三人 +shi qing wu shuai xie 世情恶衰歇 万事随转烛 +shi ren mo zhi xu ye 时人莫之许也 惟博陵崔州平 +shi ren shen ai mu dan 世人甚爱牡丹 予独爱莲之出淤泥而不染 +shi ren ting ci diao zhu yan 使人听此凋朱颜 连峰去天不盈尺 +shi ren yi zhi chu 时人已知处 +shi ren zhi suo e mo shen yu si zhe 使人之所恶莫甚于死者 则凡可以辟患者 +shi ri geng ding yi 是日更定矣 余拏一小舟 +shi san xue de pi pa cheng 十三学得琵琶成 名属教坊第一部 +shi sao xiong nu bu gu shen 誓扫匈奴不顾身 五千貂锦丧胡尘 +shi sheng jiu si dao guan suo 十生九死到官所 幽居默默如藏逃 +shi shi bo shang zhou 世事波上舟 沿洄安得住 +shi shi cuo tuo cheng bai shou 世事蹉跎成白首 昔时飞箭无全目 +shi shi kong zhi xue zui ge 世事空知学醉歌 江上月明胡雁过 +shi shi mang mang nan zi liao 世事茫茫难自料 春愁黯黯独成眠 +shi shi wu fu xian 时时误拂弦 +shi shi xin cheng en ze shi 始是新承恩泽时 云鬓花颜金步摇 +shi shi yu jie zhi xian du 实是欲界之仙都 自康乐以来 +shi si wei jun fu 十四为君妇 羞颜未尝开 +shi suo wang yu qun gong 是所望于群公 敢竭鄙怀 +shi tian shi bu ru di li ye 是天时不如地利也 城非不高也 +shi tian xia zhi ren 使天下之人 不敢言而敢怒 +shi wei da tong 是谓大同 +shi wei jiu yue 时维九月 序属三秋 +shi wei tian di zhi yi qi 是谓天地之义气 常以肃杀而为心 +shi wen kou chai jing 始闻叩柴荆 父老四五人 +shi wo bu de kai xin yan 使我不得开心颜 +shi wo san jun lei ru yu 使我三军泪如雨 +shi wu cong jun zheng 十五从军征 八十始得归 +shi wu da xiao 事无大小 悉以咨之 +shi wu fu xin 始吾弗信 今吾睹子之难穷也 +shi wu shi zhan mei 十五始展眉 愿同尘与灰 +shi wu wei shan shi 始悟为山市 未几 +shi xian zhu tun xin ye 时先主屯新野 徐庶见先主 +shi xiang lai zhi yan xia 失向来之烟霞 世间行乐亦如此 +shi xun xiu jia 十旬休假 胜友如云 +shi ye yue yin ku 是夜越吟苦 千里其如何 +shi yi bu ke yi yi hu 是亦不可以已乎 此之谓失其本心 +shi yi fan yan 始一反焉 河曲智叟笑而止之曰 +shi yi xian di jian ba yi yi bi xia 是以先帝简拔以遗陛下 愚以为宫中之事 +shi yi zhong yi ju chong wei du 是以众议举宠为督 愚以为营中之事 +shi ying xu zhi you shu 识盈虚之有数 望长安于日下 +shi yong yu xi ri 试用于昔日 先帝称之曰 +shi you bo le 世有伯乐 然后有千里马 +shi yu tai qi 使与台齐 定神细视 +shi yu ye qing qie 适与野情惬 千山高复低 +shi yun bu qi 时运不齐 命途多舛 +shi zao wu zhe zhi wu jin cang ye 是造物者之无尽藏也 而吾与子之所共适 +shi zhe ru si 逝者如斯 而未尝往也 +shi zhi bu neng jin qi cai 食之不能尽其材 鸣之而不能通其意 +shi zhi chong yan er fei ming 使之冲烟而飞鸣 作青云白鹤观 +shi zhi jiao tian zhi wai wei shi wu chun 始知郊田之外未始无春 而城居者未之知也 +shi zhi yi zhi 始指异之 遂命仆人过湘江 +shi zhong geng wu ren 室中更无人 惟有乳下孙 +shi zhong yao wang kong yun shan 世中遥望空云山 不疑灵境难闻见 +shou ba wen shu kou cheng chi 手把文书口称敕 回车叱牛牵向北 +shou chi bei jiao dao wo zhi 手持杯珓导我掷 云此最吉余难同 +shou chi shi fu da cai qi 手持十幅大彩旗 争先鼓勇 +shou di yu xian wang 受地于先王 愿终守之 +shou gao ting zhao zuo chuan zhong 收篙停棹坐船中 怪生无雨都张伞 +shou ke zhai xing chen 手可摘星辰 不敢高声语 +shou ming yi lai 受命以来 夙夜忧叹 +shou ren yu bai jun zhi ji 受任于败军之际 奉命于危难之间 +shou xiang cheng wai yue ru shuang 受降城外月如霜 不知何处吹芦管 +shou zhe chuang er 守着窗儿 独自怎生得黑 +shou zhi bu ke qu shen 手指不可屈伸 弗之怠 +shou zhi tian ye 受之天也 其受之天也 +shou zhong ge you xie 手中各有携 倾榼浊复清 +shou zi bi lu 手自笔录 计日以还 +shou zu wei yi 收族为意 传一乡秀才观之 +shu bei cui cheng mo wei nong 书被催成墨未浓 蜡照半笼金翡翠 +shu cong sha cao qun ou san 数丛沙草群鸥散 万顷江田一鹭飞 +shu dao zhi nan 蜀道之难 难于上青天 +shu di wu ren geng 黍地无人耕 兵戈既未息 +shu er huang yan si qi 倏尔黄烟四起 人物略不相睹 +shu feng liu ren wu 数风流人物 还看今朝 +shu gan pou dan xiao ying cai 输肝剖胆效英才 昭王白骨萦烂草 +shu gu duan ren xing 戍鼓断人行 边秋一雁声 +shu guo guo ju yan 属国过居延 征蓬出汉塞 +shu hu ru quan ru dou 倏忽如拳如豆 遂不可见 +shu jiang shui bi shu shan qing 蜀江水碧蜀山青 圣主朝朝暮暮情 +shu jie nu dun 庶竭驽钝 攘除奸凶 +shu ke wang bian se 戍客望边色 思归多苦颜 +shu lai qi jian 输来其间 鼎铛玉石 +shu li ru yun feng 数里入云峰 古木无人径 +shu li yi zu bao wo ji 疏粝亦足饱我饥 夜深静卧百虫绝 +shu lin yin yi 树林阴翳 鸣声上下 +shu lou xi wang yan chen hei 戍楼西望烟尘黑 汉兵屯在轮台北 +shu miao bai zhong quan 树杪百重泉 汉女输橦布 +shu mu cong sheng 树木丛生 百草丰茂 +shu mu you wei ren ai xi 树木犹为人爱惜 云来气接巫峡长 +shu qin yu zou yuan yang xian 蜀琴欲奏鸳鸯弦 此曲有意无人传 +shu se sui shan jiong 树色随山迥 河声入海遥 +shu seng bao lv qi 蜀僧抱绿绮 西下峨眉峰 +shu sheng yi qi 书生意气 挥斥方遒 +shu shu jie qiu se 树树皆秋色 山山唯落晖 +shu song ying luo kong tan jing 疏松影落空坛静 细草香闲小洞幽 +shu tiao jiao ying 疏条交映 有时见日 +shu tou hua luo wei cheng yin 树头花落未成阴 儿童急走追黄蝶 +shu wen ye ru he 数问夜如何 +shu xiao huan ye 梳晓鬟也 渭流涨腻 +shu yin qi yi 属引凄异 空谷传响 +shu yin zhao shui ai qing rou 树阴照水爱晴柔 小荷才露尖尖角 +shu you ru ci 树犹如此 倩何人唤取 +shu yu guo zhong tiao 疏雨过中条 树色随山迥 +shu yu zuo wen yi ji zhi 属予作文以记之 予观夫巴陵胜状 +shu yun wang hui hui 孰云网恢恢 将老身反累 +shu yun wu dao fei 孰云吾道非 江淮度寒食 +shu zhi ba ceng 数至八层 裁如星点 +shuang fei xi yuan cao 双飞西园草 感此伤妾心 +shuang lai fa er qing feng sheng 爽籁发而清风生 纤歌凝而白云遏 +shuang lei luo jun qian 双泪落君前 +shuang luo xiong sheng shu 霜落熊升树 林空鹿饮溪 +shuang pi liu yu si shi wei 霜皮溜雨四十围 黛色参天二千尺 +shuang qi wan shu feng ru yi 霜凄万树风入衣 铜炉华烛烛增辉 +shuang tu bang di zou 双兔傍地走 安能辨我是雄雌 +shuang xiu long zhong lei bu gan 双袖龙钟泪不干 马上相逢无纸笔 +shuang ye hong yu er yue hua 霜叶红于二月花 +shuang zhao lei hen gan 双照泪痕干 +shuang zhong gu han sheng bu qi 霜重鼓寒声不起 报君黄金台上意 +shui ai feng liu gao ge diao 谁爱风流高格调 共怜时世俭梳妆 +shui bao hong zhen 水爆轰震 声如崩山 +shui bei shi lu zhi ren 谁悲失路之人 萍水相逢 +shui bo bu xing 水波不兴 举酒属客 +shui bu zai shen 水不在深 有龙则灵 +shui cun shan guo jiu qi feng 水村山郭酒旗风 南朝四百八十寺 +shui dan dan xi sheng yan 水澹澹兮生烟 列缺霹雳 +shui dao ren sheng wu zai shao 谁道人生无再少 门前流水尚能西 +shui de er zu mie ye 谁得而族灭也 秦人不暇自哀 +shui di tong long zhou lou chang 水滴铜龙昼漏长 云髻罢梳还对镜 +shui guang jie tian 水光接天 纵一苇之所如 +shui guang lian yan qing fang hao 水光潋滟晴方好 山色空蒙雨亦奇 +shui han feng si dao 水寒风似刀 平沙日未没 +shui he dan dan 水何澹澹 山岛竦峙 +shui jia xin yan zhuo chun ni 谁家新燕啄春泥 乱花渐欲迷人眼 +shui jia yu di an fei sheng 谁家玉笛暗飞声 散入春风满洛城 +shui jie cheng zhou xun fan li 谁解乘舟寻范蠡 五湖烟水独忘机 +shui jie piao bi 水皆缥碧 千丈见底 +shui jing lian juan jin qiu he 水晶帘卷近秋河 +shui jing zhi pan xing su lin 水精之盘行素鳞 犀箸厌饫久未下 +shui lu cao mu zhi hua 水陆草木之花 可爱者甚蕃 +shui luo er shi chu zhe 水落而石出者 山间之四时也 +shui luo shi chu 水落石出 左刻 +shui mian chu ping yun jiao di 水面初平云脚低 几处早莺争暖树 +shui mian qing yuan 水面清圆 一一风荷举 +shui neng jiang qi gu 谁能将旗鼓 一为取龙城 +shui ren geng sao huang jin tai 谁人更扫黄金台 行路难 +shui shen bo lang kuo 水深波浪阔 无使蛟龙得 +shui sheng ji ji feng chui yi 水声激激风吹衣 人生如此自可乐 +shui shi yan bo heng 水是眼波横 山是眉峰聚 +shui sui tian qu qiu wu ji 水随天去秋无际 遥岑远目 +shui yan cun cao xin 谁言寸草心 报得三春晖 +shui yan tian di kuan 谁言天地宽 已知泉路近 +shui you qing lie 水尤清冽 全石以为底 +shui yue tong chan ji 水月通禅寂 鱼龙听梵声 +shui zhi lin qi zhe 谁知林栖者 闻风坐相悦 +shui zhi pan zhong can 谁知盘中餐 粒粒皆辛苦 +shui zhong zao xing jiao heng 水中藻荇交横 盖竹柏影也 +shui zhu chen fu 谁主沉浮 携来百侣曾游 +shun fa yu quan mu zhi zhong 舜发于畎亩之中 傅说举于版筑之间 +shun liu er dong xing 顺流而东行 至于北海 +shun liu er dong ye 顺流而东也 舳舻千里 +shuo fang jian er hao shen shou 朔方健儿好身手 昔何勇锐今何愚 +shuo jin xin zhong wu xian shi 说尽心中无限事 轻拢慢捻抹复挑 +shuo qi chuan jin tuo 朔气传金柝 寒光照铁衣 +si bian fa gu xue hai yong 四边伐鼓雪海涌 三军大呼阴山动 +si bie yi tun sheng 死别已吞声 生别常恻恻 +si ceng xiang shi yan gui lai 似曾相识燕归来 小园香径独徘徊 +si er bu xue ze dai 思而不学则殆 +si er hou yi 死而后已 不亦远乎 +si fang huan zhen song dang zhong 四方环镇嵩当中 火维地荒足妖怪 +si gui duo ku yan 思归多苦颜 高楼当此夜 +si guo zhi an zhe 思国之安者 必积其德义 +si hai wu xian tian 四海无闲田 农夫犹饿死 +si huang ji sheng deng kui gao 嗣皇继圣登夔皋 赦书一日行万里 +si jiao qiu ye jing she she 四郊秋叶惊摵摵 董夫子 +si jie cong lai qi gu xun 死节从来岂顾勋 君不见沙场征战苦 +si jun bu jian xia yu zhou 思君不见下渝州 +si ma chu men si jiu xiang 嘶马出门思旧乡 陈侯立身何坦荡 +si mian bian sheng lian jiao qi 四面边声连角起 千嶂里 +si mian ge can zhong po chu 四面歌残终破楚 八年风味徒思浙 +si mian zhu shu huan he 四面竹树环合 寂寥无人 +si ni zuo qun he wu yu kong zhong 私拟作群鹤舞于空中 心之所向 +si qi xin yue 俟其欣悦 则又请焉 +si qu yuan zhi wan shi kong 死去元知万事空 但悲不见九州同 +si ren bu ke wen 斯人不可闻 明朝挂帆席 +si ren du qiao cui 斯人独憔悴 孰云网恢恢 +si shan sheng zuo hai tao fan 四山声作海涛翻 溪柴火软蛮毡暖 +si sheng yi da yi 死生亦大矣 岂不痛哉 +si sheng zhang bie li 死生长别离 无人收废帐 +si shi ju bei 四时俱备 晓雾将歇 +si shi lou shi 斯是陋室 惟吾德馨 +si shi san nian 四十三年 望中犹记 +si shi zhi jing bu tong 四时之景不同 而乐亦无穷也 +si su ping sheng bu de zhi 似诉平生不得志 低眉信手续续弹 +si suan chu yan shi qun ye 嘶酸雏雁失群夜 断绝胡儿恋母声 +si wang ru yi 四望如一 然后知是山之特立 +si wen zuo zhe chi song zi 似闻昨者赤松子 恐是汉代韩张良 +si wu ren sheng 四无人声 声在树间 +si xian ru ke 思贤如渴 若跨有荆 +si xian yi sheng ru lie bo 四弦一声如裂帛 东船西舫悄无言 +si yi fa gen yi qiu mu mao 斯亦伐根以求木茂 塞源而欲流长也 +si yi wei gui xiong 死亦为鬼雄 至今思项羽 +si yi wo suo e 死亦我所恶 所恶有甚于死者 +si yu you zhe xiang le 似与游者相乐 潭西南而望 +si yue nan feng da mai huang 四月南风大麦黄 枣花未落桐叶长 +si yun ju cheng 四韵俱成 请洒潘江 +si zhe chang yi yi 死者长已矣 室中更无人 +si zhi jiang jin bu neng dong 四支僵劲不能动 媵人持汤沃灌 +si zuo lei zong heng 四座泪纵横 +si zuo wu yan xing yu xi 四座无言星欲稀 清淮奉使千馀里 +song bai yi jing qu ling gong 松柏一径趋灵宫 粉墙丹柱动光彩 +song bai you ben xing 松柏有本性 +song bai zhong lei lei 松柏冢累累 兔从狗窦入 +song er huan gu xiang 送儿还故乡 爷娘闻女来 +song he zui zhi you 宋何罪之有 荆国有余于地 +song ji lu wei yue 松际露微月 清光犹为君 +song jian sha lu jing wu ni 松间沙路净无泥 潇潇暮雨子规啼 +song ming yue zhi shi 诵明月之诗 歌窈窕之章 +song mo neng shou 宋莫能守 乃可攻也 +song ran er ting zhi 悚然而听之 曰 +song suo wei wu zhi tu fu yu zhe ye 宋所谓无雉兔鲋鱼者也 此犹粱肉之与糠糟也 +song wo zhi shan xi 送我至剡溪 谢公宿处今尚在 +song wu zhang mu 宋无长木 此犹锦绣之与短褐也 +song wu zui er gong zhi 宋无罪而攻之 不可谓仁 +song xia wen tong zi 松下问童子 言师采药去 +song you fu ren 宋有富人 天雨墙坏 +song yue sheng ye liang 松月生夜凉 风泉满清听 +song zhi di fang wu bai li 宋之地方五百里 此犹文轩之与敝舆也 +song zhi yi he jing 松枝一何劲 冰霜正惨凄 +sou yu guo zhong san ri san ye 搜于国中三日三夜 庄子往见之 +su hui cong zhi 溯洄从之 道阻且长 +su shou chou zhen leng 素手抽针冷 那堪把剪刀 +su shou qing tiao shang 素手青条上 红妆白日鲜 +su shou yu fang qian 素手玉房前 欲得周郎顾 +su su yi jin luo zao hua 簌簌衣巾落枣花 村南村北响缲车 +su sui bai meng mu 肃遂拜蒙母 结友而别 +su wu hun xiao han shi qian 苏武魂销汉使前 古祠高树两茫然 +su ye you tan 夙夜忧叹 恐托付不效 +su ying er shang 溯迎而上 出没于鲸波万仞中 +su you cong zhi 溯游从之 宛在水中央 +su zi qiao ran 苏子愀然 正襟危坐 +su zi xiong jin shui shi wo 俗子胸襟谁识我 英雄末路当磨折 +su zi yu ke fan zhou you yu chi bi zhi xia 苏子与客泛舟游于赤壁之下 清风徐来 +suan ping sheng gan dan 算平生肝胆 因人常热 +sui bo er shi 随波而逝 吴儿善泅者数百 +sui bu ke jian 遂不可见 又闻有早行者 +sui cheng ben yu feng 虽乘奔御风 不以疾也 +sui dong zhi yi yan xing 虽董之以严刑 震之以威怒 +sui feng man di shi luan zou 随风满地石乱走 匈奴草黄马正肥 +sui feng qian ru ye 随风潜入夜 润物细无声 +sui feng zhi dao ye lang xi 随风直到夜郎西 +sui jiao fang shi yin qin mi 遂教方士殷勤觅 排空驭气奔如电 +sui ling dong shan ke 遂令东山客 不得顾采薇 +sui ling tian xia fu mu xin 遂令天下父母心 不重生男重生女 +sui lv zi sun he dan zhe san fu 遂率子孙荷担者三夫 叩石垦壤 +sui ming pu ren guo xiang jiang 遂命仆人过湘江 缘染溪 +sui mu yin yang cui duan jing 岁暮阴阳催短景 天涯霜雪霁寒宵 +sui qian li bu gan yi ye 虽千里不敢易也 岂直五百里哉 +sui qu she wan shu 虽趣舍万殊 静躁不同 +sui shan dao shui yuan 随山到水源 溪花与禅意 +sui shi shu shi yi 虽世殊事异 所以兴怀 +sui sui jin he fu yu guan 岁岁金河复玉关 朝朝马策与刀环 +sui wo zhi si 虽我之死 有子存焉 +sui wu si zhu guan xian zhi sheng 虽无丝竹管弦之盛 一觞一咏 +sui xi di bu rong jian ye 虽席地不容间也 +sui xu xian di yi qu chi 遂许先帝以驱驰 后值倾覆 +sui xu zhen ci neng ju gong 睢盱侦伺能鞠躬 手持杯珓导我掷 +sui yan you yu liang 岁晏有余粮 念此私自愧 +sui yi chun fang xie 随意春芳歇 王孙自可留 +sui yi hao er mo qu 虽一毫而莫取 惟江上之清风 +sui yong chang jue 遂用猖蹶 至于今日 +sui you jue ding shui neng qiong 虽有绝顶谁能穷 我来正逢秋雨节 +sui you qian li zhi neng 虽有千里之能 食不饱 +sui yu wai ren jian ge 遂与外人间隔 问今是何世 +sui yuan lv zhu 睢园绿竹 气凌彭泽之樽 +sui zai gui chou 岁在癸丑 暮春之初 +sui zhi cheng tian si xun zhang huai min 遂至承天寺寻张怀民 怀民亦未寝 +sun gong zi yu nian yu tong ren yin lou shang 孙公子禹年与同人饮楼上 忽见山头有孤塔耸起 +sun quan ju you jiang dong 孙权据有江东 已历三世 +sun shu ao ju yu hai 孙叔敖举于海 百里奚举于市 +sun you sheng zi 孙又生子 子又有子 +sun zhong mou chu 孙仲谋处 舞榭歌台 +suo de shi zhan yi 所得是沾衣 +suo e you shen yu si zhe 所恶有甚于死者 故患有所不辟也 +suo lai jun zi jian ji 所赖君子见机 达人知命 +suo shi qiong fa zhe de wo yu 所识穷乏者得我与 乡为身死而不受 +suo shou huo fei qin 所守或匪亲 化为狼与豺 +suo wei yi ren 所谓伊人 在水一方 +suo xian jin shou lian huan leng 锁衔金兽连环冷 水滴铜龙昼漏长 +suo yi dong xin ren xing 所以动心忍性 曾益其所不能 +suo yi shen shen 所宜深慎 奔车朽索 +suo yi xing huai 所以兴怀 其致一也 +suo yi you mu cheng huai 所以游目骋怀 足以极视听之娱 +suo yu you shen yu sheng zhe 所欲有甚于生者 故不为苟得也 +ta de fei wo xian 他得非我贤 青春须早为 +ta nian jin li jing ci miao 他年锦里经祠庙 梁父吟成恨有余 +ta ri qu ting 他日趋庭 叨陪鲤对 +ta xiang fu xing yi 他乡复行役 驻马别孤坟 +ta xiang sheng bai fa 他乡生白发 旧国见青山 +ta zai cong zhong xiao 她在丛中笑 +tai hen shang jie lv 苔痕上阶绿 草色入帘青 +tai huang zhen yi xia zhi jiao 台隍枕夷夏之交 宾主尽东南之美 +tai nong yi yuan shu qie zhen 态浓意远淑且真 肌理细腻骨肉匀 +tai qiu she qu 太丘舍去 去后乃至 +tai shen bu neng sao 苔深不能扫 落叶秋风早 +tai shou gui er bin ke cong ye 太守归而宾客从也 树林阴翳 +tai shou ji qian ren sui qi wang 太守即遣人随其往 寻向所志 +tai shou wei shui 太守谓谁 庐陵欧阳修也 +tai shou yan ye 太守宴也 宴酣之乐 +tai shou yu ke lai yin yu ci 太守与客来饮于此 饮少辄醉 +tai shou zi wei ye 太守自谓也 太守与客来饮于此 +tai shou zui ye 太守醉也 已而夕阳在山 +tai ye fu rong wei yang liu 太液芙蓉未央柳 芙蓉如面柳如眉 +tai yi jin tian du 太乙近天都 连山接海隅 +tan feng jie shen fou 叹凤嗟身否 伤麟怨道穷 +tan jian zuo ge zou ku sheng 弹剑作歌奏苦声 曳裾王门不称情 +tan mian wu feng jing wei mo 潭面无风镜未磨 遥望洞庭山水色 +tan qin fu chang xiao 弹琴复长啸 深林人不知 +tan xi nan er wang 潭西南而望 斗折蛇行 +tan xi wei ying xian 叹息未应闲 +tan xiao wu huan qi 谈笑无还期 +tan xiao you hong ru 谈笑有鸿儒 往来无白丁 +tan xiong lu ru 袒胸露乳 矫首昂视 +tan yan fei rong rong 潭烟飞溶溶 林月低向后 +tan ying kong ren xin 潭影空人心 万籁此都寂 +tan zhong yu ke bai xu tou 潭中鱼可百许头 皆若空游无所依 +tang ju dui yue 唐雎对曰 否 +tang zong song zu 唐宗宋祖 稍逊风骚 +tao hou xian you wen 陶后鲜有闻 莲之爱 +tao hua jin ri sui liu shui 桃花尽日随流水 洞在清溪何处边 +tao hua liu shui gui yu fei 桃花流水鳜鱼肥 青箬笠 +tao hua tan shui shen qian chi 桃花潭水深千尺 不及汪伦送我情 +tao hua yi cu kai wu zhu 桃花一簇开无主 可爱深红爱浅红 +tao li yin yin liu xu fei 桃李阴阴柳絮飞 禁里疏钟官舍晚 +tao ran gong wang ji 陶然共忘机 +tao ran gong zui ju hua bei 陶然共醉菊花杯 +tao yuan wang duan wu xun chu 桃源望断无寻处 可堪孤馆闭春寒 +teng jiao qi feng 腾蛟起凤 孟学士之词宗 +teng she cheng wu 腾蛇乘雾 终为土灰 +teng shen bai bian 腾身百变 而旗尾略不沾湿 +teng wang gao ge lin jiang zhu 滕王高阁临江渚 佩玉鸣鸾罢歌舞 +teng zi jing zhe shou ba ling jun 滕子京谪守巴陵郡 越明年 +ti kai hong yan jiu fei e 剔开红焰救飞蛾 +ti ming bing zhuan wen 题名并篆文 为字共三十有四 +ti ren chui lei dao tian ming 替人垂泪到天明 +ti shi jing qie meng 啼时惊妾梦 不得到辽西 +ti xie yu long wei jun si 提携玉龙为君死 +tian chang di jiu you shi jin 天长地久有时尽 此恨绵绵无绝期 +tian chang lu yuan hun fei ku 天长路远魂飞苦 梦魂不到关山难 +tian de huang li si wu sheng 添得黄鹂四五声 +tian di yi sha ou 天地一沙鸥 +tian di ying xiong qi 天地英雄气 千秋尚凛然 +tian fu zhi tu 天府之土 高祖因之以成帝业 +tian gao di jiong 天高地迥 觉宇宙之无穷 +tian gao ri jing 天高日晶 其气栗冽 +tian guang yun ying gong pai huai 天光云影共徘徊 问渠那得清如许 +tian han cui xiu bo 天寒翠袖薄 日暮倚修竹 +tian jia shao xian yue 田家少闲月 五月人倍忙 +tian jia shen bing zhuan qi xiong 天假神柄专其雄 喷云泄雾藏半腹 +tian jie xiao yu run ru su 天街小雨润如酥 草色遥看近却无 +tian jie ye se liang ru shui 天阶夜色凉如水 卧看牵牛织女星 +tian jie yun tao lian xiao wu 天接云涛连晓雾 星河欲转千帆舞 +tian jiu hui deng chong kai yan 添酒回灯重开宴 千呼万唤始出来 +tian lang qi qing 天朗气清 惠风和畅 +tian lao lian tian xiang tian heng 天姥连天向天横 势拔五岳掩赤城 +tian lu you xian nan zhui pan 天路幽险难追攀 君歌且休听我歌 +tian men zhong duan chu jiang kai 天门中断楚江开 碧水东流至此回 +tian ming deng qian tu 天明登前途 独与老翁别 +tian ming du qu wu dao lu 天明独去无道路 出入高下穷烟霏 +tian pan du shan ran 天畔独潸然 老至居人下 +tian qi ren xu qiu ri 天启壬戌秋日 虞山王毅叔远甫刻 +tian qi wan lai qiu 天气晚来秋 明月松间照 +tian shan gong se 天山共色 从流飘荡 +tian shang ren jian hui xiang jian 天上人间会相见 临别殷勤重寄词 +tian sheng li zhi nan zi qi 天生丽质难自弃 一朝选在君王侧 +tian sheng wo cai bi you yong 天生我材必有用 千金散尽还复来 +tian shi bu ru di li 天时不如地利 地利不如人和 +tian shi qiong lu 天似穹庐 笼盖四野 +tian tai si wan ba qian zhang 天台四万八千丈 对此欲倒东南倾 +tian wai san feng xue bu cheng 天外三峰削不成 武帝祠前云欲散 +tian xia du jue 天下独绝 水皆缥碧 +tian xia gao su 天下缟素 今日是也 +tian xia shui ren bu shi jun 天下谁人不识君 +tian xia shun zhi 天下顺之 以天下之所顺 +tian xia wei gong 天下为公 选贤与能 +tian xia wu ma 天下无马 呜呼 +tian xia ying xiong shui di shou 天下英雄谁敌手 曹刘 +tian xia you bian 天下有变 则命一上将将荆州之军以向宛 +tian xia zhi wei guan ye 天下之伟观也 自既望以至十八日为盛 +tian xuan di zhuan hui long yu 天旋地转回龙驭 到此踌躇不能去 +tian ya gong ci shi 天涯共此时 情人怨遥夜 +tian ya gu zhao hai 天涯孤棹还 何当重相见 +tian ya ku ci shi 天涯哭此时 +tian ya ruo bi lin 天涯若比邻 无为在歧路 +tian ya shuang xue ji han xiao 天涯霜雪霁寒宵 五更鼓角声悲壮 +tian ya ti lei yi shen yao 天涯涕泪一身遥 唯将迟暮供多病 +tian ya zhan meng shu 天涯占梦数 疑误有新知 +tian yin yu shi sheng jiu jiu 天阴雨湿声啾啾 +tian yu qiang huai 天雨墙坏 其子曰 +tian yu yun yu shan yu shui 天与云与山与水 上下一白 +tian yuan liao luo gan ge hou 田园寥落干戈后 骨肉流离道路中 +tian zhi yu wu 天之于物 春生秋实 +tian zhu gao er bei chen yuan 天柱高而北辰远 关山难越 +tian zi fei chang ci yan se 天子非常赐颜色 摐金伐鼓下榆关 +tian zi zhi nu 天子之怒 伏尸百万 +tian zi zuo ming tang 天子坐明堂 策勋十二转 +tiao di song gao xia 迢递嵩高下 归来且闭关 +tiao di song xie hui 迢递送斜晖 肠断未忍扫 +tiao wang zhu zhi 跳往助之 寒暑易节 +tiao yao tai hua fu xian jing 岧峣太华俯咸京 天外三峰削不成 +tie ma bing he ru meng lai 铁马冰河入梦来 +tie qi rao long cheng 铁骑绕龙城 雪暗凋旗画 +tie qi tu chu dao qiang ming 铁骑突出刀枪鸣 曲终收拨当心画 +tie yi yuan shu xin qin jiu 铁衣远戍辛勤久 玉箸应啼别离后 +ting bei tou zhu bu neng shi 停杯投箸不能食 拔剑四顾心茫然 +ting che zuo ai feng lin wan 停车坐爱枫林晚 霜叶红于二月花 +ting chuan zan jie wen 停船暂借问 或恐是同乡 +ting fu qian zhi ci 听妇前致词 三男邺城戍 +ting jian er qi 挺剑而起 秦王色挠 +ting qi xiang gu yan 听其相顾言 闻者为悲伤 +ting qu wa sheng yi pian 听取蛙声一片 七八个星天外 +ting tai liu qi zuo 亭台六七座 八九十枝花 +ting ting jing zhi 亭亭净植 可远观而不可亵玩焉 +ting ting shan shang song 亭亭山上松 瑟瑟谷中风 +ting xia ru ji shui kong ming 庭下如积水空明 水中藻荇交横 +ting zhou wu lang fu wu yan 汀洲无浪复无烟 楚客相思益渺然 +tong dao qian niu zhi nv jia 同到牵牛织女家 +tong ji yi zhou 通计一舟 为人五 +tong ju chang gan li 同居长干里 两小无嫌猜 +tong lu hua zhu zhu zeng hui 铜炉华烛烛增辉 初弹渌水后楚妃 +tong que chun shen suo er qiao 铜雀春深锁二乔 +tong she sheng jie bei qi xiu 同舍生皆被绮绣 戴朱缨宝饰之帽 +tong shi bei liu duo shang dao 同时辈流多上道 天路幽险难追攀 +tong shi chang gan ren 同是长干人 生小不相识 +tong shi huan you ren 同是宦游人 海内存知己 +tong shi tian ya lun luo ren 同是天涯沦落人 相逢何必曾相识 +tong sun wei jie gong geng zhi 童孙未解供耕织 也傍桑阴学种瓜 +tong xin yu wo wei 同心与我违 行当浮桂棹 +tong xing shi er nian 同行十二年 不知木兰是女郎 +tong yu zhe he ren 同予者何人 牡丹之爱 +tong zhi kai jing fei 童稚开荆扉 绿竹入幽径 +tong zhi xie hu jiang 童稚携壶浆 相随饷田去 +tong zi he zhi 童子何知 躬逢胜饯 +tong zi mo dui 童子莫对 垂头而睡 +tou cai bai lian hui 偷采白莲回 不解藏踪迹 +tou shang he suo you 头上何所有 翠微盍叶垂鬓唇 +tou shang hong guan bu yong cai 头上红冠不用裁 满身雪白走将来 +tou shi zeng mi luo 投诗赠汨罗 +tou zhu bo hai zhi wei 投诸渤海之尾 隐土之北 +tu cong gou dou ru 兔从狗窦入 雉从架上飞 +tu di ping kuang 土地平旷 屋舍俨然 +tu gao wei run 土膏微润 一望空阔 +tu lao hen fei sheng 徒劳恨费声 五更疏欲断 +tu ling shang jiang hui shen bi 徒令上将挥神笔 终见降王走传车 +tu yan shu tao li 徒言树桃李 此木岂无阴 +tu yi you xian sheng ye 徒以有先生也 +tu you xian yu qing 徒有羡鱼情 +tui ran hu qi jian zhe 颓然乎其间者 太守醉也 +tui ran jiu zui 颓然就醉 不知日之入 +tun tian wo ri 吞天沃日 势极雄豪 +tuo wo zhan shi pao 脱我战时袍 著我旧时裳 +tuo yi xiang yu bei feng 托遗响于悲风 苏子曰 +wa feng cen ci 瓦缝参差 多于周身之帛缕 +wai jie hao sun quan 外结好孙权 内修政理 +wai yu tian ji 外与天际 四望如一 +wan fang duo nan ci deng lin 万方多难此登临 锦江春色来天地 +wan feng chui xing zhou 晚风吹行舟 花路入溪口 +wan fu mo kai 万夫莫开 所守或匪亲 +wan gu wei liu chu ke bei 万古惟留楚客悲 秋草独寻人去后 +wan gu yun xiao yi yu mao 万古云霄一羽毛 伯仲之间见伊吕 +wan guo yi guan bai mian liu 万国衣冠拜冕旒 日色才临仙掌动 +wan he shu can tian 万壑树参天 千山响杜鹃 +wan hu dao yi sheng 万户捣衣声 秋风吹不尽 +wan jia nan shan chui 晚家南山陲 兴来每独往 +wan jian yan hang pin 晚见雁行频 落叶他乡树 +wan jing ren zong mie 万径人踪灭 孤舟蓑笠翁 +wan lai ci dou ji 万籁此都寂 但余钟磬音 +wan lai feng ji 晚来风急 雁过也 +wan lai tian yu xue 晚来天欲雪 能饮一杯无 +wan lei shuang tian jing zi you 万类霜天竞自由 怅寥廓 +wan li bei qiu chang zuo ke 万里悲秋常作客 百年多病独登台 +wan li chang zheng ren wei huan 万里长征人未还 但使龙城飞将在 +wan li fu rong ji 万里赴戎机 关山度若飞 +wan li fu yun yin qie qing 万里浮云阴且晴 嘶酸雏雁失群夜 +wan li gui chuan nong chang di 万里归船弄长笛 此心吾与白鸥盟 +wan li gui xin dui yue ming 万里归心对月明 旧业已随征战尽 +wan li huang he rao hei shan 万里黄河绕黑山 +wan li lai you hai wang yuan 万里来游还望远 三年多难更凭危 +wan li song xing zhou 万里送行舟 +wan li xue piao 万里雪飘 望长城内外 +wan li yan zhong ming 万里眼中明 +wan li yun luo yi yan fei 万里云罗一雁飞 +wan ma qi yin jiu ke ai 万马齐喑究可哀 我劝天公重抖擞 +wan niu hui shou qiu shan zhong 万牛回首丘山重 不露文章世已惊 +wan qing jiang tian yi lu fei 万顷江田一鹭飞 谁解乘舟寻范蠡 +wan shi lao qi xing 万事劳其形 有动于中 +wan shi sui zhuan zhu 万事随转烛 夫婿轻薄儿 +wan shi xu ji yun 万事须己运 他得非我贤 +wan tiao chui xia lv si tao 万条垂下绿丝绦 不知细叶谁裁出 +wan wu sheng guang hui 万物生光辉 常恐秋节至 +wan zai shui zhong di 宛在水中坻 蒹葭采采 +wan zai shui zhong yang 宛在水中央 蒹葭萋萋 +wan zai shui zhong zhi 宛在水中沚 +wan zhong yu wo he jia yan 万钟于我何加焉 为宫室之美 +wan zhong ze bu bian li yi er shou zhi 万钟则不辨礼义而受之 万钟于我何加焉 +wan zhuan e mei ma qian si 宛转蛾眉马前死 花钿委地无人收 +wan zi qian hong zong shi chun 万紫千红总是春 +wang bu yin shi xiang xing 罔不因势象形 各具情态 +wang chang an yu ri xia 望长安于日下 目吴会于云间 +wang chang cheng nei wai 望长城内外 惟馀莽莽 +wang di chun xin tuo du juan 望帝春心托杜鹃 沧海月明珠有泪 +wang feng xi xin 望峰息心 经纶世务者 +wang hu lou xia shui ru tian 望湖楼下水如天 +wang huai de shi 忘怀得失 以此自终 +wang jiang jun zhi wu ku 王将军之武库 家君作宰 +wang jin tian ya lu 望尽天涯路 欲寄彩笺兼尺素 +wang lai er bu jue zhe 往来而不绝者 滁人游也 +wang lai wu bai ding 往来无白丁 可以调素琴 +wang lai xi hu 往来翕忽 似与游者相乐 +wang lu zhi yuan jin 忘路之远近 忽逢桃花林 +wang mei ren xi tian yi fang 望美人兮天一方 客有吹洞箫者 +wang shi bei ding zhong yuan ri 王师北定中原日 家祭无忘告乃翁 +wang shi zhi duo shao 往事知多少 小楼昨夜又东风 +wang sun gui bu gui 王孙归不归 +wang sun shan bao qian jin qu 王孙善保千金躯 不敢长语临交衢 +wang sun zi ke liu 王孙自可留 +wang wang qu jiu hai du qing 往往取酒还独倾 岂无山歌与村笛 +wang wu er shan 王屋二山 方七百里 +wang xian lou shang wang jun wang 望仙楼上望君王 锁衔金兽连环冷 +wang yang xiang ruo er tan yue 望洋向若而叹曰 野语有之曰 +wang zhi wei ran er shen xiu zhe 望之蔚然而深秀者 琅琊也 +wang zhong you ji 望中犹记 烽火扬州路 +wang zi huang sun 王子皇孙 辞楼下殿 +wei an gu shi xing 未谙姑食性 先遣小姑尝 +wei bai bu zhe fan 未百步辄返 廿二日天稍和 +wei bao qing cheng sui tai shou 为报倾城随太守 亲射虎 +wei bo ling cui zhou ping 惟博陵崔州平 颍川徐庶元直与亮友善 +wei chang bu lin wen jie dao 未尝不临文嗟悼 不能喻之于怀 +wei chang bu tan xi tong hen yu huan 未尝不叹息痛恨于桓 灵也 +wei chang di yi hen 惟长堤一痕 湖心亭一点 +wei chang shao jiang ci se 未尝稍降辞色 余立侍左右 +wei chang shi shu ju 未尝识书具 忽啼求之 +wei cheng qu diao xian you qing 未成曲调先有情 弦弦掩抑声声思 +wei cheng yang qi xing shi ling 为乘阳气行时令 不是宸游玩物华 +wei cheng zhao yu yi qing chen 渭城朝雨浥轻尘 客舍青青柳色新 +wei ci guan ye 惟此官也 而此地适与余近 +wei ci jian fa shui neng song 未辞翦伐谁能送 苦心岂免容蝼蚁 +wei de bao en bu neng gui 未得报恩不能归 辽东小妇年十五 +wei er qu zhi 委而去之 是地利不如人和也 +wei feng chui lan du 微风吹兰杜 +wei fu ren zhi li bu ji ci 微夫人之力不及此 因人之力而敝之 +wei fu you neng yu qi qi zhe 未复有能与其奇者 +wei gan jun wang zhan zhuan si 为感君王辗转思 遂教方士殷勤觅 +wei gong shi zhi mei 为宫室之美 妻妾之奉 +wei hu gao zai 危乎高哉 蜀道之难 +wei huo suo fen 为火所焚 随波而逝 +wei ji fu jing fei 未几拂荆扉 远树带行客 +wei jian chang jiang tian ji liu 唯见长江天际流 +wei jian jiang xin qiu yue bai 唯见江心秋月白 沉吟放拨插弦中 +wei jian lin hua luo 唯见林花落 莺啼送客闻 +wei jiang chi mu gong duo bing 唯将迟暮供多病 未有涓埃答圣朝 +wei jiang jiu wu biao shen qing 唯将旧物表深情 钿合金钗寄将去 +wei jiang shang zhi qing feng 惟江上之清风 与山间之明月 +wei jie yi chang an 未解忆长安 香雾云鬟湿 +wei jue shi zhi zhen xi 惟觉时之枕席 失向来之烟霞 +wei jue zun qian xiao bu cheng 唯觉樽前笑不成 蜡烛有心还惜别 +wei jun fan zuo pi pa xing 为君翻作琵琶行 感我此言良久立 +wei jun tu zhi 唯君图之 秦伯说 +wei lian yi deng ying 惟怜一灯影 万里眼中明 +wei liu zhang ni 渭流涨腻 弃脂水也 +wei lou gao bai chi 危楼高百尺 手可摘星辰 +wei mian chui chu chen ai jian 未免捶楚尘埃间 同时辈流多上道 +wei nian zhu ge yi 为念珠各一 对联 +wei qiang du ye zhou 危樯独夜舟 星垂平野阔 +wei qin gong ren 为秦宫人 明星荧荧 +wei qing bu bai you tian xing 卫青不败由天幸 李广无功缘数奇 +wei ren mou er bu zhong hu 为人谋而不忠乎 与朋友交而不信乎 +wei ruo liu xu yin feng qi 未若柳絮因风起 公大笑乐 +wei shuang qi qi dian se han 微霜凄凄簟色寒 孤灯不明思欲绝 +wei shui liu xia xiao xiang qu 为谁流下潇湘去 +wei shui xin ku wei shui tian 为谁辛苦为谁甜 +wei shui zi ying qin sai qu 渭水自萦秦塞曲 黄山旧绕汉宫斜 +wei ta ren zuo jia yi shang 为他人作嫁衣裳 +wei tang ju yue 谓唐雎曰 公亦尝闻天子之怒乎 +wei tian xia bu yi bing ge zhi li 威天下不以兵革之利 得道者多助 +wei tu chan yan bu ke pan 畏途巉岩不可攀 但见悲鸟号古木 +wei wei lou yi zuo 惟危楼一座 直接霄汉 +wei wei xin ran 谓为信然 时先主屯新野 +wei wen nv tan xi 惟闻女叹息 问女何所思 +wei wen yuan rong dou che qi 为问元戎窦车骑 何时反旆勒燕然 +wei wo wei gai shen can shang 帷幄未改神惨伤 国家成败吾岂敢 +wei wo yi hui shou 为我一挥手 如听万壑松 +wei wu de xin 惟吾德馨 苔痕上阶绿 +wei wu zhi ling 惟物之灵 百忧感其心 +wei xian zhu yue 谓先主曰 诸葛孔明者 +wei xiu guan xi zu 未休关西卒 县官急索租 +wei yang qian dian yue lun gao 未央前殿月轮高 平阳歌舞新承宠 +wei yang xia chu qiu 微阳下楚丘 猿啼洞庭树 +wei you juan ai da sheng chao 未有涓埃答圣朝 跨马出郊时极目 +wei you qing ting jia die fei 惟有蜻蜓蛱蝶飞 +wei you ru xia sun 惟有乳下孙 有孙母未去 +wei you yin zhe liu qi ming 惟有饮者留其名 陈王昔时宴平乐 +wei you you ren ye lai qu 惟有幽人夜来去 +wei you yuan tou huo shui lai 为有源头活水来 +wei you yun ping wu xian jiao 为有云屏无限娇 凤城寒尽怕春宵 +wei yu ci tong lei 为与此同类 王曰 +wei yu mang mang 惟馀莽莽 大河上下 +wei yun he long 未云何龙 複道行空 +wei zhi jing ying 魏之经营 齐 +wei zhi yi ran cheng kuai 为之怡然称快 余常于土墙凹凸处 +wei zi gong san shi you si 为字共三十有四 而计其长曾不盈寸 +wei zi wang qu 猥自枉屈 三顾臣于草庐之中 +wen cang mang da di 问苍茫大地 谁主沉浮 +wen dao gu lin xiang shi duo 闻道故林相识多 罢官昨日今如何 +wen dao han jia tian zi shi 闻道汉家天子使 九华帐里梦魂惊 +wen dao huang long shu 闻道黄龙戍 频年不解兵 +wen dao long biao guo wu xi 闻道龙标过五溪 我寄愁心与明月 +wen dao yu lai xiang wen xun 闻道欲来相问讯 西楼望月几回圆 +wen feng zuo xiang yue 闻风坐相悦 草木有本心 +wen gu er zhi xin 温故而知新 可以为师矣 +wen jin shi he shi 问今是何世 乃不知有汉 +wen jun he neng er 问君何能尔 心远地自偏 +wen jun neng you ji duo chou 问君能有几多愁 恰似一江春水向东流 +wen jun xi you he shi huan 问君西游何时还 畏途巉岩不可攀 +wen nv he suo si 问女何所思 问女何所忆 +wen nv he suo yi 问女何所忆 女亦无所思 +wen qi xing shi 问其姓氏 是金陵人 +wen qu na de qing ru xu 问渠那得清如许 为有源头活水来 +wen quan shui hua xi ning zhi 温泉水滑洗凝脂 侍儿扶起娇无力 +wen shuo ji ming jian ri sheng 闻说鸡鸣见日升 不畏浮云遮望眼 +wen shuo shuang xi chun shang hao 闻说双溪春尚好 也拟泛轻舟 +wen suo cong lai 问所从来 具答之 +wen weng fan jiao shou 文翁翻教授 不敢倚先贤 +wen wo jiu yuan xing 问我久远行 手中各有携 +wen wu zheng chi 文武争驰 君臣无事 +wen xing jing chu jian 问姓惊初见 称名忆旧容 +wen yan yu shui can 问言与谁餐 +wen ying xiong lei 揾英雄泪 +wen you sheng zi xi nan lai zhe 闻有声自西南来者 悚然而听之 +wen zhang zeng ming da 文章憎命达 魑魅喜人过 +wen zhe wei bei shang 闻者为悲伤 家田输税尽 +wen zhi bu ken dao xing ming 问之不肯道姓名 但道困苦乞为奴 +wo bao lu zhang jie ri mu 我报路长嗟日暮 学诗漫有惊人句 +wo cong qu nian ci di jing 我从去年辞帝京 谪居卧病浔阳城 +wo du bu de chu 我独不得出 羞逐长安社中儿 +wo ge jin yu jun shu ke 我歌今与君殊科 一年明月今宵多 +wo ge yue pai huai 我歌月徘徊 我舞影零乱 +wo ji chou xin yu ming yue 我寄愁心与明月 随风直到夜郎西 +wo jia xian yan chi tou shu 我家冼砚池头树 朵朵花开淡墨痕 +wo jia xiang shui qu 我家襄水曲 遥隔楚云端 +wo kan qian niu zhi nv xing 卧看牵牛织女星 +wo lai zheng feng qiu yu jie 我来正逢秋雨节 阴气晦昧无清风 +wo long yue ma zhong huang tu 卧龙跃马终黄土 人事音书漫寂寥 +wo quan tian gong chong dou sou 我劝天公重抖擞 不拘一格降人才 +wo suo yu ye 我所欲也 熊掌 +wo wen pi pa yi tan xi 我闻琵琶已叹息 又闻此语重唧唧 +wo wu ying ling luan 我舞影零乱 醒时相交欢 +wo xin bei shang 我心悲伤 莫知我哀 +wo xing shu wei yi 我行殊未已 何日复归来 +wo yan qiu ri sheng chun zhao 我言秋日胜春朝 晴空一鹤排云上 +wo ye qian li 沃野千里 天府之土 +wo yi ju jia qing 我亦举家清 +wo yu cheng feng gui qu 我欲乘风归去 又恐琼楼玉宇 +wo yu li nu bu chu men 我与狸奴不出门 +wo yu yin zhi meng wu yue 我欲因之梦吴越 一夜飞度镜湖月 +wo zhi wei ye 我之谓也 且夫我尝闻少仲尼之闻 +wo zui jun fu le 我醉君复乐 陶然共忘机 +wu an du zhi lao xing 无案牍之劳形 南阳诸葛庐 +wu bian guang jing yi shi xin 无边光景一时新 等闲识得东风面 +wu bian luo mu xiao xiao xia 无边落木萧萧下 不尽长江滚滚来 +wu bu neng zao yong zi 吾不能早用子 今急而求子 +wu bu yi lou 五步一楼 十步一阁 +wu cai ri shuai lao 无才日衰老 驻马望千门 +wu chu dong nan che 吴楚东南坼 乾坤日夜浮 +wu cong bei fang wen zi wei ti 吾从北方闻子为梯 将以攻宋 +wu cong zhi shu yi guan 无从致书以观 每假借于藏书之家 +wu di ci qian yun yu san 武帝祠前云欲散 仙人掌上雨初晴 +wu di da guan zou bi hu 屋底达官走避胡 金鞭断折九马死 +wu dian leng xiu 舞殿冷袖 风雨凄凄 +wu duan jia de jin gui xu 无端嫁得金龟婿 辜负香衾事早朝 +wu er shan qiu zhe shu bai 吴儿善泅者数百 皆披发文身 +wu fei zhi yu zi zhi men 吾非至于子之门 则殆矣 +wu ge you zhu 物各有主 苟非吾之所有 +wu geng gu jiao sheng bei zhuang 五更鼓角声悲壮 三峡星河影动摇 +wu geng shu yu duan 五更疏欲断 一树碧无情 +wu gong hua cao mai you jing 吴宫花草埋幽径 晋代衣冠成古丘 +wu guan zi gu xian da ren 吾观自古贤达人 功成不退皆殒身 +wu guo sheng er dang sha 物过盛而当杀 嗟乎 +wu hu yan shui du wang ji 五湖烟水独忘机 +wu hua lian qian xuan zuo bing 五花连钱旋作冰 幕中草檄砚水凝 +wu hua tian bao 物华天宝 龙光射牛斗之墟 +wu hua zhi you han 无花只有寒 笛中闻折柳 +wu huai shi zhi min yu 无怀氏之民欤 葛天氏之民欤 +wu huan xing yi ji du qiu 物换星移几度秋 阁中帝子今何在 +wu huang kai bian yi wei yi 武皇开边意未已 君不闻汉家山东二百州 +wu ji lao er bei shang 物既老而悲伤 夷 +wu ji ya jiu huan ke chang 吴姬压酒唤客尝 金陵子弟来相送 +wu ji yi yan zhi wang yi 吾既已言之王矣 子墨子曰 +wu jia wen si sheng 无家问死生 寄书长不达 +wu ke nai he hua luo qu 无可奈何花落去 似曾相识燕归来 +wu ling jia qi wu shi wu 五陵佳气无时无 +wu ling nian shao zheng chan tou 五陵年少争缠头 一曲红绡不知数 +wu ling ren bu yu wei ye 武陵人捕鱼为业 缘溪行 +wu long duan yan 无陇断焉 +wu lu du po shou dong si yi zu 吾庐独破受冻死亦足 +wu lu qing ying 无路请缨 等终军之弱冠 +wu lun wei jin 无论魏晋 此人一一为具言所闻 +wu ma mo liu lian 五马莫留连 +wu mei qiu zhi 寤寐求之 求之不得 +wu mei si fu 寤寐思服 悠哉悠哉 +wu mou shi bu yong 吾谋适不用 勿谓知音稀 +wu neng wei ye yi 无能为也已 公曰 +wu ning ai yu zeng 吾宁爱与憎 +wu qi hai ye 吾其还也 亦去之 +wu qian diao jin sang hu chen 五千貂锦丧胡尘 可怜无定河边骨 +wu qian ren yue shang mo tian 五千仞岳上摩天 遗民泪尽胡尘里 +wu qing wu gong song yi 吾请无攻宋矣 +wu qing zui shi tai cheng liu 无情最是台城柳 依旧烟笼十里堤 +wu que nan fei 乌鹊南飞 此非曹孟德之诗乎 +wu ren shou fei zhang 无人收废帐 归马识残旗 +wu ren zhi shi li zhi lai 无人知是荔枝来 +wu ri san xing wu shen 吾日三省吾身 为人谋而不忠乎 +wu ru ou hua shen chu 误入藕花深处 争渡 +wu se jiao hui 五色交辉 青林翠竹 +wu she yan ran 屋舍俨然 有良田美池桑竹之属 +wu shi jiao long de 无使蛟龙得 +wu shi lou tai 雾失楼台 月迷津渡 +wu shi ren fei shi shi xiu 物是人非事事休 欲语泪先流 +wu shi xian fan sai wai sheng 五十弦翻塞外声 沙场秋点兵 +wu shi yu ren 勿施于人 +wu shui yu gui 吾谁与归 时六年九月十五日 +wu si zhu zhi luan er 无丝竹之乱耳 无案牍之劳形 +wu song hang dang 雾凇沆砀 天与云与山与水 +wu sun bu luo jia xiang yuan 乌孙部落家乡远 逻娑沙尘哀怨生 +wu tong geng jian xi yu 梧桐更兼细雨 到黄昏 +wu tong xiang dai lao 梧桐相待老 鸳鸯会双死 +wu wei zai qi lu 无为在歧路 儿女共沾巾 +wu wei zhi yin xi 勿谓知音稀 +wu xi yi fu gong yun shan 五溪衣服共云山 羯胡事主终无赖 +wu xia ti yuan shu hang lei 巫峡啼猿数行泪 衡阳归雁几封书 +wu xian fei zi wei zhi xiang 无鲜肥滋味之享 同舍生皆被绮绣 +wu xian feng guang jin bei zhan 无限风光尽被占 采得百花成蜜后 +wu xian shan he lei 无限山河泪 谁言天地宽 +wu xie ge tai 舞榭歌台 风流总被 +wu yan du shang xi lou 无言独上西楼 月如钩 +wu yan yi shi zhi nei 悟言一室之内 或因寄所托 +wu yi gu bu sha ren 吾义固不杀人 子墨子起 +wu yi ku zheng chun 无意苦争春 一任群芳妒 +wu yi xiang kou xi yang xie 乌衣巷口夕阳斜 旧时王谢堂前燕 +wu you he zhi qian jiao 舞幽壑之潜蛟 泣孤舟之嫠妇 +wu yu ru bi li ping xian 吾与汝毕力平险 指通豫南 +wu yuan bu dao 无远不到 到则披草而坐 +wu yue bu ke chu 五月不可触 猿鸣天上哀 +wu yue ji zhi jie san gong 五岳祭秩皆三公 四方环镇嵩当中 +wu yue ren bei mang 五月人倍忙 夜来南风起 +wu yue tian shan xue 五月天山雪 无花只有寒 +wu yue xi shi cai 五月西施采 人看隘若耶 +wu yue yu lang xiang yi fou 五月渔郎相忆否 小楫轻舟 +wu zhang jian xiao yu da fang zhi jia 吾长见笑于大方之家 +wu zhi suo yi ju zi yi 吾知所以距子矣 吾不言 +wu zhi zi zhi suo yi ju wo 吾知子之所以距我 吾不言 +xi bi chang she 夕避长蛇 磨牙吮血 +xi bian chao zhou lu ba qian 夕贬潮州路八千 欲为圣明除弊事 +xi cao wei feng an 细草微风岸 危樯独夜舟 +xi cao xiang xian xiao dong you 细草香闲小洞幽 何用别寻方外去 +xi chai huo ruan man zhan nuan 溪柴火软蛮毡暖 我与狸奴不出门 +xi chu dou men bai yu li 西出都门百余里 六军不发无奈何 +xi chu yang guan wu gu ren 西出阳关无故人 +xi dang tai bai you niao dao 西当太白有鸟道 可以横绝峨眉巅 +xi dian ying fei si qiao ran 夕殿萤飞思悄然 孤灯挑尽未成眠 +xi feng tian shu bai suo wei 夕奉天书拜琐闱 强欲从君无那老 +xi gong nan nei duo qiu cao 西宫南内多秋草 落叶满阶红不扫 +xi he yong rui jin he yu 昔何勇锐今何愚 窃闻天子已传位 +xi he zhu rong 西和诸戎 南抚夷越 +xi hu ge wu ji shi xiu 西湖歌舞几时休 暖风熏得游人醉 +xi hua yu chan yi 溪花与禅意 相对亦忘言 +xi jiao hu zheng fan 西郊胡正繁 至今残破胆 +xi lou wang yue ji hui yuan 西楼望月几回圆 +xi lu zhan wo yi 夕露沾我衣 衣沾不足惜 +xi qin huang han wu 惜秦皇汉武 略输文采 +xi qu zhi er you yu 昔取之而有余 今守之而不足 +xi ren yi cheng huang he qu 昔人已乘黄鹤去 此地空余黄鹤楼 +xi ri chang cheng zhan 昔日长城战 咸言意气高 +xi ri heng bo mu 昔日横波目 今成流泪泉 +xi ri yu tui 夕日欲颓 沉鳞竞跃 +xi ru wai ren 悉如外人 黄发垂髫 +xi ruo wen zu 细若蚊足 钩画了了 +xi sai shan qian bai lu fei 西塞山前白鹭飞 桃花流水鳜鱼肥 +xi shan bai xue san cheng shu 西山白雪三城戍 南浦清江万里桥 +xi shan kou dao mo xiang qin 西山寇盗莫相侵 可怜后主还祠庙 +xi shan luan he qun 西山鸾鹤群 +xi shang qing qing cao 溪上青青草 醉里吴音相媚好 +xi shen er yu fei 溪深而鱼肥 酿泉为酒 +xi shi fei jian wu quan mu 昔时飞箭无全目 今日垂杨生左肘 +xi shi mai an jian 西市买鞍鞯 南市买辔头 +xi shou zuo geng tang 洗手作羹汤 未谙姑食性 +xi shu zi yun ting 西蜀子云亭 孔子云 +xi si mi lu man zhi 犀兕麋鹿满之 江汉之鱼鳖鼋鼍为天下富 +xi sui liu shi ding chang an 昔随刘氏定长安 帷幄未改神惨伤 +xi tou wo bo lian peng 溪头卧剥莲蓬 +xi tuo long men 喜托龙门 杨意不逢 +xi wang xia kou 西望夏口 东望武昌 +xi wen dong ting shui 昔闻洞庭水 今上岳阳楼 +xi wo wang yi 昔我往矣 杨柳依依 +xi xi yi dai zhong 栖栖一代中 地犹鄹氏邑 +xi xia e mei feng 西下峨眉峰 为我一挥手 +xi xiang niu tou chong tan zhi 系向牛头充炭直 +xi yang du xi ling 夕阳度西岭 群壑倏已暝 +xi yang wu xian hao 夕阳无限好 只是近黄昏 +xi yang xi xia 夕阳西下 断肠人在天涯 +xi yang xi xia ji shi hui 夕阳西下几时回 无可奈何花落去 +xi yang yi jiu lei 夕阳依旧垒 寒磬满空林 +xi yi hu shan yu mu shi 徙倚湖山欲暮时 万里来游还望远 +xi yi yu he yi 徙倚欲何依 树树皆秋色 +xi yi zi zhi 悉以咨之 然后施行 +xi yu gu ren wei 惜与故人违 当路谁相假 +xi zhan geng zhuo 洗盏更酌 肴核既尽 +xi zhu yan yu jiu wei xia 犀箸厌饫久未下 銮刀缕切空纷纶 +xia che yin zhi 下车引之 元方入门不顾 +xia chuang wei she shi wei yao 下床畏蛇食畏药 海气湿蛰熏腥臊 +xia jian xiao tan 下见小潭 水尤清冽 +xia lang zhi lin 呷浪之鳞 悠然自得 +xia li shui zhi you ren shi 峡里谁知有人事 世中遥望空云山 +xia lin wu di 下临无地 鹤汀凫渚 +xia wen cheng lei 夏蚊成雷 私拟作群鹤舞于空中 +xia you chong bo ni zhe zhi hui chuan 下有冲波逆折之回川 黄鹤之飞尚不得过 +xia you lu shui zhi bo lan 下有渌水之波澜 天长路远魂飞苦 +xia zhe piao zhuan chen tang ao 下者飘转沉塘坳 南村群童欺我老无力 +xian ai gu yun jing ai seng 闲爱孤云静爱僧 欲把一麾江海去 +xian chang jiang zhi wu qiong 羡长江之无穷 挟飞仙以遨游 +xian chou gong hen 献愁供恨 玉簪螺髻 +xian da de long wang zun 先达德隆望尊 门人弟子填其室 +xian di bu yi chen bei bi 先帝不以臣卑鄙 猥自枉屈 +xian di cheng zhi yue 先帝称之曰 能 +xian di chuang ye wei ban er zhong dao beng cu 先帝创业未半而中道崩殂 今天下三分 +xian di zai shi 先帝在时 每与臣论此事 +xian di zhi chen jin shen 先帝知臣谨慎 故临崩寄臣以大事也 +xian fu shang xian hou jiao yu 先拂商弦后角羽 四郊秋叶惊摵摵 +xian ge ning er bai yun e 纤歌凝而白云遏 睢园绿竹 +xian guan ji suo zu 县官急索租 租税从何出 +xian jing shao yan 闲静少言 不慕荣利 +xian lai chui diao bi xi shang 闲来垂钓碧溪上 忽复乘舟梦日边 +xian lai wen xun 咸来问讯 自云先世避秦时乱 +xian lu qi chang zao 闲鹭栖常早 秋花落更迟 +xian mei ji zou 衔枚疾走 不闻号令 +xian neng wei zhi yong 贤能为之用 此可以为援而不可图也 +xian qian xiao gu chang 先遣小姑尝 +xian qiao qi zi luo deng hua 闲敲棋子落灯花 +xian ren chui liang zu 仙人垂两足 桂树何团团 +xian ren zhang shang yu chu qing 仙人掌上雨初晴 河山北枕秦关险 +xian shang fu shi 衔觞赋诗 以乐其志 +xian sheng bu zhi he xu ren ye 先生不知何许人也 亦不详其姓字 +xian tai chu jian wu cheng lou 仙台初见五城楼 风物凄凄宿雨收 +xian tian xia zhi you er you 先天下之忧而忧 后天下之乐而乐 +xian xian yan yi sheng sheng si 弦弦掩抑声声思 似诉平生不得志 +xian yan ming mei 鲜妍明媚 如倩女之靧面而髻鬟之始掠也 +xian yan yi qi gao 咸言意气高 黄尘足今古 +xian yi nong pu lin 闲依农圃邻 偶似山林客 +xian yi yi zhi teng 闲倚一枝藤 世界微尘里 +xian yu cai ren yuan yi 贤于材人远矣 卒之为众人 +xian yue feng piao chu chu wen 仙乐风飘处处闻 缓歌慢舞凝丝竹 +xian yun si juan tian wu he 纤云四卷天无河 清风吹空月舒波 +xian yun tan ying ri you you 闲云潭影日悠悠 物换星移几度秋 +xian zhe neng wu sang er 贤者能勿丧耳 一箪食 +xian zhi ren xi lie ru ma 仙之人兮列如麻 忽魂悸以魄动 +xian zhu jie zhi yue 先主解之曰 孤之有孔明 +xian zhu qi zhi 先主器之 谓先主曰 +xian zhu wu hou tong bi gong 先主武侯同閟宫 崔嵬枝干郊原古 +xian zuo shuo xuan zong 闲坐说玄宗 +xiang bei ge wen nian 相悲各问年 孤灯寒照雨 +xiang cun si yue xian ren shao 乡村四月闲人少 才了蚕桑又插田 +xiang dui yi wang yan 相对亦忘言 +xiang feng he bi ceng xiang shi 相逢何必曾相识 我从去年辞帝京 +xiang feng mei zui huan 相逢每醉还 浮云一别后 +xiang gu jing yi 相顾惊疑 念近中无此禅院 +xiang gu wu xiang shi 相顾无相识 长歌怀采薇 +xiang jian he tai ji 相煎何太急 +xiang jian shi nan bie yi nan 相见时难别亦难 东风无力百花残 +xiang jiang bei qu 湘江北去 橘子洲头 +xiang kan bai ren xue fen fen 相看白刃血纷纷 死节从来岂顾勋 +xiang kan liang bu yan 相看两不厌 只有敬亭山 +xiang lai yin xiu ju 向来吟秀句 不觉已鸣鸦 +xiang lei ke zhong jin 乡泪客中尽 孤帆天际看 +xiang qi miao yun han 相期邈云汉 +xiang qiong peng li zhi bin 响穷彭蠡之滨 雁阵惊寒 +xiang shu bu ke ji 乡书不可寄 秋雁又南回 +xiang shu he chu da 乡书何处达 归雁洛阳边 +xiang shui wu qing diao qi zhi 湘水无情吊岂知 寂寂江山摇落处 +xiang song qing wu xian 相送情无限 沾襟比散丝 +xiang sui xiang tian qu 相随饷田去 丁壮在南冈 +xiang wan yi bu shi 向晚意不适 驱车登古原 +xiang wei er qu 相委而去 元方曰 +xiang wei shen si er bu shou 乡为身死而不受 今为宫室之美为之 +xiang wei zhi qiang 项为之强 又留蚊于素帐中 +xiang wu yun huan shi 香雾云鬟湿 清辉玉臂寒 +xiang xie ji tian jia 相携及田家 童稚开荆扉 +xiang xin xin sui qie 乡心新岁切 天畔独潸然 +xiang yan yu bang gun long fu 香烟欲傍衮龙浮 朝罢须裁五色诏 +xiang yang hao feng ri 襄阳好风日 留醉与山翁 +xiang ye zhong jing su luan feng 香叶终经宿鸾凤 志士幽人莫怨嗟 +xiang yi cai fu rong 相忆采芙蓉 +xiang yin wu gai bin mao shuai 乡音无改鬓毛衰 儿童相见不相识 +xiang ying bu dao yuan 相迎不道远 直至长风沙 +xiang yu bu yu zhong ting 相与步于中庭 庭下如积水空明 +xiang yu zhen jie hu zhou zhong 相与枕藉乎舟中 不知东方之既白 +xiang yuan yi qing 香远益清 亭亭净植 +xiang zhi suo xin 向之所欣 俯仰之间 +xiao chang jun shi 晓畅军事 试用于昔日 +xiao chou dou ji qian 销愁斗几千 +xiao cong shuang lian sheng 笑从双脸生 +xiao di wen zi lai 小弟闻姊来 磨刀霍霍向猪羊 +xiao feng can yue 晓风残月 此去经年 +xiao geng fan lu cao 晓耕翻露草 夜榜响溪石 +xiao gu ai yin gan gui shen 箫鼓哀吟感鬼神 宾从杂遝实要津 +xiao gu zhui sui chun she jin 箫鼓追随春社近 衣冠简朴古风存 +xiao guan feng hou ji 萧关逢候骑 都护在燕然 +xiao he cai lu jian jian jiao 小荷才露尖尖角 早有蜻蜓立上头 +xiao ji qing xiang ran chu zhu 晓汲清湘燃楚竹 烟销日出不见人 +xiao ji qing zhou 小楫轻舟 梦入芙蓉浦 +xiao jia tan che nian bing zhe 晓驾炭车辗冰辙 牛困人饥日已高 +xiao jing dan chou yun bin gai 晓镜但愁云鬓改 夜吟应觉月光寒 +xiao kan hong shi chu 晓看红湿处 花重锦官城 +xiao kou chai fei jiu bu kai 小扣柴扉久不开 春色满园关不住 +xiao lang hui he qu lan xie 小廊回合曲阑斜 多情只有春庭月 +xiao lou zuo ye you dong feng 小楼昨夜又东风 故国不堪回首月明中 +xiao mai fu long huang 小麦覆陇黄 妇姑荷箪食 +xiao mian bao yu an 宵眠抱玉鞍 愿将腰下剑 +xiao qiao chu jia le 小乔初嫁了 雄姿英发 +xiao qiao liu shui ren jia 小桥流水人家 古道西风瘦马 +xiao shi bu shi yue 小时不识月 呼作白玉盘 +xiao shu bin yi ban 萧疏鬓已斑 何因北归去 +xiao sui tian zhang ru 晓随天仗入 暮惹御香归 +xiao tiao yi dai bu tong shi 萧条异代不同时 江山故宅空文藻 +xiao wa cheng xiao ting 小娃撑小艇 偷采白莲回 +xiao wei yu shu fei han hai 校尉羽书飞瀚海 单于猎火照狼山 +xiao wen ke cong he chu lai 笑问客从何处来 +xiao wu jiang xie 晓雾将歇 猿鸟乱鸣 +xiao xi fan jin que shan xing 小溪泛尽却山行 绿阴不减来时路 +xiao xian qie qie ru si yu 小弦切切如私语 嘈嘈切切错杂弹 +xiao xiao ban ma ming 萧萧班马鸣 +xiao xiao mu yu zi gui ti 潇潇暮雨子规啼 谁道人生无再少 +xiao xiao wu ye song han sheng 萧萧梧叶送寒声 江上秋风动客情 +xiao yuan hua luan fei 小园花乱飞 参差连曲陌 +xiao yuan xiang jing du pai huai 小园香径独徘徊 +xiao yue guo can lei 晓月过残垒 繁星宿故关 +xiao zhan sui jin gu 晓战随金鼓 宵眠抱玉鞍 +xiao zhu jing hua 小住京华 早又是 +xie ba yu chai deng ying pan 斜拔玉钗灯影畔 剔开红焰救飞蛾 +xie fei xian yi ao you 挟飞仙以遨游 抱明月而长终 +xie feng xi yu bu xu gui 斜风细雨不须归 +xie gong su chu jin shang zai 谢公宿处今尚在 渌水荡漾清猿啼 +xie gong zui xiao pian lian nv 谢公最小偏怜女 嫁与黔娄百事乖 +xie guang dao xiao chuan zhu hu 斜光到晓穿朱户 昨夜西风凋碧树 +xie hui mo mo shui you you 斜晖脉脉水悠悠 肠断白蘋洲 +xie lai bai lv ceng you 携来百侣曾游 忆往昔峥嵘岁月稠 +xie shu you chu dong zhi 偕数友出东直 至满井 +xie tai fu han xue ri nei ji 谢太傅寒雪日内集 与儿女讲论文义 +xie tian zi er ling zhu hou 挟天子而令诸侯 此诚不可与争锋 +xie yang cao shu 斜阳草树 寻常巷陌 +xie yi xun long zuo dao ming 斜倚薰笼坐到明 +xin duan xin feng jiu 心断新丰酒 销愁斗几千 +xin gui fan yuan jiu gui ku 新鬼烦冤旧鬼哭 天阴雨湿声啾啾 +xin ke le ye 信可乐也 夫人之相与 +xin ku zao feng qi yi jing 辛苦遭逢起一经 干戈寥落四周星 +xin ning xing shi 心凝形释 与万化冥合 +xin qi wo yi she 心期卧亦赊 向来吟秀句 +xin qie kong fang bu ren gui 心怯空房不忍归 +xin qing wan shi jie hong mao 心轻万事皆鸿毛 醉卧不知白日暮 +xin ran gui wang 欣然规往 未果 +xin ran qi xing 欣然起行 念无与为乐者 +xin ren mei ru yu 新人美如玉 合昏尚知时 +xin sui ming yue dao hu tian 心随明月到胡天 机中锦字论长恨 +xin xin ci sheng yi 欣欣此生意 自尔为佳节 +xin yi zhu yu si hai 信义著于四海 总揽英雄 +xin you ling xi yi dian tong 心有灵犀一点通 隔座送钩春酒暖 +xin you tan jian yuan tian han 心忧炭贱愿天寒 夜来城外一尺雪 +xin yuan di zi pian 心远地自偏 采菊东篱下 +xin zhe xiao qi zhong 信者效其忠 文武争驰 +xin zhi sheng nan e 信知生男恶 反是生女好 +xin zhi suo xiang 心之所向 则或千或百 +xin zhi zao bo su 新知遭薄俗 旧好隔良缘 +xin zhong zi bu ping 心中自不平 牙璋辞凤阙 +xin zhuang yi mian xia zhu lou 新妆宜面下朱楼 深锁春光一院愁 +xing cheng en yu wei jian 幸承恩于伟饯 登高作赋 +xing chui ping ye kuo 星垂平野阔 月涌大江流 +xing ci nan yi zhe 幸此南夷谪 闲依农圃邻 +xing dao shui qiong chu 行到水穷处 坐看云起时 +xing dao zhi ren fu shou 行道之人弗受 蹴尔而与之 +xing dao zhong ting shu hua duo 行到中庭数花朵 蜻蜓飞上玉搔头 +xing fen yi zhen 星分翼轸 地接衡庐 +xing fu han shi 兴复汉室 还于旧都 +xing fu luan qi suo wei 行拂乱其所为 所以动心忍性 +xing gong jian yue shang xin se 行宫见月伤心色 夜雨闻铃肠断声 +xing gong zhi jun zui qiong jiang 星宫之君醉琼浆 羽人稀少不在旁 +xing han can lan 星汉灿烂 若出其里 +xing he qiu yi yan 星河秋一雁 砧杵夜千家 +xing he yu zhuan qian fan wu 星河欲转千帆舞 仿佛梦魂归帝所 +xing jin bei lai 兴尽悲来 识盈虚之有数 +xing jin qing xi bu jian ren 行尽青溪不见人 山口潜行始隈隩 +xing jin wan hui zhou 兴尽晚回舟 误入藕花深处 +xing lai mei du wang 兴来每独往 胜事空自知 +xing le xu ji chun 行乐须及春 我歌月徘徊 +xing li zhi wang lai 行李之往来 共其乏困 +xing lin wan hu dong 星临万户动 月傍九霄多 +xing neng shu yi wen zhe 醒能述以文者 太守也 +xing ren dan yun dian hang pin 行人但云点行频 或从十五北防河 +xing ren gong jian ge zai yao 行人弓箭各在腰 耶娘妻子走相送 +xing ren lin fa you kai feng 行人临发又开封 +xing shen shan ju gu zhong 行深山巨谷中 穷冬烈风 +xing shen zhi zai 幸甚至哉 歌以咏志 +xing shi ri shi ye 行十日十夜 而至于郢 +xing shi xiang jiao huan 醒时相交欢 醉后各分散 +xing xing shu jun 性行淑均 晓畅军事 +xing xue ping feng hua zhe zhi 猩血屏风画折枝 八尺龙须方锦褥 +xing yue jiao jie 星月皎洁 明河在天 +xing yue yan ying yun tong long 星月掩映云曈昽 猿鸣钟动不知曙 +xing zhe xiu yu shu 行者休于树 前者呼 +xing zhou lv shui qian 行舟绿水前 潮平两岸阔 +xiong di zao sha lu 兄弟遭杀戮 官高何足论 +xiong fei ci cong rao lin jian 雄飞雌从绕林间 又闻子规啼夜月 +xiong nu cao huang ma zheng fei 匈奴草黄马正肥 金山西见烟尘飞 +xiong pao long yin yin yan quan 熊咆龙吟殷岩泉 栗深林兮惊层巅 +xiong tu jiao pu shuo 雄兔脚扑朔 雌兔眼迷离 +xiong zhou wu lie 雄州雾列 俊采星驰 +xiong zi hu er yue 兄子胡儿曰 撒盐空中差可拟 +xiong zi ying fa 雄姿英发 羽扇纶巾 +xiu jiang bai fa chang huang ji 休将白发唱黄鸡 +xiu jin jiang yu tian 休祲降于天 与臣而将四矣 +xiu luo yi shang zhao mu chun 绣罗衣裳照暮春 蹙金孔雀银麒麟 +xiu shuo lu yu kan kuai 休说鲈鱼堪脍 尽西风 +xiu xi shi ye 修禊事也 群贤毕至 +xiu yan wei chang kai 羞颜未尝开 低头向暗壁 +xiu zhu chang an she zhong er 羞逐长安社中儿 赤鸡白狗赌梨栗 +xu jin qiu he mei 须尽丘壑美 莫学武陵人 +xu li shang gu yan 墟里上孤烟 复值接舆醉 +xu pen yi yan 徐喷以烟 使之冲烟而飞鸣 +xu ru wei mao zhe 须如猬毛磔 黄云陇底白雪飞 +xu ru xia chen fan zhi ta 徐孺下陈蕃之榻 雄州雾列 +xu shu jian xian zhu 徐庶见先主 先主器之 +xu shu san qiu 序属三秋 潦水尽而寒潭清 +xu xing ji qi fang ming shan 须行即骑访名山 安能摧眉折腰事权贵 +xu yu jing sao zhong feng chu 须臾静扫众峰出 仰见突兀撑青空 +xuan quan pu bu 悬泉瀑布 飞漱其间 +xuan shi qiu xian fang zhu chen 宣室求贤访逐臣 贾生才调更无伦 +xuan xian yu neng 选贤与能 讲信修睦 +xuan zong hui ma yang fei si 玄宗回马杨妃死 云雨难忘日月新 +xue an diao qi hua 雪暗凋旗画 风多杂鼓声 +xue er bu si ze wang 学而不思则罔 思而不学则殆 +xue er shi xi zhi 学而时习之 不亦说乎 +xue fu hua mao cen ci shi 雪肤花貌参差是 金阙西厢叩玉扃 +xue se luo qun fan jiu wu 血色罗裙翻酒污 今年欢笑复明年 +xue shang kong liu ma xing chu 雪上空留马行处 +xue shi man you jing ren ju 学诗漫有惊人句 九万里风鹏正举 +xue yong lan guan ma bu qian 雪拥蓝关马不前 知汝远来应有意 +xun chang xiang mo 寻常巷陌 人道寄奴曾住 +xun huan bu ke xun 循环不可寻 徒言树桃李 +xun sheng an wen tan zhe shui 寻声暗问弹者谁 琵琶声停欲语迟 +xun xiang suo zhi 寻向所志 遂迷 +xun xun mi mi 寻寻觅觅 冷冷清清 +xun yang di pi wu yin yue 浔阳地僻无音乐 终岁不闻丝竹声 +xun yang jiang tou ye song ke 浔阳江头夜送客 枫叶荻花秋瑟瑟 +ya ran wa ran 岈然洼然 若垤若穴 +ya xiang qin wang gan ku xin 亚相勤王甘苦辛 誓将报主静边尘 +ya zhang ci feng que 牙璋辞凤阙 铁骑绕龙城 +yan bo jiang shang shi ren chou 烟波江上使人愁 +yan can fei yu shang lu 俨骖騑于上路 访风景于崇阿 +yan cao ru bi si 燕草如碧丝 秦桑低绿枝 +yan chi geng su jie ying shou 言迟更速皆应手 将往复旋如有情 +yan chuan reng yu gui 眼穿仍欲归 芳心向春尽 +yan cun si wu jia 烟村四五家 亭台六七座 +yan de zhi zhi gong yu tang 焉得置之贡玉堂 +yan fei song jing chang ji liao 岩扉松径长寂寥 惟有幽人夜来去 +yan fei yun lian 烟霏云敛 其容清明 +yan guang ning er mu shan zi 烟光凝而暮山紫 俨骖騑于上路 +yan han zhi le 宴酣之乐 非丝非竹 +yan hua san yue xia yang zhou 烟花三月下扬州 孤帆远影碧空尽 +yan hui an de zhu 沿洄安得住 +yan jian de chui fan le zhe jia 眼见的吹翻了这家 吹伤了那家 +yan lei kong xiang xiang 掩泪空相向 风尘何处期 +yan long han shui yue long sha 烟笼寒水月笼沙 夜泊秦淮近酒家 +yan niao qi chu ding 烟鸟栖初定 之子期宿来 +yan ran wei le gui wu ji 燕然未勒归无计 羌管悠悠霜满地 +yan shang wu xin yun xiang zhu 岩上无心云相逐 +yan sheng yuan guo xiao xiang qu 雁声远过潇湘去 十二楼中月自明 +yan shi cai yao qu 言师采药去 只在此山中 +yan su zu jue 沿溯阻绝 或王命急宣 +yan tao wei mang xin nan qiu 烟涛微茫信难求 越人语天姥 +yan wei xiu mao hu 燕尾绣蝥弧 独立扬新令 +yan xiao bo jing 烟消波静 则一舸无迹 +yan xiao ri chu bu jian ren 烟销日出不见人 欸乃一声山水绿 +yan xie wu heng 烟斜雾横 焚椒兰也 +yan ya gao zhuo 檐牙高啄 各抱地势 +yan yang zai chuang hu 演漾在窗户 苒苒几盈虚 +yan yong wang zheng yi pei lin 焉用亡郑以陪邻 邻之厚 +yan zhen jing han 雁阵惊寒 声断衡阳之浦 +yan zi lai shi xin she 燕子来时新社 梨花落后清明 +yan zi shuang fei qu 燕子双飞去 明月不谙离恨苦 +yang cheng zhai shi yun 杨诚斋诗云 海涌银为郭 +yang chun bu de ze 阳春布德泽 万物生光辉 +yang chun yi qu he jie nan 阳春一曲和皆难 +yang er shi zhi yue 仰而视之曰 吓 +yang guan yu zhou zhi da 仰观宇宙之大 俯察品类之盛 +yang hua luo jin zi gui ti 杨花落尽子规啼 闻道龙标过五溪 +yang hua xue luo fu bai ping 杨花雪落覆白蘋 青鸟飞去衔红巾 +yang jia you nv chu zhang cheng 杨家有女初长成 养在深闺人未识 +yang jian tu wu cheng qing kong 仰见突兀撑青空 紫盖连延接天柱 +yang liu yi yi 杨柳依依 今我来思 +yang sun shu zhi 杨孙戍之 乃还 +yang yang hu yu zao wu zhe you 洋洋乎与造物者游 而不知其所穷 +yang yi bu feng 杨意不逢 抚凌云而自惜 +yang yi zhi fu 养怡之福 可得永年 +yang yue nan fei yan 阳月南飞雁 传闻至此回 +yang zai shen gui ren wei shi 养在深闺人未识 天生丽质难自弃 +yao bai yu zhi huan 腰白玉之环 左佩刀 +yao bu zhi qi suo zhi ye 杳不知其所之也 一肌一容 +yao cen yuan mu 遥岑远目 献愁供恨 +yao chi a mu qi chuang kai 瑶池阿母绮窗开 黄竹歌声动地哀 +yao ding qing shan bu fang song 咬定青山不放松 立根原在破岩中 +yao ge chu yun duan 遥隔楚云端 乡泪客中尽 +yao he ji jin 肴核既尽 杯盘狼籍 +yao jin fu chang 遥襟甫畅 逸兴遄飞 +yao kan pu bu gua qian chuan 遥看瀑布挂前川 飞流直下三千尺 +yao kan shi jun jia 遥看是君家 松柏冢累累 +yao kan yi chu zan yun shu 遥看一处攒云树 近入千家散花竹 +yao kui zheng dian lian kai chu 遥窥正殿帘开处 袍袴宫人扫御床 +yao li zhi ci qing ji ye 要离之刺庆忌也 仓鹰击于殿上 +yao lian xiao er nv 遥怜小儿女 未解忆长安 +yao liu qing bai zai ren jian 要留清白在人间 +yao luo shen zhi song yu bei 摇落深知宋玉悲 风流儒雅亦吾师 +yao tiao dan qing hu you kong 窈窕丹青户牖空 落落盘踞虽得地 +yao tiao shu nv 窈窕淑女 君子好逑 +yao wang dong ting shan shui se 遥望洞庭山水色 白银盘里一青螺 +yao wo zhi tian jia 邀我至田家 绿树村边合 +yao xia bao jue qing shan hu 腰下宝玦青珊瑚 可怜王孙泣路隅 +yao xiang gong jin dang nian 遥想公瑾当年 小乔初嫁了 +yao yao zhong sheng wan 杳杳钟声晚 荷笠带斜阳 +yao yuan zi tai wen 药院滋苔纹 余亦谢时去 +yao zhi xiong di deng gao chu 遥知兄弟登高处 遍插茱萸少一人 +ye ban wu ren si yu shi 夜半无人私语时 在天愿作比翼鸟 +ye ban zhong sheng dao ke chuan 夜半钟声到客船 +ye bang sang yin xue zhong gua 也傍桑阴学种瓜 +ye bang xiang xi shi 夜榜响溪石 来往不逢人 +ye bo qin huai jin jiu jia 夜泊秦淮近酒家 商女不知亡国恨 +ye di huang li yi liang sheng 叶底黄鹂一两声 日长飞絮轻 +ye du wu ren zhou zi heng 野渡无人舟自横 +ye fa qing xi xiang san xia 夜发清溪向三峡 思君不见下渝州 +ye fang fa er you xiang 野芳发而幽香 佳木秀而繁阴 +ye fei yan qiu men shang hu 夜飞延秋门上呼 又向人家啄大屋 +ye fu wu zhu qian 业复五铢钱 得相能开国 +ye huo shao bu jin 野火烧不尽 春风吹又生 +ye jing chun shan kong 夜静春山空 月出惊山鸟 +ye jing yun ju hei 野径云俱黑 江船火独明 +ye jiu qin luo wa 夜久侵罗袜 却下水晶帘 +ye jiu yu sheng jue 夜久语声绝 如闻泣幽咽 +ye ju wang men bu cheng qing 曳裾王门不称情 淮阴市井笑韩信 +ye ku ji jia wen zhan fa 野哭几家闻战伐 夷歌数处起渔樵 +ye kuang tian di shu 野旷天低树 江清月近人 +ye lai cheng wai yi chi xue 夜来城外一尺雪 晓驾炭车辗冰辙 +ye lai feng yu sheng 夜来风雨声 花落知多少 +ye lai nan feng qi 夜来南风起 小麦覆陇黄 +ye lan wo ting feng chui yu 夜阑卧听风吹雨 铁马冰河入梦来 +ye lu you you zou tang xia 野鹿呦呦走堂下 长安城连东掖垣 +ye ni fan qing zhou 也拟泛轻舟 只恐双溪舴艋舟 +ye niang qi zi zou xiang song 耶娘妻子走相送 尘埃不见咸阳桥 +ye niang wen nv lai 爷娘闻女来 出郭相扶将 +ye ran ruo shen ren 烨然若神人 余则缊袍敝衣处其间 +ye shang chu yang gan su yu 叶上初阳干宿雨 水面清圆 +ye shen hu meng shao nian shi 夜深忽梦少年事 梦啼妆泪红阑干 +ye shen jing wo bai chong jue 夜深静卧百虫绝 清月出岭光入扉 +ye shen li luo yi deng ming 夜深篱落一灯明 +ye shen qian dian an ge sheng 夜深前殿按歌声 红颜未老恩先断 +ye shen qian zhang deng 夜深千帐灯 风一更 +ye shu chong shan gan chang huo 野蔬充膳甘长藿 落叶添薪仰古槐 +ye shui zhu hua 邺水朱华 光照临川之笔 +ye si ren lai shao 野寺人来少 云峰水隔深 +ye tou fo si shang gao ge 夜投佛寺上高阁 星月掩映云曈昽 +ye ye chu fei 夜夜除非 好梦留人睡 +ye yin ying jue yue guang han 夜吟应觉月光寒 蓬山此去无多路 +ye yu wen ling chang duan sheng 夜雨闻铃肠断声 天旋地转回龙驭 +ye yu you zhi yue 野语有之曰 闻道百 +ye zhui er chu 夜缒而出 见秦伯 +yi ba jing zhi yi ye 邑八景之一也 然数年恒不一见 +yi bei xiang zhu jun dang ge 一杯相属君当歌 君歌声酸辞且苦 +yi bi wan qing 一碧万顷 沙鸥翔集 +yi bie yin rong liang miao mang 一别音容两渺茫 昭阳殿里恩爱绝 +yi bu sha shao er sha zhong 义不杀少而杀众 不可谓知类 +yi bu shen xi 亦不甚惜 嗟乎 +yi bu xiang qi xing zi 亦不详其姓字 宅边有五柳树 +yi can nian yu li 以残年余力 曾不能毁山之一毛 +yi chi chen shou yu zhi qi 已持臣守圉之器 在宋城上而待楚寇矣 +yi chong yi wei shou 以虫蚁为兽 以土砾凸者为丘 +yi chuan sui shi da ru dou 一川碎石大如斗 随风满地石乱走 +yi chuan xiang jin yao xiang jian 移船相近邀相见 添酒回灯重开宴 +yi ci kua neng 以此夸能 江干上下十余里间 +yi ci zi zhong 以此自终 赞曰 +yi cong cao wei lin 以丛草为林 以虫蚁为兽 +yi cong ming zhi er mu 役聪明之耳目 亏无为之大道哉 +yi cun xiang si yi cun hui 一寸相思一寸灰 +yi cuo shuo dong 一厝朔东 一厝雍南 +yi cuo yong nan 一厝雍南 自此 +yi da yi xiao 以大易小 甚善 +yi dai tian jiao 一代天骄 成吉思汗 +yi dan bu neng you 一旦不能有 输来其间 +yi dian fei hong ying xia 一点飞鸿影下 青山绿水 +yi die ru shan 倚叠如山 一旦不能有 +yi die wei xie 以牒为械 公输盘九设攻城之机变 +yi er xi yang zai shan 已而夕阳在山 人影散乱 +yi feng chao zou jiu chong tian 一封朝奏九重天 夕贬潮州路八千 +yi fu dang guan 一夫当关 万夫莫开 +yi fu gan shen hen 役夫敢申恨 且如今年冬 +yi fu you si lun qi xing shang 宜付有司论其刑赏 以昭陛下平明之理 +yi gao xian di zhi ling 以告先帝之灵 若无兴德之言 +yi ge er he zhi 倚歌而和之 其声呜呜然 +yi ge shu chu qi yu qiao 夷歌数处起渔樵 卧龙跃马终黄土 +yi gong zhi jian 一宫之间 而气候不齐 +yi guai zuo xiao chun meng hao 疑怪昨宵春梦好 元是今朝斗草赢 +yi guan cang hai 以观沧海 水何澹澹 +yi guan jian piao gu feng cun 衣冠简朴古风存 从今若许闲乘月 +yi guan qi zhi zun 移官岂至尊 无才日衰老 +yi guang xian di yi de 以光先帝遗德 恢弘志士之气 +yi hang bai lu shang qing tian 一行白鹭上青天 窗含西岭千秋雪 +yi he hen hu qiu sheng 亦何恨乎秋声 童子莫对 +yi hen shi tun wu 遗恨失吞吴 +yi hu zhong yi 宜乎众矣 +yi huo lai zhao suo jian xi 以火来照所见稀 铺床拂席置羹饭 +yi ji mei hua 驿寄梅花 鱼传尺素 +yi ji yi rong 一肌一容 尽态极妍 +yi jian ceng dang bai wan shi 一剑曾当百万师 汉兵奋迅如霹雳 +yi jiang you gan yu si wen 亦将有感于斯文 +yi jiao qian men wan hu kai 一叫千门万户开 +yi jie shu sheng 一介书生 无路请缨 +yi jing bai ri cuan jing ji 已经百日窜荆棘 身上无有完肌肤 +yi jiu yan long shi li di 依旧烟笼十里堤 +yi jun tiao tiao ge qing tian 忆君迢迢隔青天 昔日横波目 +yi jun wei zhang zhe 以君为长者 故不错意也 +yi jun zhi li 以君之力 曾不能损魁父之丘 +yi kong chi chi gui 意恐迟迟归 谁言寸草心 +yi lan zhong shan xiao 一览众山小 +yi le qi zhi 以乐其志 无怀氏之民欤 +yi li san shi 已历三世 国险而民附 +yi li zi yi shi 倚立自移时 北斗兼春远 +yi liang tian qi wei han shi 已凉天气未寒时 +yi lu jing xing chu 一路经行处 莓苔见履痕 +yi lu xi lian han zhi ping 驿路西连汉畤平 借问路傍名利客 +yi luan yi zheng 以乱易整 不武 +yi mian guan tu xian 亦免冠徒跣 以头抢地耳 +yi min lei jin hu chen li 遗民泪尽胡尘里 南望王师又一年 +yi mu sheng xian zhi dao 益慕圣贤之道 又患无硕师 +yi nan fu shu zhi 一男附书至 二男新战死 +yi nian hao jing jun xu ji 一年好景君须记 最是橙黄橘绿时 +yi nian ming yue jin xiao duo 一年明月今宵多 人生由命非由他 +yi pian bing xin zai yu hu 一片冰心在玉壶 +yi pian gu cheng wan ren shan 一片孤城万仞山 羌笛何须怨杨柳 +yi pian shen ya she gu 一片神鸦社鼓 凭谁问 +yi po gui lai ri 毅魄归来日 灵旗空际看 +yi qi hong chen fei zi xiao 一骑红尘妃子笑 无人知是荔枝来 +yi qi jing guo qing 以其境过清 不可久居 +yi qi wo ran dan zhe wei gao mu 宜其渥然丹者为槁木 黟然黑者为星星 +yi qi wu li yu jin 以其无礼于晋 且贰于楚也 +yi qie wu you 一切乌有 惟危楼一座 +yi qin yong fu 以衾拥覆 久而乃和 +yi qu er san li 一去二三里 烟村四五家 +yi qu hong xiao bu zhi shu 一曲红绡不知数 钿头银篦击节碎 +yi qu xin ci jiu yi bei 一曲新词酒一杯 去年天气旧亭台 +yi qu zi tai lian shuo mo 一去紫台连朔漠 独留青冢向黄昏 +yi ran bu dong 佁然不动 俶尔远逝 +yi ran hei zhe wei xing xing 黟然黑者为星星 奈何以非金石之质 +yi ran zi de 怡然自得 一日 +yi ren ling ping shi nian shi 已忍伶俜十年事 强移栖息一枝安 +yi ren qi zhi 邑人奇之 稍稍宾客其父 +yi ren qun fang du 一任群芳妒 零落成泥碾作尘 +yi ren zhi xin 一人之心 千万人之心也 +yi ri zhi nei 一日之内 一宫之间 +yi ruo wei qiang zhe 以弱为强者 非惟天时 +yi sai zhong jian zhi lu ye 以塞忠谏之路也 宫中府中 +yi shan fang guo yi shan lan 一山放过一山拦 +yi shang xian di zhi ming 以伤先帝之明 故五月渡泸 +yi shang yi yong 一觞一咏 亦足以畅叙幽情 +yi shen zhuan zhan san qian li 一身转战三千里 一剑曾当百万师 +yi sheng he man zi 一声何满子 双泪落君前 +yi sheng yi dong wu jie jing 一声已动物皆静 四座无言星欲稀 +yi shi cai zu gan chang zhong 衣食才足甘长终 侯王将相望久绝 +yi shi di shang shuang 疑是地上霜 举头望明月 +yi shi duo shao hao jie 一时多少豪杰 遥想公瑾当年 +yi shi hou ren er fu ai hou ren ye 亦使后人而复哀后人也 +yi shi huang hun du zi chou 已是黄昏独自愁 更著风和雨 +yi shi huo jin su yi shi 一食或尽粟一石 食马者不知其能千里而食也 +yi shi ren duo yi shu jia yu 以是人多以书假余 余因得遍观群书 +yi shi shi you 亦时时有 风力虽尚劲 +yi shi xuan ya bai zhang bing 已是悬崖百丈冰 犹有花枝俏 +yi shi yin he luo jiu tian 疑是银河落九天 +yi shou fu ying zuo chang tan 以手抚膺坐长叹 问君西游何时还 +yi shu bi wu qing 一树碧无情 薄宦梗犹泛 +yi shui hu tian jiang lv rao 一水护田将绿绕 两山排闼送青来 +yi si chang sha fu 已似长沙傅 从今又几年 +yi sui yi ku rong 一岁一枯荣 野火烧不尽 +yi tan yi shi you ba pai 一弹一十有八拍 胡人落泪沾边草 +yi tian xia zhi mei wei jin zai ji 以天下之美为尽在己 顺流而东行 +yi tian xia zhi suo shun 以天下之所顺 攻亲戚之所畔 +yi tong zi shao jiu lu zheng fei 一童子烧酒炉正沸 见余 +yi tou qiang di er 以头抢地耳 唐雎曰 +yi tu li tu zhe wei qiu 以土砾凸者为丘 凹者为壑 +yi wai duan qiao bian 驿外断桥边 寂寞开无主 +yi wang kong kuo 一望空阔 若脱笼之鹄 +yi wang xi zheng rong sui yue chou 忆往昔峥嵘岁月稠 恰同学少年 +yi wei chen ji 已为陈迹 犹不能不以之兴怀 +yi wei fan shi zhou zhi shan shui you yi tai zhe 以为凡是州之山水有异态者 皆我有也 +yi wei mo ji ruo zhe 以为莫己若者 我之谓也 +yi wei qu long cheng 一为取龙城 +yi wo suo yu ye 亦我所欲也 二者不可得兼 +yi wu you xin zhi 疑误有新知 +yi xi bei zai 噫嘻悲哉 此秋声也 +yi xian yi zhu si hua nian 一弦一柱思华年 庄生晓梦迷蝴蝶 +yi xing chuan fei 逸兴遄飞 爽籁发而清风生 +yi xing you wu dian ming chu 一行有五点明处 楼外天也 +yi xiu xing ren zi ke chou 一宿行人自可愁 潮落夜江斜月里 +yi yan jun fu 一言均赋 四韵俱成 +yi ye fei du jing hu yue 一夜飞度镜湖月 湖月照我影 +yi ye xiang xin wu chu tong 一夜乡心五处同 +yi ye xu zheng pao 一夜絮征袍 素手抽针冷 +yi ye yu chuan liang xiao tong 一叶渔船两小童 收篙停棹坐船中 +yi ye zheng ren jin wang xiang 一夜征人尽望乡 +yi yi feng he ju 一一风荷举 故乡遥 +yi yi ren mou ye 抑亦人谋也 今操已拥百万之众 +yi yi sheng lv tai 一一生绿苔 苔深不能扫 +yi yi wan ji 以亿万计 忽大风起 +yi you jin zhi shi xi 亦犹今之视昔 悲夫 +yi you liu wang kui feng qian 邑有流亡愧俸钱 闻道欲来相问讯 +yi you suo ji 意有所极 梦亦同趣 +yi yu bu ming chan 意欲捕鸣蝉 忽然闭口立 +yi ze wei qi yue zhi lv 夷则为七月之律 商 +yi zhan bu zu xi 衣沾不足惜 但使愿无违 +yi zhang chai men wai 倚杖柴门外 临风听暮蝉 +yi zhang qi jiu 以彰其咎 陛下亦宜自谋 +yi zhao bi xia ping ming zhi li 以昭陛下平明之理 不宜偏私 +yi zhao xuan zai jun wang ce 一朝选在君王侧 回眸一笑百媚生 +yi zhe he suo mu 弋者何所慕 +yi zhi hong xing chu qiang lai 一枝红杏出墙来 +yi zhi hong yan lu ning xiang 一枝红艳露凝香 云雨巫山枉断肠 +yi zhi hu yan yu zheng bo yue 佚之狐言于郑伯曰 国危矣 +yi zhi niao shou 以至鸟兽 木石 +yi zhi quan lu jin 已知泉路近 欲别故乡难 +yi zhong you zu le zhe 以中有足乐者 不知口体之奉不若人也 +yi zhou bo yan zhu 移舟泊烟渚 日暮客愁新 +yi zhou pi bi 益州疲弊 此诚危急存亡之秋也 +yi zhou xian sai 益州险塞 沃野千里 +yi zi zou shan dao 以咨诹善道 察纳雅言 +yi zu yi chang xu you qing 亦足以畅叙幽情 是日也 +yi zun huan lei jiang yue 一尊还酹江月 +yi zuo lu rao jin ting dong 忆昨路绕锦亭东 先主武侯同閟宫 +yin bian dong zhi ji tian ya 吟鞭东指即天涯 落红不是无情物 +yin chu wei gu yun 隐处唯孤云 松际露微月 +yin er he yan 因而和焉 汉家烟尘在东北 +yin feng nu hao 阴风怒号 浊浪排空 +yin feng xiang yu ke 因风想玉珂 明朝有封事 +yin ma du qiu shui 饮马渡秋水 水寒风似刀 +yin ping ren yue 因屏人曰 汉室倾颓 +yin ping zha po shui jiang beng 银瓶乍破水浆迸 铁骑突出刀枪鸣 +yin qi hui mei wu qing feng 阴气晦昧无清风 潜心默祷若有应 +yin qin wen wo gui he chu 殷勤问我归何处 我报路长嗟日暮 +yin qing zhong he shu 阴晴众壑殊 欲投人处宿 +yin ren chang re 因人常热 俗子胸襟谁识我 +yin ren zhi li er bi zhi 因人之力而敝之 不仁 +yin shang man zhuo 引觞满酌 颓然就醉 +yin shao zhe zui 饮少辄醉 而年又最高 +yin shi bai wu jie bei qiong chang shi 饮食百物皆倍穹常时 而僦赁看幕 +yin si du ling meng 因思杜陵梦 凫雁满回塘 +yin tian bi ri 隐天蔽日 自非亭午夜分 +yin tu zhi bei 隐土之北 遂率子孙荷担者三夫 +yin wu shu ying xiong jing zhe yao 引无数英雄竞折腰 惜秦皇汉武 +yin yang ge hun xiao 阴阳割昏晓 荡胸生曾云 +yin yi wei hao yan 因以为号焉 闲静少言 +yin yi wei liu shang qu shui 引以为流觞曲水 列坐其次 +yin yin fei qiao ge ye yan 隐隐飞桥隔野烟 石矶西畔问渔船 +yin yu shi yi 引喻失义 以塞忠谏之路也 +yin zheng ye jiu yin qin nong 银筝夜久殷勤弄 心怯空房不忍归 +yin zhi chuan yuan qing 因之传远情 日夕怀空意 +yin zhu qiu guang leng hua ping 银烛秋光冷画屏 轻罗小扇扑流萤 +yin zuo fa hua xi ting 因坐法华西亭 望西山 +ying bu shi shang 影布石上 佁然不动 +ying chuan xu shu yuan zhi yu liang you shan 颍川徐庶元直与亮友善 谓为信然 +ying dai zuo you 映带左右 引以为流觞曲水 +ying de cang huang bei gu 赢得仓皇北顾 四十三年 +ying de sheng qian shen hou ming 赢得生前身后名 可怜白发生 +ying dong dao jing yao xiao xiang 影动倒景摇潇湘 星宫之君醉琼浆 +ying gong yuan hun yu 应共冤魂语 投诗赠汨罗 +ying ji chang kong 鹰击长空 鱼翔浅底 +ying jian long tou mei 应见陇头梅 +ying jie bi cao zi chun se 映阶碧草自春色 隔叶黄鹂空好音 +ying lian ji chi yin cang tai 应怜屐齿印苍苔 小扣柴扉久不开 +ying ling jin lai gui 英灵尽来归 遂令东山客 +ying qing liao bai 萦青缭白 外与天际 +ying ren chi tang wo guan 媵人持汤沃灌 以衾拥覆 +ying ri he hua bie yang hong 映日荷花别样红 +ying ru ping qiang jiang shui liu 影入平羌江水流 夜发清溪向三峡 +ying shi chang e zhi yu ren 应是嫦娥掷与人 +ying shi liang chen hao jing xu she 应是良辰好景虚设 便纵有千种风情 +ying suo zhi qi 盈缩之期 不但在天 +ying ti song ke wen 莺啼送客闻 +ying ti yan yu bao xin nian 莺啼燕语报新年 马邑龙堆路几千 +ying tu sui wo shen 影徒随我身 暂伴月将影 +ying wu qian tou bu gan yan 鹦鹉前头不敢言 +ying xiong mo lu dang mo zhe 英雄末路当磨折 莽红尘 +ying xiong wu mi 英雄无觅 孙仲谋处 +ying xu zhe ru bi 盈虚者如彼 而卒莫消长也 +ying ying cheng yun 嘤嘤成韵 蝉则千转不穷 +ying you wei zhao hun 应有未招魂 近得归京邑 +ying zhi gu xiang shi 应知故乡事 来日绮窗前 +ying zhuan huang zhou chun se lan 莺啭皇州春色阑 金阙晓钟开万户 +yong bao wu jiang zhi xiu 永保无疆之休 不念居安思危 +yong cui yi lu huo 拥毳衣炉火 独往湖心亭看雪 +yong he jiu nian 永和九年 岁在癸丑 +yong huai dang ci jie 永怀当此节 倚立自移时 +yong hui zhe jie wu xian cai 拥篲折节无嫌猜 剧辛乐毅感恩分 +yong jie wu qing you 永结无情游 相期邈云汉 +yong ye jiao sheng bei zi yu 永夜角声悲自语 中天月色好谁看 +yong zhe jie qi li 勇者竭其力 仁者播其惠 +yong zhi bu jie 用之不竭 是造物者之无尽藏也 +yong zhi ru ni sha 用之如泥沙 使负栋之柱 +you an xiang ying xiu 有暗香盈袖 莫道不销魂 +you bao pi pa ban zhe mian 犹抱琵琶半遮面 转轴拨弦三两声 +you bei rong xiu 右备容臭 烨然若神人 +you bing xiang ye 又兵象也 于行用金 +you bu de jian zhe 有不得见者 三十六年 +you bu neng bu yi zhi xing huai 犹不能不以之兴怀 况修短随化 +you bu ru ren 犹不如人 今老矣 +you bu shou zhi ren 又不受之人 得为众人而已耶 +you chan wei ji 忧谗畏讥 满目萧然 +you chou feng yu 忧愁风雨 树犹如此 +you dai zhao yang ri ying lai 犹带昭阳日影来 +you de bei chen chui 犹得备晨炊 夜久语声绝 +you di jie fen san 有弟皆分散 无家问死生 +you dong yu zhong 有动于中 必摇其精 +you er mo xi ying chuan shui 有耳莫洗颍川水 有口莫食首阳蕨 +you huai tou bi 有怀投笔 慕宗悫之长风 +you huan wu shuo shi 又患无硕师 名人与游 +you jian ru chang lou 又渐如常楼 又渐如高舍 +you jian ru gao she 又渐如高舍 倏忽如拳如豆 +you jing du xing mi 幽径独行迷 霜落熊升树 +you jiu bu yin nai ming he 有酒不饮奈明何 +you ju mo mo ru cang tao 幽居默默如藏逃 下床畏蛇食畏药 +you ju zai kong gu 幽居在空谷 自云良家子 +you kan yi zhan qu gong xun 犹堪一战取功勋 +you kong qiong lou yu yu 又恐琼楼玉宇 高处不胜寒 +you kou mo shi shou yang jue 有口莫食首阳蕨 含光混世贵无名 +you lai qing qi chi 由来轻七尺 杀人莫敢前 +you lai zheng zhan di 由来征战地 不见有人还 +you li ye zhuo ren 有吏夜捉人 老翁逾墙走 +you liang ren pu zhan dui zuo 有两人铺毡对坐 一童子烧酒炉正沸 +you liang tian mei chi sang zhu zhi shu 有良田美池桑竹之属 阡陌交通 +you lie de suo 优劣得所 亲贤臣 +you liu wen yu su zhang zhong 又留蚊于素帐中 徐喷以烟 +you long ze ling 有龙则灵 斯是陋室 +you peng zi yuan fang lai 有朋自远方来 不亦乐乎 +you qi zhi zhi suo bu neng 忧其智之所不能 宜其渥然丹者为槁木 +you quan guai shi 幽泉怪石 无远不到 +you ran jian nan shan 悠然见南山 山气日夕佳 +you ran zi de 悠然自得 毛羽鳞鬣之间皆有喜气 +you ren bian nu 友人便怒 非人哉 +you ren gui du wo 幽人归独卧 滞虑洗孤清 +you ren qu er qin niao le ye 游人去而禽鸟乐也 然而禽鸟知山林之乐 +you ren sui wei sheng 游人虽未盛 泉而茗者 +you ren ying wei mian 幽人应未眠 +you ru fu di zhi bing 又如赴敌之兵 衔枚疾走 +you shan shi zhe shi fan 有善始者实繁 能克终者盖寡 +you shi chao fa bai di 有时朝发白帝 暮到江陵 +you shi chun gui meng li ren 犹是春闺梦里人 +you shi gan ji 由是感激 遂许先帝以驱驰 +you shi gu fan yi ri cheng 犹是孤帆一日程 估客昼眠知浪静 +you shi jian ri 有时见日 +you shi kong wang gu yun gao 有时空望孤云高 长河浪头连天黑 +you shi piao ling 有时飘零 人为动物 +you shi xian zhu sui yi liang 由是先主遂诣亮 凡三往 +you shi ze ke yi bi huan er you bu wei ye 由是则可以辟患而有不为也 是故所欲有甚于生者 +you shi ze sheng er you bu yong ye 由是则生而有不用也 由是则可以辟患而有不为也 +you shou bing yi sui 右手秉遗穗 左臂悬敝筐 +you shou pan you zhi 右手攀右趾 若啸呼状 +you shou zhi juan 右手指卷 如有所语 +you si ni chang yu yi wu 犹似霓裳羽衣舞 玉容寂寞泪阑干 +you song jun gui qu 又送君归去 若到江南赶上春 +you song wang sun qu 又送王孙去 萋萋满别情 +you sun mu wei qu 有孙母未去 出入无完裙 +you suo guang yi 有所广益 将军向宠 +you ting yi ran lin yu quan shang zhe 有亭翼然临于泉上者 醉翁亭也 +you wei li ren zhao luo hua 犹为离人照落花 +you wen ci yu zhong ji ji 又闻此语重唧唧 同是天涯沦落人 +you wen you zao xing zhe 又闻有早行者 见山上人烟市肆 +you wen zi gui ti ye yue 又闻子规啼夜月 愁空山 +you xian ze ming 有仙则名 水不在深 +you xiang ren jia zhuo da wu 又向人家啄大屋 屋底达官走避胡 +you ye quan liu bing xia nan 幽咽泉流冰下难 冰泉冷涩弦凝绝 +you yi wu duan jue 幽意无断绝 此去随所偶 +you yi yan er ke yi zhong shen xing zhi zhe hu 有一言而可以终身行之者乎 子曰 +you yi yao tai jing 又疑瑶台镜 飞在青云端 +you yi zhao yan se 犹疑照颜色 水深波浪阔 +you yin bian diao hu piao sa 幽音变调忽飘洒 长风吹林雨堕瓦 +you yong zhuan zhang yi 又用篆章一 文曰 +you you hu yu hao qi ju 悠悠乎与颢气俱 而莫得其涯 +you you hua zhi qiao 犹有花枝俏 俏也不争春 +you you jing shi 犹有竟时 腾蛇乘雾 +you you qing liu ji tuan 又有清流激湍 映带左右 +you you sheng si bie jing nian 悠悠生死别经年 魂魄不曾来入梦 +you you suo chang 幼有所长 矜 +you yu shi hu shi 游于是乎始 故为之文以志 +you yu si qi xi feng 又欲肆其西封 若不阙秦 +you yu xi shi 游鱼细石 直视无碍 +you yu zhi you shui ye 犹鱼之有水也 愿诸君勿复言 +you yue bu lai guo ye ban 有约不来过夜半 闲敲棋子落灯花 +you zai you zai 悠哉悠哉 辗转反侧 +you zi cun yan 有子存焉 子又生孙 +you zi jiu bu zhi 游子久不至 三夜频梦君 +you zi meng yu qiao 犹自梦渔樵 +you zi shen shang yi 游子身上衣 临行密密缝 +you zi yin shu zhi yi xiang 犹自音书滞一乡 +yu ba mu tian zhong 语罢暮天钟 明日巴陵道 +yu ba xi hu bi xi zi 欲把西湖比西子 淡妆浓抹总相宜 +yu ba yi hui jiang hai qu 欲把一麾江海去 乐游原上望昭陵 +yu bao zhi yu bi xia ye 欲报之于陛下也 诚宜开张圣听 +yu bian yi wang yan 欲辨已忘言 +yu bie gu xiang nan 欲别故乡难 毅魄归来日 +yu chang qiu gu ren ren zhi xin 予尝求古仁人之心 或异二者之为 +yu chang yu tu qiang ao tu chu 余常于土墙凹凸处 花台小草丛杂处 +yu chen er jiang si yi 与臣而将四矣 若士必怒 +yu chi yi piao jiu 欲持一瓢酒 远慰风雨夕 +yu chu bu de 欲出不得 每冒风驰行 +yu chu luo yi song ba zhen 御厨络绎送八珍 箫鼓哀吟感鬼神 +yu chuan chi su 鱼传尺素 砌成此恨无重数 +yu da feng chui qu 雨打风吹去 斜阳草树 +yu dai zi xiang 欲代子相 于是惠子恐 +yu dang jian zha he you da 玉珰缄札何由达 万里云罗一雁飞 +yu de zhou lang gu 欲得周郎顾 时时误拂弦 +yu di zong xuan 余弟宗玄 隶而从者 +yu du ai lian zhi chu yu ni er bu ran 予独爱莲之出淤泥而不染 濯清涟而不妖 +yu du huang he bing sai chuan 欲渡黄河冰塞川 将登太行雪满山 +yu er nv jiang lun wen yi 与儿女讲论文义 俄而雪骤 +yu er tong xiao wan gu chou 与尔同销万古愁 +yu ge hu da 渔歌互答 此乐何极 +yu guan fu ba ling sheng zhuang 予观夫巴陵胜状 在洞庭一湖 +yu han you li 余寒犹厉 冻风时作 +yu hu cang cang 郁乎苍苍 此非孟德之困于周郎者乎 +yu hu qian si ji jing hui 玉虎牵丝汲井回 贾氏窥帘韩掾少 +yu hua er deng xian 羽化而登仙 于是饮酒乐甚 +yu ji cai jian jian chi su 欲寄彩笺兼尺素 山长水阔知何处 +yu ji wu zhou ji 欲济无舟楫 端居耻圣明 +yu ji yi jun zai 欲祭疑君在 天涯哭此时 +yu jiang qing qi zhu 欲将轻骑逐 大雪满弓刀 +yu jiang shu bao jia 预将书报家 相迎不道远 +yu jiao ru ma wei duan jue 雨脚如麻未断绝 自经丧乱少睡眠 +yu jie sheng bai lu 玉阶生白露 夜久侵罗袜 +yu jie xian zhang yong qian guan 玉阶仙仗拥千官 花迎剑珮星初落 +yu jin fu cao wu ying huo 于今腐草无萤火 终古垂杨有暮鸦 +yu jing qun di ji bei dou 玉京群帝集北斗 或骑麒麟翳凤凰 +yu jiu jia jian zhi 于舅家见之 十二三矣 +yu jun ge yi qu 与君歌一曲 请君为我侧耳听 +yu jun li bie yi 与君离别意 同是宦游人 +yu jun ying dian fu ying zhai 与君营奠复营斋 +yu ke shan shan xia yue lun 玉颗珊珊下月轮 殿前拾得露华新 +yu lei fu yun bian gu jin 玉垒浮云变古今 北极朝廷终不改 +yu li shi zuo you 余立侍左右 援疑质理 +yu liang du tou zheng du xuan 渔梁渡头争渡喧 人随沙岸向江村 +yu liu zhi yuan zhe 欲流之远者 必浚其泉源 +yu long ting fan sheng 鱼龙听梵声 惟怜一灯影 +yu lou tian ban qi sheng ge 玉楼天半起笙歌 风送宫嫔笑语和 +yu lou yan ba zui he chun 玉楼宴罢醉和春 姊妹弟兄皆列土 +yu lv ti xie 伛偻提携 往来而不绝者 +yu meng lun yi 与蒙论议 大惊曰 +yu min bu yi feng jiang zhi jie 域民不以封疆之界 固国不以山溪之险 +yu na yi xiao zhou 余拏一小舟 拥毳衣炉火 +yu pan zhen xiu zhi wan qian 玉盘珍羞直万钱 停杯投箸不能食 +yu peng you jiao er bu xin hu 与朋友交而不信乎 传不习乎 +yu qiang yin san da bai er bie 余强饮三大白而别 问其姓氏 +yu qiong qi lin 欲穷其林 林尽水源 +yu qiong qian li mu 欲穷千里目 更上一层楼 +yu qu wu cheng zuo di jia 欲取芜城作帝家 玉玺不缘归日角 +yu ren ge fu yan zhi qi jia 余人各复延至其家 皆出酒食 +yu ren he chu jiao chui xiao 玉人何处教吹箫 +yu ren qi xing 与人期行 相委而去 +yu ren shen yi zhi 渔人甚异之 复前行 +yu ren xi shao bu zai pang 羽人稀少不在旁 似闻昨者赤松子 +yu rong ji mo lei lan gan 玉容寂寞泪阑干 梨花一枝春带雨 +yu shan guan jin 羽扇纶巾 谈笑间 +yu shan jian zhi ming yue 与山间之明月 耳得之而为声 +yu shan wang yi shu yuan fu ke 虞山王毅叔远甫刻 细若蚊足 +yu shang qing tian lan ming yue 欲上青天览明月 抽刀断水水更流 +yu shi bing pi shi jie 于时冰皮始解 波色乍明 +yu shi chi de fu shu 于是鸱得腐鼠 鹓鶵过之 +yu shi hui zi kong 于是惠子恐 搜于国中三日三夜 +yu shi jian gong shu pan 于是见公输盘 子墨子解带为城 +yu shi wei yin 于时为阴 又兵象也 +yu shi wu bie 与世无别 故又名鬼市云 +yu shi yin jiu le shen 于是饮酒乐甚 扣舷而歌之 +yu shi yu liang qing hao ri mi 于是与亮情好日密 关羽 +yu shu zuo ye guo qu li 羽书昨夜过渠黎 单于已在金山西 +yu tou ren chu su 欲投人处宿 隔水问樵夫 +yu wan hua ming he 与万化冥合 然后知吾向之未始游 +yu wei sheng ming chu bi shi 欲为圣明除弊事 肯将衰朽惜残年 +yu wei tong zi 予谓童子 此何声也 +yu wen xin zhou zhi yi fan 宇文新州之懿范 襜帷暂驻 +yu wen xing ren qu na bian 欲问行人去那边 眉眼盈盈处 +yu wen zhi ye jiu 余闻之也久 明道中 +yu weng ye bang xi yan su 渔翁夜傍西岩宿 晓汲清湘燃楚竹 +yu xi bu yuan gui ri jiao 玉玺不缘归日角 锦帆应是到天涯 +yu xi jiao chi ri xi wen 羽檄交驰日夕闻 节使三河募年少 +yu xi lian ye bei 鱼戏莲叶北 +yu xi lian ye dong 鱼戏莲叶东 鱼戏莲叶西 +yu xi lian ye jian 鱼戏莲叶间 鱼戏莲叶东 +yu xi lian ye nan 鱼戏莲叶南 鱼戏莲叶北 +yu xi lian ye xi 鱼戏莲叶西 鱼戏莲叶南 +yu xiang qian di 鱼翔浅底 万类霜天竞自由 +yu xiang ru shuang zhong 馀响入霜钟 不觉碧山暮 +yu xin da yi yu tian xia 欲信大义于天下 而智术浅短 +yu xin ping sheng zui xiao se 庾信平生最萧瑟 暮年诗赋动江关 +yu xing bu xing ge jin shang 欲行不行各尽觞 请君试问东流水 +yu xing yong jin 于行用金 是谓天地之义气 +yu xue fei fei 雨雪霏霏 行道迟迟 +yu xun fang cao qu 欲寻芳草去 惜与故人违 +yu yan bu ji han ya se 玉颜不及寒鸦色 犹带昭阳日影来 +yu yang pi gu dong di lai 渔阳鼙鼓动地来 惊破霓裳羽衣曲 +yu yi cheng zhou gui lu men 余亦乘舟归鹿门 鹿门月照开烟树 +yu yi fei bo ming qi zhong 欲以菲薄明其衷 庙令老人识神意 +yu yi neng gao yong 余亦能高咏 斯人不可闻 +yu yi tong zhi shi 余忆童稚时 能张目对日 +yu yi wei gong zhong zhi shi 愚以为宫中之事 事无大小 +yu yi wei ying zhong zhi shi 愚以为营中之事 悉以咨之 +yu yi xie shi qu 余亦谢时去 西山鸾鹤群 +yu yin de bian guan qun shu 余因得遍观群书 既加冠 +yu yin niao niao 余音袅袅 不绝如缕 +yu yin pi pa ma shang cui 欲饮琵琶马上催 醉卧沙场君莫笑 +yu you shi ji shi xue 余幼时即嗜学 家贫 +yu yu cao mu er zheng rong 欲与草木而争荣 念谁为之戕贼 +yu yu duo nian qiu bu de 御宇多年求不得 杨家有女初长成 +yu yu lei xian liu 欲语泪先流 闻说双溪春尚好 +yu yu qing qing 郁郁青青 而或长烟一空 +yu yu tian gong shi bi gao 欲与天公试比高 须晴日 +yu yu zhou yi jie 与余舟一芥 舟中人两三粒而已 +yu zan luo ji 玉簪螺髻 落日楼头 +yu ze yun pao bi yi chu qi jian 余则缊袍敝衣处其间 略无慕艳意 +yu zhang gu jun 豫章故郡 洪都新府 +yu zhen sha chu 玉枕纱厨 半夜凉初透 +yu zheng ren meng 与郑人盟 使杞子 +yu zhi you jiang zi ci shi 余之游将自此始 恶能无纪 +yu zhong chun shu wan ren jia 雨中春树万人家 为乘阳气行时令 +yu zhou chang wan 渔舟唱晚 响穷彭蠡之滨 +yu zhou zhu shui ai shan chun 渔舟逐水爱山春 两岸桃花夹古津 +yu zhu xi hu 余住西湖 大雪三日 +yu zhu ying ti bie li hou 玉箸应啼别离后 少妇城南欲断肠 +yu zhuang lin jing yong 欲妆临镜慵 承恩不在貌 +yu zuo jia shu yi wan chong 欲作家书意万重 复恐匆匆说不尽 +yuan bi xia qin zhi 愿陛下亲之 信之 +yuan bi xia tuo chen yi tao zei xing fu zhi xiao 愿陛下托臣以讨贼兴复之效 不效 +yuan bu shen er wang liu zhi yuan 源不深而望流之远 根不固而求木之长 +yuan bu zai da 怨不在大 可畏惟人 +yuan chi la xiang 原驰蜡象 欲与天公试比高 +yuan chi qian li zu 愿驰千里足 送儿还故乡 +yuan chu guo zhi 鹓鶵过之 仰而视之曰 +yuan de yan gong she da jiang 愿得燕弓射大将 耻令越甲鸣吾军 +yuan fang qin gu dao 远芳侵古道 晴翠接荒城 +yuan fang ru men bu gu 元方入门不顾 +yuan fang shi nian qi sui 元方时年七岁 门外戏 +yuan fei li tian zhe 鸢飞戾天者 望峰息心 +yuan feng liu nian shi yue shi er ri ye 元丰六年十月十二日夜 解衣欲睡 +yuan he si nian ye 元和四年也 +yuan jia cao cao 元嘉草草 封狼居胥 +yuan jiang yao xia jian 愿将腰下剑 直为斩楼兰 +yuan jie zi sha zhi 愿借子杀之 公输盘不说 +yuan jin gao di ge bu tong 远近高低各不同 不识庐山真面目 +yuan jun duo cai xie 愿君多采撷 此物最相思 +yuan kan shan you se 远看山有色 近听水无声 +yuan lu ying bei chun wan wan 远路应悲春晼晚 残霄犹得梦依稀 +yuan meng gui qin xiao 远梦归侵晓 家书到隔年 +yuan ming san sheng lei zhan shang 猿鸣三声泪沾裳 +yuan ming tian shang ai 猿鸣天上哀 门前迟行迹 +yuan ming zhong dong bu zhi shu 猿鸣钟动不知曙 杲杲寒日生于东 +yuan nao yu du chou pan yuan 猿猱欲度愁攀援 青泥何盘盘 +yuan niao luan ming 猿鸟乱鸣 夕日欲颓 +yuan niao you yi wei jian shu 猿鸟犹疑畏简书 风云常为护储胥 +yuan shang han shan shi jing xie 远上寒山石径斜 白云深处有人家 +yuan shi jin zhao dou cao ying 元是今朝斗草赢 笑从双脸生 +yuan shu dai xing ke 远树带行客 孤城当落晖 +yuan sui chun feng ji yan ran 愿随春风寄燕然 忆君迢迢隔青天 +yuan ti dong ting shu 猿啼洞庭树 人在木兰舟 +yuan tong chen yu hui 愿同尘与灰 常存抱柱信 +yuan wei chi gan sou 愿为持竿叟 +yuan wei feng yu xi 远慰风雨夕 落叶满空山 +yuan wei shi an ma 愿为市鞍马 从此替爷征 +yuan yang bu du su 鸳鸯不独宿 但见新人笑 +yuan yang hui shuang si 鸳鸯会双死 贞妇贵殉夫 +yuan yang wa leng shuang hua zhong 鸳鸯瓦冷霜华重 翡翠衾寒谁与共 +yuan yi zhi li 援疑质理 俯身倾耳以请 +yuan ze bai jiao wu jue 猿则百叫无绝 鸢飞戾天者 +yuan zhong shou zhi 愿终守之 弗敢易 +yuan zhu jun wu fu yan 愿诸君勿复言 羽 +yue bang jiu xiao duo 月傍九霄多 不寝听金钥 +yue chu han tong xue shan bai 月出寒通雪山白 忆昨路绕锦亭东 +yue chu jing shan niao 月出惊山鸟 时鸣春涧中 +yue chu yu dong shan zhi shang 月出于东山之上 徘徊于斗牛之间 +yue dian ying kai wen ye lou 月殿影开闻夜漏 水晶帘卷近秋河 +yue guo yi bi yuan 越国以鄙远 君知其难也 +yue hei yan fei gao 月黑雁飞高 单于夜遁逃 +yue ji bu jie yin 月既不解饮 影徒随我身 +yue luo wu ti shuang man tian 月落乌啼霜满天 江枫渔火对愁眠 +yue mi jin du 月迷津渡 桃源望断无寻处 +yue ming song xia fang long jing 月明松下房栊静 日出云中鸡犬喧 +yue ming xing xi 月明星稀 乌鹊南飞 +yue ming yu su chou bu mian 月明欲素愁不眠 赵瑟初停凤凰柱 +yue ren yu tian lao 越人语天姥 云霞明灭或可睹 +yue se ru hu 月色入户 欣然起行 +yue shi gu xiang ming 月是故乡明 有弟皆分散 +yue wei yi ti 越为一体 傲物则骨肉为行路 +yue xia fei tian jing 月下飞天镜 云生结海楼 +yue xie lou shang wu geng zhong 月斜楼上五更钟 梦为远别啼难唤 +yue yong da jiang liu 月涌大江流 名岂文章著 +yue you yin qing yuan que 月有阴晴圆缺 此事古难全 +yue zhao cheng tou wu ban fei 月照城头乌半飞 霜凄万树风入衣 +yun bian yan duan hu tian yue 云边雁断胡天月 陇上羊归塞草烟 +yun bin ban pian xin shui jiao 云鬓半偏新睡觉 花冠不整下堂来 +yun bin hua yan jin bu yao 云鬓花颜金步摇 芙蓉帐暖度春宵 +yun ci zui ji yu nan tong 云此最吉余难同 窜逐蛮荒幸不死 +yun cong chuang li chu 云从窗里出 +yun deng zhi man 允等之慢 以彰其咎 +yun feng shui ge shen 云峰水隔深 夕阳依旧垒 +yun gui er yan xue ming 云归而岩穴暝 晦明变化者 +yun heng qin ling jia he zai 云横秦岭家何在 雪拥蓝关马不前 +yun ji ba shu hai dui jing 云髻罢梳还对镜 罗衣欲换更添香 +yun kai yuan jian han yang cheng 云开远见汉阳城 犹是孤帆一日程 +yun lai qi jie wu xia chang 云来气接巫峡长 月出寒通雪山白 +yun li di cheng shuang feng que 云里帝城双凤阙 雨中春树万人家 +yun ming wei suo yu 运命惟所遇 循环不可寻 +yun mu ping feng zhu ying shen 云母屏风烛影深 长河渐落晓星沉 +yun qing qing xi yu yu 云青青兮欲雨 水澹澹兮生烟 +yun shen bu zhi chu 云深不知处 +yun sheng jie hai lou 云生结海楼 仍怜故乡水 +yun wai yi sheng ji 云外一声鸡 +yun xia ming mie huo ke du 云霞明灭或可睹 天姥连天向天横 +yun xia sheng bi wei 云霞生薜帷 竹怜新雨后 +yun xiang yi shang hua xiang rong 云想衣裳花想容 春风拂槛露华浓 +yun xiao yu ji 云销雨霁 彩彻区明 +yun yi han zuo zhong nan fu 运移汉祚终难复 志决身歼军务劳 +yun yu huang tai qi meng si 云雨荒台岂梦思 最是楚宫俱泯灭 +yun yu nan wang ri yue xin 云雨难忘日月新 终是圣明天子事 +yun yu wu shan wang duan chang 云雨巫山枉断肠 借问汉宫谁得似 +yun zhan ying yu deng jian ge 云栈萦纡登剑阁 峨嵋山下少人行 +yun zhi jun xi fen fen er lai xia 云之君兮纷纷而来下 虎鼓瑟兮鸾回车 +yun zhi ren ye 允之任也 愿陛下托臣以讨贼兴复之效 +yun zhong jun bu jian 云中君不见 竟夕自悲秋 +za ran er qian chen zhe 杂然而前陈者 太守宴也 +za ran xiang xu 杂然相许 其妻献疑曰 +zai bu dong xu duo chou 载不动许多愁 +zai di yuan wei lian li zhi 在地愿为连理枝 天长地久有时尽 +zai dong ting yi hu 在洞庭一湖 衔远山 +zai he zhi zhou 在河之洲 窈窕淑女 +zai hu shan shui zhi jian ye 在乎山水之间也 山水之乐 +zai ke zai ji 载渴载饥 我心悲伤 +zai shan quan shui qing 在山泉水清 出山泉水浊 +zai shui yi fang 在水一方 溯洄从之 +zai shui zhi mei 在水之湄 溯洄从之 +zai shui zhi si 在水之涘 溯洄从之 +zai song cheng shang er dai chu kou yi 在宋城上而待楚寇矣 虽杀臣 +zai tian yuan zuo bi yi niao 在天愿作比翼鸟 在地愿为连理枝 +zai zhou fu zhou 载舟覆舟 所宜深慎 +zai zhou you hun 在昼犹昏 疏条交映 +zan ban yue jiang ying 暂伴月将影 行乐须及春 +zan cu lei ji 攒蹙累积 莫得遁隐 +zan de yu ji 暂得于己 快然自足 +zan ping bei jiu zhang jing shen 暂凭杯酒长精神 +zan shi fen shou mo chou chu 暂时分手莫踌躇 +zan you tao yuan li 暂游桃源里 +zao bei chan juan wu 早被婵娟误 欲妆临镜慵 +zao hua wei luo tong ye chang 枣花未落桐叶长 青山朝别暮还见 +zao hua zhong shen xiu 造化钟神秀 阴阳割昏晓 +zao sheng hua fa 早生华发 人生如梦 +zao wan xia san ba 早晚下三巴 预将书报家 +zao yin zhe jin 造饮辄尽 期在必醉 +zao you qing ting li shang tou 早有蜻蜓立上头 +zao zhi chao you xin 早知潮有信 嫁与弄潮儿 +ze an ran piao miao 则黯然缥缈 不可计其层次矣 +ze ba ye ke cheng 则霸业可成 汉室可兴矣 +ze di san shi ke zhi wan shi er wei jun 则递三世可至万世而为君 谁得而族灭也 +ze fan ke yi de sheng zhe 则凡可以得生者 何不用也 +ze fan ke yi pi huan zhe 则凡可以辟患者 何不为也 +ze fan shu zhou zhi tu rang 则凡数州之土壤 皆在衽席之下 +ze han shi zhi long 则汉室之隆 可计日而待也 +ze huo qian huo bai 则或千或百 果然鹤也 +ze ming jian shao 则明渐少 数至八层 +ze ming wei er zhong gua 则名微而众寡 然操遂能克绍 +ze ming yi shang jiang jiang jing zhou zhi jun yi xiang wan 则命一上将将荆州之军以向宛 洛 +ze qi shan zhe er cong zhi 择其善者而从之 其不善者而改之 +ze qi shou yu ren zhe bu zhi ye 则其受于人者不至也 彼其受之天也 +ze shan er cong zhi 择善而从之 则智者尽其谋 +ze shi shi er xing 则施施而行 漫漫而游 +ze shi wu li 则是无礼 友人惭 +ze shi wu xin 则是无信 对子骂父 +ze si jiang hai xia bai chuan 则思江海下百川 乐盘游 +ze si qian chong er zi mu 则思谦冲而自牧 惧满溢 +ze si san qu yi wei du 则思三驱以为度 忧懈怠 +ze si shen shi er jing zhong 则思慎始而敬终 虑壅蔽 +ze si wu yi nu er lan xing 则思无以怒而滥刑 总此十思 +ze si wu yin xi yi miu shang 则思无因喜以谬赏 罚所及 +ze si xu xin yi na xia 则思虚心以纳下 惧谗邪 +ze si zheng shen yi chu e 则思正身以黜恶 恩所加 +ze si zhi zhi yi an ren 则思知止以安人 念高危 +ze si zhi zu yi zi jie 则思知足以自戒 将有作 +ze su tuan lv tan 则素湍绿潭 回清倒影 +ze ti ming qi shang 则题名其上 文曰 +ze tian di ceng bu neng yi yi shun 则天地曾不能以一瞬 自其不变者而观之 +ze wu yu wo jie wu jin ye 则物与我皆无尽也 而又何羡乎 +ze yi ge wu ji 则一舸无迹 仅有 +ze you qing yan 则又请焉 故余虽愚 +ze you qu guo huai xiang 则有去国怀乡 忧谗畏讥 +ze you xin kuang shen yi 则有心旷神怡 宠辱偕忘 +ze yu cheng xue ling ji tian er lai 则玉城雪岭际天而来 大声如雷霆 +ze ze you zhi 则责攸之 祎 +ze zhi chen zhi zui 则治臣之罪 以告先帝之灵 +ze zhi zhe jin qi mou 则智者尽其谋 勇者竭其力 +ze zu yi ju qin 则足以拒秦 使秦复爱六国之人 +zen yi ge chou zi liao de 怎一个愁字了得 +zeng qi jiu zhi 增其旧制 刻唐贤今人诗赋于其上 +zeng yi qi suo bu neng 曾益其所不能 人恒过 +zha jian fan yi meng 乍见翻疑梦 相悲各问年 +zha nuan huan han shi hou 乍暖还寒时候 最难将息 +zhai bian you wu liu shu 宅边有五柳树 因以为号焉 +zhai hua bu cha fa 摘花不插发 采柏动盈掬 +zhai ji lu wang gong 宅即鲁王宫 叹凤嗟身否 +zhan bi sheng yi 战必胜矣 +zhan chang bai gu chan cao gen 战场白骨缠草根 剑河风急雪片阔 +zhan jin bi san si 沾襟比散丝 +zhan shi jun qian ban si sheng 战士军前半死生 美人帐下犹歌舞 +zhan zhuan fan ce 辗转反侧 参差荇菜 +zhang da yi xiang feng 长大一相逢 问姓惊初见 +zhang fei deng bu yue 张飞等不悦 先主解之曰 +zhang lu zai bei 张鲁在北 民殷国富而不知存恤 +zhang zai han jia ying 长在汉家营 少妇今春意 +zhang zhe sui you wen 长者虽有问 役夫敢申恨 +zhao bi meng hu 朝避猛虎 夕避长蛇 +zhao ci bai di cai yun jian 朝辞白帝彩云间 千里江陵一日还 +zhao ge ye xian 朝歌夜弦 为秦宫人 +zhao hui xi yin 朝晖夕阴 气象万千 +zhao lu dai ri xi 朝露待日晞 阳春布德泽 +zhao ru qing si mu cheng xue 朝如青丝暮成雪 人生得意须尽欢 +zhao se chu ting feng huang zhu 赵瑟初停凤凰柱 蜀琴欲奏鸳鸯弦 +zhao shu wu dao chu jiang jun 诏书五道出将军 试拂铁衣如雪色 +zhao wang bai gu ying lan cao 昭王白骨萦烂草 谁人更扫黄金台 +zhao yang dian li en ai jue 昭阳殿里恩爱绝 蓬莱宫中日月长 +zhao zhao kong zi gui 朝朝空自归 欲寻芳草去 +zhao zhao ma ce yu dao huan 朝朝马策与刀环 三春白雪归青冢 +zhao zhao wu qie qi 朝朝误妾期 早知潮有信 +zhao zhi shou cang 赵之收藏 韩 +zhe hua men qian ju 折花门前剧 郎骑竹马来 +zhe ji chen sha tie wei xiao 折戟沉沙铁未销 自将磨洗认前朝 +zhe jiang zhi chao 浙江之潮 天下之伟观也 +zhe ju wo bing xun yang cheng 谪居卧病浔阳城 浔阳地僻无音乐 +zhen chu ye qian jia 砧杵夜千家 节候看应晚 +zhen fu gui xun fu 贞妇贵殉夫 舍生亦如此 +zhen han ji she 震撼激射 吞天沃日 +zhen sheng jin bao han gong qiu 砧声近报汉宫秋 疏松影落空坛静 +zhen zhi yi wei nu 震之以威怒 终苟免而不怀仁 +zheng dun yi shang qi lian rong 整顿衣裳起敛容 自言本是京城女 +zheng er bu de 争而不得 不可谓强 +zheng gao zhi zhi 争高直指 千百成峰 +zheng guo you ren wei ji jia 郑国游人未及家 洛阳行子空叹息 +zheng ji zhi wang yi 郑既知亡矣 若亡郑而有益于君 +zheng jin wei zuo 正襟危坐 而问客曰 +zheng peng chu han sai 征蓬出汉塞 归雁入胡天 +zheng ren ji bei kong hui shou 征人蓟北空回首 边庭飘飖那可度 +zheng ru wan shan quan zi li 正入万山圈子里 一山放过一山拦 +zheng shi he tun yu shang shi 正是河豚欲上时 +zheng shi jiang nan hao feng jing 正是江南好风景 落花时节又逢君 +zheng tong ren he 政通人和 百废具兴 +zheng xian gu yong 争先鼓勇 溯迎而上 +zheng zhi yuan yin zao hua gong 正直原因造化功 大厦如倾要梁栋 +zhi ba chun lai bao 只把春来报 待到山花烂漫时 +zhi ba hang zhou zuo bian zhou 直把杭州作汴州 +zhi bu ke hu zhou de 知不可乎骤得 托遗响于悲风 +zhi ce er lin zhi 执策而临之 曰 +zhi chui de shui jin e fei ba 只吹的水尽鹅飞罢 +zhi cong jia shang fei 雉从架上飞 中庭生旅谷 +zhi dian jiang shan 指点江山 激扬文字 +zhi er bu zheng 知而不争 不可谓忠 +zhi fa zang pi 陟罚臧否 不宜异同 +zhi gua yun fan ji cang hai 直挂云帆济沧海 +zhi hua ming yi qiang 枳花明驿墙 因思杜陵梦 +zhi hui ruo ding shi xiao cao 指挥若定失萧曹 运移汉祚终难复 +zhi jian cao xiao shu 只见草萧疏 水萦纡 +zhi jie xiao han 直接霄汉 楼五架 +zhi jin bu hui tian zhong shi 至今不会天中事 应是嫦娥掷与人 +zhi jin can po dan 至今残破胆 应有未招魂 +zhi jin kui mu ma 至今窥牧马 不敢过临洮 +zhi jin si xiang yu 至今思项羽 不肯过江东 +zhi jin yi hen mi yan shu 至今遗恨迷烟树 列国周齐秦汉楚 +zhi jin yi jue bu xin xian 至今已觉不新鲜 江山代有才人出 +zhi jin you yi li jiang jun 至今犹忆李将军 +zhi jiu chang an dao 置酒长安道 同心与我违 +zhi jue shen jian jun wu lao 志决身歼军务劳 +zhi kong shuang xi ze meng zhou 只恐双溪舴艋舟 载不动 +zhi lan heng kan 直栏横槛 多于九土之城郭 +zhi li dong bei feng chen ji 支离东北风尘际 漂泊西南天地间 +zhi liu qing qi man qian kun 只留清气满乾坤 +zhi lv xi gu qing 滞虑洗孤清 持此谢高鸟 +zhi lv zhong chun 志虑忠纯 是以先帝简拔以遗陛下 +zhi neng zhi shi si de ming jun 智能之士思得明君 将军既帝室之胄 +zhi qi bu ke 知其不可 而况于明哲乎 +zhi ru yuan lai ying you yi 知汝远来应有意 好收吾骨瘴江边 +zhi ruo chun he jing ming 至若春和景明 波澜不惊 +zhi shi dang shi yi wang ran 只是当时已惘然 +zhi shi jin huang hun 只是近黄昏 +zhi shi wan gong she da diao 只识弯弓射大雕 俱往矣 +zhi shi wu ai 直视无碍 急湍甚箭 +zhi shi you ren mo yuan jie 志士幽人莫怨嗟 古来材大难为用 +zhi shi zhu yan gai 只是朱颜改 问君能有几多愁 +zhi shou ke re shi jue lun 炙手可热势绝伦 慎莫近前丞相嗔 +zhi shou xiang kan lei yan 执手相看泪眼 竟无语凝噎 +zhi tong yu nan 指通豫南 达于汉阴 +zhi wei zhan lou lan 直为斩楼兰 +zhi wu suo jian 至无所见 而犹不欲归 +zhi yin shi suo xi 知音世所稀 只应守寂寞 +zhi ying shou ji mo 只应守寂寞 还掩故园扉 +zhi you er tong tiao cu zhi 知有儿童挑促织 夜深篱落一灯明 +zhi you jing ting shan 只有敬亭山 +zhi you tian zai shang 只有天在上 更无山与齐 +zhi you xiang ru gu 只有香如故 +zhi yu bei hai 至于北海 东面而视 +zhi yu fu zhe ge yu tu 至于负者歌于途 行者休于树 +zhi yu jin ri 至于今日 然志犹未已 +zhi yu xia shui xiang ling 至于夏水襄陵 沿溯阻绝 +zhi yu zhen zhuo sun yi 至于斟酌损益 进尽忠言 +zhi yuan shen zai ci shan zhong 只缘身在此山中 +zhi yuan shen zai zui gao ceng 只缘身在最高层 +zhi zai ci shan zhong 只在此山中 云深不知处 +zhi zai qian li 志在千里 烈士暮年 +zhi zhi wei zhi zhi 知之为知之 不知为不知 +zhi zhi zhang feng sha 直至长风沙 +zhi zi qi su lai 之子期宿来 孤琴候萝径 +zhi zou xian yang 直走咸阳 二川溶溶 +zhong bin huan ye 众宾欢也 苍颜白发 +zhong dou nan shan xia 种豆南山下 草盛豆苗稀 +zhong e guan er duo ran zhe wei dong po 中峨冠而多髯者为东坡 佛印居右 +zhong er zheng zhi ji long 中儿正织鸡笼 最喜小儿亡赖 +zhong gou mian er bu huai ren 终苟免而不怀仁 貌恭而不心服 +zhong gu chui yang you mu ya 终古垂杨有暮鸦 地下若逢陈后主 +zhong gu yue zhi 钟鼓乐之 +zhong gu zhuan yu bu zu gui 钟鼓馔玉不足贵 但愿长醉不复醒 +zhong jian xiang wang zou chuan che 终见降王走传车 管乐有才原不忝 +zhong jian xiao xie you qing fa 中间小谢又清发 俱怀逸兴壮思飞 +zhong jun zhi jiu yin gui ke 中军置酒饮归客 胡琴琵琶与羌笛 +zhong ming ding shi zhi jia 钟鸣鼎食之家 舸舰迷津 +zhong nan yin ling xiu 终南阴岭秀 积雪浮云端 +zhong niao gao fei jin 众鸟高飞尽 孤云独去闲 +zhong qi ji yu 钟期既遇 奏流水以何惭 +zhong qi yu jin 终期于尽 古人云 +zhong qiu jia jie 中秋佳节 为篱下 +zhong shan zhi ge shu chong shan 钟山只隔数重山 春风又绿江南岸 +zhong shi sheng ming tian zi shi 终是圣明天子事 景阳宫井又何人 +zhong sui bu wen si zhu sheng 终岁不闻丝竹声 住近湓江地低湿 +zhong sui chang duan zheng 终岁常端正 岂不罹凝寒 +zhong sui po hao dao 中岁颇好道 晚家南山陲 +zhong tian yue se hao shui kan 中天月色好谁看 风尘荏苒音书绝 +zhong ting sheng lv gu 中庭生旅谷 井上生旅葵 +zhong tong wai zhi 中通外直 不蔓不枝 +zhong wei tu hui 终为土灰 老骥伏枥 +zhong wu za shu 中无杂树 芳草鲜美 +zhong xuan chang zhe wei cang 中轩敞者为舱 箬篷覆之 +zhong yong sheng wu nian 仲永生五年 未尝识书具 +zhong yong zhi tong wu 仲永之通悟 受之天也 +zhong you lou ruo zhe 中有楼若者 堂若者 +zhong you yi ren zi tai zhen 中有一人字太真 雪肤花貌参差是 +zhong zhi zhi shi wang shen yu wai zhe 忠志之士忘身于外者 盖追先帝之殊遇 +zhou chu yun tian ye ji ma 昼出耘田夜绩麻 村庄儿女各当家 +zhou ji kong shi zhui 舟楫恐失坠 出门搔白首 +zhou jia shen ming shi jia yi 州家申名使家抑 坎轲只得移荆蛮 +zhou nan liu zhi gu suo xi 周南留滞古所惜 南极老人应寿昌 +zhou ren ye yu jue chao sheng 舟人夜语觉潮生 三湘愁鬓逢秋色 +zhou ren zhi dian dao jin yi 舟人指点到今疑 +zhou shou wei chang yue ba fen you qi 舟首尾长约八分有奇 高可二黍许 +zhou wei heng wo yi ji 舟尾横卧一楫 楫左右舟子各一人 +zhou yu chu xie 骤雨初歇 都门帐饮无绪 +zhou zhong ren liang san li er yi 舟中人两三粒而已 到亭上 +zhou zi nan nan yue 舟子喃喃曰 莫说相公痴 +zhu bo piao deng du zi gui 珠箔飘灯独自归 远路应悲春晼晚 +zhu bo yin ping yi li kai 珠箔银屏迤逦开 云鬓半偏新睡觉 +zhu cui luo qi yi mu 珠翠罗绮溢目 车马塞途 +zhu dou chi zuo geng 煮豆持作羹 漉菽以为汁 +zhu dou ran dou qi 煮豆燃豆萁 豆在釜中泣 +zhu ge da ming chui yu zhou 诸葛大名垂宇宙 宗臣遗像肃清高 +zhu ge kong ming zhe 诸葛孔明者 卧龙也 +zhu jin pen jiang di di shi 住近湓江地低湿 黄芦苦竹绕宅生 +zhu ke li li shu ye 珠可历历数也 舟尾横卧一楫 +zhu ke wu xiao xi 逐客无消息 故人入我梦 +zhu lian mu juan xi shan yu 珠帘暮卷西山雨 闲云潭影日悠悠 +zhu lian xin yu hou 竹怜新雨后 山爱夕阳时 +zhu lu qian li 舳舻千里 旌旗蔽空 +zhu ma bie gu fen 驻马别孤坟 近泪无干土 +zhu ma wang qian men 驻马望千门 +zhu ma xian bei wen zhe ju 驻马衔杯问谪居 巫峡啼猿数行泪 +zhu qing sha bai niao fei hui 渚清沙白鸟飞回 无边落木萧萧下 +zhu que qiao bian ye cao hua 朱雀桥边野草花 乌衣巷口夕阳斜 +zhu ren he wei yan shao qian 主人何为言少钱 径须沽取对君酌 +zhu ren ri zai shi 主人日再食 无鲜肥滋味之享 +zhu ren wang gui ke bu fa 主人忘归客不发 寻声暗问弹者谁 +zhu ren xia ma ke zai chuan 主人下马客在船 举酒欲饮无管弦 +zhu ren you jiu huan jin xi 主人有酒欢今夕 请奏鸣琴广陵客 +zhu shang meng chen 主上蒙尘 孤不度德量力 +zhu wai tao hua san liang zhi 竹外桃花三两枝 春江水暖鸭先知 +zhu wo jiu shi shang 著我旧时裳 当窗理云鬓 +zhu xian yi wei jia ren jue 朱弦已为佳人绝 青眼聊因美酒横 +zhu xuan gui huan nv 竹喧归浣女 莲动下渔舟 +zhu ya yao jie wen chen shen 珠压腰衱稳称身 就中云幕椒房亲 +zhu zhang wu shi ye kou men 拄杖无时夜叩门 +zhu zhong kui luo ri 竹中窥落日 鸟向檐上飞 +zhuan de xing ren cuo xi huan 赚得行人错喜欢 正入万山圈子里 +zhuan jiao xiao yu bao shuang cheng 转教小玉报双成 闻道汉家天子使 +zhuan zhou bo xian san liang sheng 转轴拨弦三两声 未成曲调先有情 +zhuang ba di sheng wen fu xu 妆罢低声问夫婿 画眉深浅入时无 +zhuang cheng mei bei qiu niang du 妆成每被秋娘妒 五陵年少争缠头 +zhuang sheng xiao meng mi hu die 庄生晓梦迷蝴蝶 望帝春心托杜鹃 +zhuang shi shi nian gui 壮士十年归 归来见天子 +zhuang xin bu yi 壮心不已 盈缩之期 +zhuang you suo yong 壮有所用 幼有所长 +zhuang zi wang jian zhi 庄子往见之 或谓惠子曰 +zhuo jiu yi bei jia wan li 浊酒一杯家万里 燕然未勒归无计 +zhuo lang pai kong 浊浪排空 日星隐曜 +zhuo qing lian er bu yao 濯清涟而不妖 中通外直 +zhuo tan quan er jue shuang 酌贪泉而觉爽 处涸辙以犹欢 +zhuo zu dong ting wang ba huang 濯足洞庭望八荒 鸿飞冥冥日月白 +zi chen yi dang shi zhi shi 咨臣以当世之事 由是感激 +zi cong qi zhi bian shuai xiu 自从弃置便衰朽 世事蹉跎成白首 +zi dian qing shuang 紫电青霜 王将军之武库 +zi dong zhuo yi lai 自董卓已来 豪杰并起 +zi er wei jia jie 自尔为佳节 谁知林栖者 +zi fan qing ji zhi 子犯请击之 公曰 +zi fei ting wu ye fen 自非亭午夜分 不见曦月 +zi fu yang zhi tong lu yi bai xu li 自富阳至桐庐一百许里 奇山异水 +zi gai lian yan jie tian zhu 紫盖连延接天柱 石廪腾掷堆祝融 +zi gong wen yue 子贡问曰 有一言而可以终身行之者乎 +zi gu feng qiu bei ji liao 自古逢秋悲寂寥 我言秋日胜春朝 +zi gui sheng li yu ru yan 子规声里雨如烟 乡村四月闲人少 +zi ji wang yi zhi shi ba ri wei sheng 自既望以至十八日为盛 方其远出海门 +zi jiang mo xi ren qian chao 自将磨洗认前朝 东风不与周郎便 +zi jing sang luan shao shui mian 自经丧乱少睡眠 长夜沾湿何由彻 +zi jue jian shu xi 自觉谏书稀 +zi kang le yi lai 自康乐以来 未复有能与其奇者 +zi li tang lai 自李唐来 世人甚爱牡丹 +zi mei di xiong jie lie tu 姊妹弟兄皆列土 可怜光彩生门户 +zi mo zi jian wang 子墨子见王 曰 +zi mo zi jie dai wei cheng 子墨子解带为城 以牒为械 +zi mo zi jiu ju zhi 子墨子九距之 公输盘之攻械尽 +zi mo zi qi 子墨子起 再拜 +zi mo zi wen zhi 子墨子闻之 起于鲁 +zi mo zi yi yue 子墨子亦曰 吾知子之所以距我 +zi mo zi yue 子墨子曰 北方有侮臣者 +zi mo zi zhi shou yu you yu 子墨子之守圉有余 公输盘诎 +zi qi bu bian zhe er guan zhi 自其不变者而观之 则物与我皆无尽也 +zi quan gong dian suo yan xia 紫泉宫殿锁烟霞 欲取芜城作帝家 +zi san xia qi bai li zhong 自三峡七百里中 两岸连山 +zi shi zhi wu zuo shi li jiu 自是指物作诗立就 其文理皆有可观者 +zi tuo zhi feng chu cui fu 紫驼之峰出翠釜 水精之盘行素鳞 +zi wei jing guo jiu bu mi 自谓经过旧不迷 安知峰壑今来变 +zi xu ji qi wu jiang shang 子胥既弃吴江上 屈原终投湘水滨 +zi yan ben shi jing cheng nv 自言本是京城女 家在虾蟆陵下住 +zi yi wei da you suo yi 自以为大有所益 蒙乃始就学 +zi yi you bu li yan 子亦有不利焉 许之 +zi you sheng sun 子又生孙 孙又生子 +zi you sui han xin 自有岁寒心 可以荐嘉客 +zi you you sun 子又有孙 子子孙孙无穷匮也 +zi you you zi 子又有子 子又有孙 +zi yu wei lu ren 自余为僇人 居是州 +zi yuan er zhi 自远而至 至无所见 +zi yun liang jia zi 自云良家子 零落依草木 +zi yun xian shi bi qin shi luan 自云先世避秦时乱 率妻子邑人来此绝境 +zi zai jiao ying qia qia ti 自在娇莺恰恰啼 +zi ze qiu xu 梓泽丘墟 临别赠言 +zi zhi zhi hu 子知之乎 夫鹓鶵发于南海 +zi zi sun sun wu qiong kui ye 子子孙孙无穷匮也 而山不加增 +zong ba xin tao huan jiu fu 总把新桃换旧符 +zong chen yi xiang su qing gao 宗臣遗像肃清高 三分割据纡筹策 +zong ci shi si 总此十思 宏兹九德 +zong lan ying xiong 总揽英雄 思贤如渴 +zong shi yu guan qing 总是玉关情 何日平胡虏 +zong wei fu yun neng bi ri 总为浮云能蔽日 长安不见使人愁 +zong yi wei zhi suo ru 纵一苇之所如 凌万顷之茫然 +zong you jian fu ba chu li 纵有健妇把锄犁 禾生陇亩无东西 +zou liu shui yi he can 奏流水以何惭 呜乎 +zou ma lan tai lei zhuan peng 走马兰台类转蓬 +zu fu jun lie er bu zhi 足肤皲裂而不知 至舍 +zu huo you suo wen 卒获有所闻 当余之从师也 +zu shui cong he chu 租税从何出 信知生男恶 +zu yi ji shi ting zhi yu 足以极视听之娱 信可乐也 +zu zheng shu tu qi 足蒸暑土气 背灼炎天光 +zu zhi wei zhong ren 卒之为众人 则其受于人者不至也 +zui ai hu dong xing bu zu 最爱湖东行不足 绿杨阴里白沙堤 +zui bu cheng huan can jiang bie 醉不成欢惨将别 别时茫茫江浸月 +zui cong da pi jie chu si 罪从大辟皆除死 迁者追回流者还 +zui hou ge fen san 醉后各分散 永结无情游 +zui li tiao deng kan jian 醉里挑灯看剑 梦回吹角连营 +zui li wu yin xiang mei hao 醉里吴音相媚好 白发谁家翁媪 +zui nan jiang xi 最难将息 三杯两盏淡酒 +zui neng tong qi le 醉能同其乐 醒能述以文者 +zui shi cheng huang ju lv shi 最是橙黄橘绿时 +zui shi chu gong ju min mie 最是楚宫俱泯灭 舟人指点到今疑 +zui shi yi nian chun hao chu 最是一年春好处 绝胜烟柳满皇都 +zui weng ting ye 醉翁亭也 作亭者谁 +zui weng zhi yi bu zai jiu 醉翁之意不在酒 在乎山水之间也 +zui wo bu zhi bai ri mu 醉卧不知白日暮 有时空望孤云高 +zui wo sha chang jun mo xiao 醉卧沙场君莫笑 古来征战几人回 +zui xi xiao er wang lai 最喜小儿亡赖 溪头卧剥莲蓬 +zui ze geng xiang zhen yi wo 醉则更相枕以卧 卧而梦 +zun jiu jia pin zhi jiu pei 樽酒家贫只旧醅 肯与邻翁相对饮 +zun jiu wei li yan 樽酒慰离颜 +zun jun zai fou 尊君在不 答曰 +zuo bi gua nian zhu yi zhi 左臂挂念珠倚之 珠可历历数也 +zuo bi xuan bi kuang 左臂悬敝筐 听其相顾言 +zuo chou hong yan lao 坐愁红颜老 早晚下三巴 +zuo duan dong nan zhan wei xiu 坐断东南战未休 天下英雄谁敌手 +zuo guan chui diao zhe 坐观垂钓者 徒有羡鱼情 +zuo jiang jun wang ning zhi qi ye 左将军王凝之妻也 +zuo kan hong shu bu zhi yuan 坐看红树不知远 行尽青溪不见人 +zuo kan yun qi shi 坐看云起时 偶然值林叟 +zuo qing yun bai he guan 作青云白鹤观 果如鹤唳云端 +zuo ri zhi ri bu ke liu 昨日之日不可留 乱我心者 +zuo shou fu lu 左手抚炉 炉上有壶 +zuo shou fu lu zhi bei 左手抚鲁直背 鲁直左手执卷末 +zuo shou yi yi heng mu 左手倚一衡木 右手攀右趾 +zuo ting zhe shui 作亭者谁 山之僧智仙也 +zuo wo xi ge chuang 坐我西阁床 脱我战时袍 +zuo yan ge xing yi shi shi 作燕歌行以示适 感征戍之事 +zuo ye dong feng chui xue xing 昨夜东风吹血腥 东来橐驼满旧都 +zuo ye feng kai lu jing tao 昨夜风开露井桃 未央前殿月轮高 +zuo ye jian jun tie 昨夜见军帖 可汗大点兵 +zuo ye qun dai jie 昨夜裙带解 今朝蟢子飞 +zuo ye xi feng diao bi shu 昨夜西风凋碧树 独上高楼 +zuo ye xing chen zuo ye feng 昨夜星辰昨夜风 画楼西畔桂堂东 +zuo you cai zhi 左右采之 窈窕淑女 +zuo you ge si 左右各四 共八扇 +zuo you liu zhi 左右流之 窈窕淑女 +zuo you mao zhi 左右芼之 窈窕淑女 +zuo yu gu ren qi 昨与故人期 +zuo ze fei sha zou li 作则飞沙走砾 局促一室之内 +zuo zhe zhou qian chui da gu 昨者州前捶大鼓 嗣皇继圣登夔皋 +zuo zhong qi xia shui zui duo 座中泣下谁最多 江州司马青衫湿 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index d0687a2c..f6f7dc1f 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -27,6 +27,7 @@ import llc.slacker.openime.candidate.EmojiAssociationIndex import llc.slacker.openime.candidate.EnglishLexicon import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.PinyinLexicon +import llc.slacker.openime.candidate.PoemIndex import llc.slacker.openime.engine.PinyinEngine import llc.slacker.openime.candidate.FuzzyPinyin import llc.slacker.openime.candidate.Stroke @@ -220,6 +221,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ), pinyinEngine = PinyinEngine.obtain(this), fuzzyRules = { ImeSettingsRepository.activeFuzzyRules(this) }, + poemIndex = { activePoemIndex() }, ) } .onSuccess { candidatePipeline = it } @@ -230,6 +232,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can runCatching { EmojiCatalog.byCategory(this) } // The association table is the reference's full one now; parse it here, not on the first commit. runCatching { EmojiAssociationIndex.preload(this) } + // 诗句联想 reads on the first keystroke otherwise. + runCatching { PoemIndex.preload(this) } // Only someone who uses the 笔画 keyboard pays for its table; the // keyboard loads it on first use otherwise. if (ImeSettingsRepository.loadPreferredChineseMode(this) == KeyboardMode.STROKE) { @@ -1400,6 +1404,13 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can EmojiAssociationIndex.obtain(this) } + /** + * 诗句联想 shares the 智能联想 switch, as it does in the reference, whose own + * text for that switch is 「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」 + */ + private fun activePoemIndex(): PoemIndex = + if (ImeSettingsRepository.loadEmojiAssociation(this)) PoemIndex.obtain(this) else PoemIndex.EMPTY + /** Next-word suggestions, with the emoji that go with the committed text first. */ private fun associationsAfterCommit(committed: String): List { val words = candidatePipeline.associationsFor(committed) diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt index cdd811fb..5ac76fc1 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -70,6 +70,8 @@ class CandidatePipeline internal constructor( private val pinyinEngine: PinyinEngine? = null, /** 当前启用的模糊音规则(关掉时为空集),引擎按规则展开候选。 */ private val fuzzyRules: () -> Set = { emptySet() }, + /** 诗句联想;关掉联想时为空索引(见 [PoemIndex])。 */ + private val poemIndex: () -> PoemIndex = { PoemIndex.EMPTY }, ) : CandidateResolver { /** True when the 26-key pinyin flow is served by the built-in engine. */ @@ -103,11 +105,14 @@ class CandidatePipeline internal constructor( fuzzy: Boolean, ): List = when (mode) { KeyboardMode.PINYIN_26 -> if (pinyinEngine == null) { - pinyin26Candidates(composition, fuzzy) + withPoemLines(pinyin26Candidates(composition, fuzzy), composition) } else { // The built-in engine serves full pinyin and honours the enabled // fuzzy switches itself; exact readings rank above fuzzy ones. - pinyinEngine.candidates(composition, fuzzyRules = fuzzyRules()) + withPoemLines( + pinyinEngine.candidates(composition, fuzzyRules = fuzzyRules()), + composition, + ) } KeyboardMode.ENGLISH_26 -> englishCandidates(composition) KeyboardMode.PINYIN_9 -> if (composition.length <= 32) { @@ -123,6 +128,18 @@ class CandidatePipeline internal constructor( KeyboardMode.DIGITS -> emptyList() } + /** + * The poetry lines the composition spells, behind the engine's own + * candidates: a line never displaces a word, and a composition that + * happens to be a line often has no candidate at all, which is exactly + * where the line belongs. + */ + private fun withPoemLines(candidates: List, composition: String): List { + val poems = poemIndex().suggestionsFor(composition) + if (poems.isEmpty()) return candidates + return (candidates + poems).distinct().take(MAX_CANDIDATES + PoemIndex.MAX_SUGGESTIONS) + } + private fun pinyin26Candidates( composition: String, fuzzy: Boolean, diff --git a/app/src/main/java/llc/slacker/openime/candidate/PoemIndex.kt b/app/src/main/java/llc/slacker/openime/candidate/PoemIndex.kt new file mode 100644 index 00000000..86998a12 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/candidate/PoemIndex.kt @@ -0,0 +1,133 @@ +package llc.slacker.openime.candidate + +import android.content.Context + +/** + * Classical lines that go with what is being typed ("联想推荐诗词"). + * + * The reference recommends poetry lines from a bundled grammar dictionary — + * its own settings text names them among what 联想 recommends + * (`关闭联想后,将不会推荐诗词、计算结果、emoji表情。`) — and the lines are keyed + * by the pinyin they are typed with. This index does the same: as soon as the + * composition spells [MIN_SYLLABLES] syllables of a line, the line is offered, + * and a line the user spelled out completely also offers the line that follows + * it in its own poem (`春眠不觉晓` -> `处处闻啼鸟`). + * + * Three syllables is the measured floor: of the 3,000 most frequent pinyin + * keys of the merged tables, 380 (12.7%) are a two-syllable prefix of some + * line and only 3 (0.1%) are a three-syllable one, and a line is worth + * recommending only when it is what the user is typing and not what they are + * about to type anyway. + */ +class PoemIndex internal constructor(private val lines: List) { + + /** One line of the table: its pinyin, where its syllables end, its text. */ + internal data class PoemLine( + /** The line's pinyin, syllables joined: `chunmianbujuexiao`. */ + val letters: String, + /** Letter count at the end of each syllable, ascending. */ + val ends: IntArray, + val text: String, + /** The next line of the same poem; empty when the table carries none. */ + val follow: String, + ) + + /** The lines the table carries. */ + val size: Int get() = lines.size + + /** + * Up to [limit] lines for [composition], the line the user spelled out + * first. Empty while the composition is shorter than three syllables, cuts + * a syllable open, or matches nothing. + */ + fun suggestionsFor(composition: String, limit: Int = MAX_SUGGESTIONS): List { + if (lines.isEmpty() || limit <= 0) return emptyList() + val typed = composition.lowercase().filter { it in 'a'..'z' } + if (typed.isEmpty()) return emptyList() + + val complete = mutableListOf() + val prefixes = mutableListOf() + for (line in lines) { + if (typed.length > line.letters.length) continue + if (!line.letters.startsWith(typed)) continue + if (syllablesSpelled(line.ends, typed.length) < MIN_SYLLABLES) continue + if (typed.length == line.letters.length) complete += line else prefixes += line + } + + val suggestions = LinkedHashSet() + complete.sortedBy { it.text }.forEach { line -> + suggestions += line.text + if (line.follow.isNotEmpty()) suggestions += line.follow + } + prefixes.sortedBy { it.text }.forEach { suggestions += it.text } + return suggestions.take(limit) + } + + /** Syllables [length] letters of a line spell, or 0 when they cut one open. */ + private fun syllablesSpelled(ends: IntArray, length: Int): Int { + var spelled = 0 + for (end in ends) { + if (end == length) return spelled + 1 + if (end > length) return 0 + spelled++ + } + return 0 + } + + companion object { + /** `春风` is every day's typing; three syllables is a line worth offering. */ + const val MIN_SYLLABLES = 3 + const val MAX_SUGGESTIONS = 2 + private const val ASSET = "poem/lines.tsv" + + val EMPTY = PoemIndex(emptyList()) + + /** + * Lines are `pinyin syllableslinenext line`; the third column + * is empty when the table has no following line, and `#` starts a + * comment. + */ + fun parse(lines: Sequence): PoemIndex { + val parsed = ArrayList() + lines.forEach { raw -> + val line = raw.trimEnd() + if (line.isEmpty() || line.startsWith("#")) return@forEach + val parts = line.split('\t') + if (parts.size < 2) return@forEach + val syllables = parts[0].trim().split(' ').filter { it.isNotEmpty() } + val text = parts[1].trim() + val follow = parts.getOrNull(2)?.trim().orEmpty() + if (syllables.isEmpty() || text.isEmpty()) return@forEach + val ends = IntArray(syllables.size) + var letters = 0 + syllables.forEachIndexed { index, syllable -> + letters += syllable.length + ends[index] = letters + } + parsed += PoemLine(syllables.joinToString(""), ends, text, follow) + } + return PoemIndex(parsed) + } + + fun load(context: Context): PoemIndex = + runCatching { + parse( + context.assets.open(ASSET).bufferedReader(Charsets.UTF_8) + .use { it.readLines() } + .asSequence(), + ) + }.getOrDefault(EMPTY) + + @Volatile + private var cached: PoemIndex? = null + + /** The parsed table, built once; [preload] fills this off the first keystroke. */ + fun obtain(context: Context): PoemIndex = + cached ?: synchronized(this) { cached ?: load(context).also { cached = it } } + + /** Read the table on a background thread so the first keystroke does not. */ + fun preload(context: Context) { + obtain(context) + } + } +} diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt index 11b118d3..af832c7d 100644 --- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt @@ -4,6 +4,7 @@ import llc.slacker.openime.candidate.CandidateEngine import llc.slacker.openime.candidate.CandidatePipeline import llc.slacker.openime.candidate.NineKeyLocalDecoder import llc.slacker.openime.candidate.NineKeyPresets +import llc.slacker.openime.candidate.PoemIndex import llc.slacker.openime.core.KeyboardMode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -252,4 +253,34 @@ class CandidatePipelineTest { assertEquals(listOf("你好", "你敢好"), pipeline.preferExactNineKeyMatches("9", listOf("你好", "你敢好"))) assertEquals(listOf("a", "b"), pipeline.preferExactNineKeyMatches(null, listOf("a", "b"))) } + + @Test + fun poemLinesComeBehindTheEnginesOwnCandidates() { + val poems = PoemIndex.parse( + sequenceOf("chun mian bu jue xiao\t春眠不觉晓\t处处闻啼鸟"), + ) + val local = CandidateEngine(linkedMapOf("chunmianbu" to listOf("纯棉布"))) + val withPoems = CandidatePipeline(local, poemIndex = { poems }) + assertEquals( + listOf("纯棉布", "春眠不觉晓"), + withPoems.candidatesFor(KeyboardMode.PINYIN_26, "chunmianbu", false) + .filter { it in setOf("纯棉布", "春眠不觉晓") }, + ) + // A whole line also brings the line that follows it. + assertEquals( + listOf("春眠不觉晓", "处处闻啼鸟"), + withPoems.candidatesFor(KeyboardMode.PINYIN_26, "chunmianbujuexiao", false) + .filter { it in setOf("春眠不觉晓", "处处闻啼鸟") }, + ) + // A composition no line spells is the engine's own, poem index or not. + assertEquals( + CandidatePipeline(local).candidatesFor(KeyboardMode.PINYIN_26, "nihao", false), + withPoems.candidatesFor(KeyboardMode.PINYIN_26, "nihao", false), + ) + // Nine-key speaks digits; the poem lookup is a pinyin one. + assertTrue( + withPoems.candidatesFor(KeyboardMode.PINYIN_9, "chunmianbujuexiao", false) + .none { it == "春眠不觉晓" }, + ) + } } diff --git a/app/src/test/java/llc/slacker/openime/PoemIndexTest.kt b/app/src/test/java/llc/slacker/openime/PoemIndexTest.kt new file mode 100644 index 00000000..9b4a3ece --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/PoemIndexTest.kt @@ -0,0 +1,76 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.PoemIndex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PoemIndexTest { + + private val index = PoemIndex.parse( + sequenceOf( + "# Lines of the reference's poetry dictionary.", + "chun mian bu jue xiao\t春眠不觉晓\t处处闻啼鸟", + "chun feng bu xiang shi\t春风不相识\t", + "luo ri gu ren qing\t落日故人情\t挥手自兹去", + "hai shang sheng ming yue\t海上生明月\t天涯共此时", + ), + ) + + /** 春眠不觉晓 spelled out: the whole line, then the line that follows it. */ + @Test + fun aLineSpelledOutOffersItsOwnNextLine() { + assertEquals( + listOf("春眠不觉晓", "处处闻啼鸟"), + index.suggestionsFor("chunmianbujuexiao"), + ) + } + + /** A half-typed line offers the line itself; only its own follow needs the whole line. */ + @Test + fun threeSyllablesAreEnoughToOfferTheLine() { + assertEquals(listOf("春眠不觉晓"), index.suggestionsFor("chunmianbu")) + assertEquals(listOf("春风不相识"), index.suggestionsFor("chunfengbu")) + assertEquals(listOf("海上生明月"), index.suggestionsFor("haishangsheng")) + } + + /** `春风` is every day's typing; two syllables would offer a line all day. */ + @Test + fun twoSyllablesAreNotARecommendation() { + assertTrue(index.suggestionsFor("chunfeng").isEmpty()) + assertTrue(index.suggestionsFor("chunmian").isEmpty()) + } + + @Test + fun aHalfTypedSyllableMatchesNothing() { + assertTrue(index.suggestionsFor("chunmi").isEmpty()) + assertTrue(index.suggestionsFor("chunmianbuj").isEmpty()) + } + + @Test + fun caseAndApostrophesAreIgnored() { + assertEquals( + listOf("春眠不觉晓", "处处闻啼鸟"), + index.suggestionsFor("Chun'mian'bu'jue'xiao"), + ) + assertEquals(listOf("春风不相识"), index.suggestionsFor("CHUNFENGBU")) + } + + @Test + fun theMostSpecificLineComesFirst() { + // 落日故人情 is offered by its own three syllables too, but the line the + // user spelled out whole ranks first. + assertEquals( + listOf("落日故人情", "挥手自兹去"), + index.suggestionsFor("luorigurenqing"), + ) + } + + @Test + fun anEmptyOrUnrelatedCompositionOffersNothing() { + assertTrue(index.suggestionsFor("").isEmpty()) + assertTrue(index.suggestionsFor(" ").isEmpty()) + assertTrue(index.suggestionsFor("nihao").isEmpty()) + assertTrue(PoemIndex.EMPTY.suggestionsFor("chunmianbujuexiao").isEmpty()) + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1b798ccc..296b39ae 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -368,3 +368,7 @@ Add new entries at the end. - **A clipboard entry takes a long press into 拆词, and a slide picks the words to keep.** The reference names all three parts of this feature itself. The clipboard list carries the tip 「长按列表可进入拆词」 (`resources.arsc`, `string/clipboard_history_word_seg_tip`, `0x7f1100fa`), the view it opens is titled 「滑动选词」 (`string/word_segmentation_title`, `0x7f110567`) and holds one action, 「添加到常用语」 (`string/word_segmentation_add_to_common_phrase`, `0x7f110566`). Its colour set separates the two kinds of label and gives that action a disabled face — `ime_word_segmentation_item_bg_color`, `ime_word_segmentation_item_selected_bg_color`, `ime_word_segmentation_item_selected_text_color`, `ime_word_segmentation_label_clickable_bg_color`, `ime_word_segmentation_label_un_clickable_bg_color`, `word_segmentation_add_to_common_phrase_bg_disabled` — and its engine carries the subsystem (mangled strings of `libkeyboard.so`: `WordSegmentationByIndex`, `WordSegmentationData`, `WordSegmentationDetailData`, `WordSegmentationHandlerD`). This port had nothing behind a long press on a clipboard card, so a copied paragraph could only be inserted whole. `HanWordSegmenter` now splits an entry with the longest word the tables carry at each position — they hold words of up to eight characters, 依法追究刑事责任 being the longest row of `pinyin_phrases.tsv` — the words come from the text side of the same tables (`TextWordIndex` over `PinyinLexicon`), and `ClipboardPanelController` selects a run by sliding across the words and files it into 常用语 (`QuickPhraseRepository`). *Reason:* the gesture and its destination are the reference's own words to the user, and the split is what makes a long copied text useful — copying a paragraph and wanting one phrase out of it is the case this exists for. Three details follow the reference's own evidence rather than an invention: the run is a slide, not a tap per word, and it stays selected when the finger lifts so the action can be taken after (「滑动选词」); a separator cannot be selected, because the label colours have a clickable and an un-clickable face and punctuation is not a word; and the action is disabled until something is selected, which is what its disabled background exists for. The words are our panels' own rows in a wrapped list (`WordFlowLayout`) rather than the reference's item grid, and the word set is the tables this port ships rather than a segmentation model — the reference's segmentation runs in its native code, and no asset of its APK is named for a segmentation table (`assets/dict/*` carries none). The count limit the reference states (`common_phrase_count_over_toast` 「常用语数量已达上限」) is not ported: this port's 常用语 have no cap to report. The word index is a new `text` package (it needs the lexicon, so the layer map places it above `candidate`), built once on the panel's own thread next to the clipboard read that asked for it. + +- **The candidate row recommends the poem line the composition spells.** + The reference ships a poetry grammar — `assets/dict/8ce02a28dedf`, type 11010 `sys_poem_gram`, whose loader is `FUN_003f1258` — and the app decodes it end to end: the data section walks all 351,270 bytes with no residue as LINE records `[u32 line_id][u16 0x0190][u16 nchar][u16 chars ×nchar/2][u16 nsyl][u16 syls ×nsyl/2]` followed by association groups `[u16 glen][ENTRY ×k]`, an ENTRY being `[u32 ref_id ×1 or 2][u16 punctuation ×2 or 3][u16 0x0190]` whose reference is the line that follows in the same poem (`doubaoime-teardown/DECODE-BATCH-D.md` §4, `poem-decoded.tsv` 5,966 lines, `poem-assoc.tsv` 10,542 groups / 10,687 entries, the samples `春眠不觉晓` → `处处闻啼鸟`, `落日故人情` → `挥手自兹去`). Its own settings text puts poetry among what 联想 recommends — 「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」 (`resources.arsc`, next to the switch 「智能联想」 this port already has) — and the dictionary's index is `53,921 × 9B` of `(u8 key, u32 child, u32 data_off)` whose leaves all point at line starts, i.e. a trie over the letters the lines are typed with, not over their characters. This port had no poetry at all. `scripts/import_doubao_data.py --poems-only` now generates `poem/lines.tsv` from those two decodes — 5,498 lines of four Han characters or more, each with its syllable sequence and the line that follows it (5,042 of them) — and `PoemIndex` answers `CandidatePipeline.candidatesFor` for 26-key compositions, behind the engine's own candidates (`withPoemLines`). + *Reason:* the reference recommends a line while its pinyin is being typed, which is a candidate, not a post-commit chip — this port's association row exists only after a commit by its own contract (`ImeKeyboardView.Listener.onAssociationSelected`), so the line ships as a candidate the same way the engine's words do. Three syllables is the floor, measured against this port's own tables rather than guessed: of the 3,000 most frequent pinyin keys, 380 (12.7%) are a two-syllable prefix of some line and 3 (0.1%) are a three-syllable one — `春风` would recommend a line all day, `chunfengbu` would not. The line the composition spells out completely ranks before the lines it merely starts and brings its own next line with it, and the whole layer follows the 智能联想 switch, which is the reference's own grouping. Three parts of the feature are deliberately not ported: the reference's 计算结果 has no counterpart here (this port has no expression candidates), the lines shorter than four characters are dropped (42 single-character and 107 two-character entries are ordinary typing, not poetry), and nine-key is left out — its composition is digits, and the poem lookup is a pinyin one. The association punctuation (the 2 or 3 marks per entry) is written out but unused: the reference's decoder keeps the field, and nothing in the data says which mark joins which pair. diff --git a/scripts/import_doubao_data.py b/scripts/import_doubao_data.py index 55538c7f..f8444d3e 100644 --- a/scripts/import_doubao_data.py +++ b/scripts/import_doubao_data.py @@ -47,6 +47,12 @@ differences are written: the table's other words already carry the right reading. The app loads this small file next to ``pinyin_phrases.tsv``, so a correction never has to wait for the large local merge. +10. ``poem/lines.tsv`` + Lines of the reference's poetry dictionary (its ``sys_poem_gram`` table, + whose own UI text is 「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」), + one row per line (pinyin syllables / line / the line that follows it in + its poem). Lines shorter than four characters are dropped, and the app + only offers a line once the typed pinyin spells three of its syllables. Originals are backed up once to ``.local/doubao-import/originals/`` and a report is written to ``.local/doubao-import/report.json``. @@ -77,6 +83,9 @@ ENGLISH_WORD_RE = re.compile(r"^[A-Za-z][A-Za-z'\-]{0,31}$") MAX_SYLLABLES = 10 MAX_WEIGHT = 20_000_000 +# A poetry line shorter than this is not worth recommending: two or three +# characters are ordinary words, and every one of them would match. +POEM_MIN_CHARS = 4 def parse_rime(path: Path): @@ -486,6 +495,72 @@ def build_emoji_association_asset(src: Path, assets: Path, report): } +def build_poems(src: Path, assets: Path, report): + """Write poem/lines.tsv from the reference's poetry dictionary. + + ``poem-decoded.tsv`` carries every line of the reference's ``sys_poem_gram`` + table (5,966 lines) with the syllables it is typed by, and + ``poem-assoc.tsv`` the line that follows it in its own poem. The app + offers a line as a candidate as soon as the typed pinyin spells three of + its syllables, so only lines that read cleanly are useful: four Han + characters or more, one syllable per character, no punctuation inside. + + The following line is written next to its own line so a whole line the + user typed out can be continued (`春眠不觉晓` -> `处处闻啼鸟`). + """ + decoded, assoc = src / "poem-decoded.tsv", src / "poem-assoc.tsv" + if not decoded.is_file(): + report["poems"] = {"skipped": "poem-decoded.tsv not found"} + return + + texts, rows, by_id = {}, {}, {} + with io.open(decoded, encoding="utf-8") as f: + next(f) + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 8: + continue + _, _, line_id, nchar, nsyl, _, syllables, text = parts[:8] + syllables = syllables.strip('"').split() + texts[int(line_id)] = text + if (int(nchar) < POEM_MIN_CHARS or len(syllables) != int(nchar) + or not all(s.isalpha() for s in syllables) or not HAN_RE.match(text)): + continue + key = (tuple(syllables), text) + rows[key] = "" + by_id[int(line_id)] = key + + if assoc.is_file(): + with io.open(assoc, encoding="utf-8") as f: + next(f) + for line in f: + parts = line.rstrip("\n").split("\t") + if len(parts) < 8 or parts[4] != "0": + continue # one entry per group is enough: the nearest line + key = by_id.get(int(parts[2])) + refs = [int(x) for x in parts[6].split()] + follow = texts.get(refs[0]) if refs else None + if key is not None and follow and HAN_RE.match(follow): + rows[key] = follow + + if not rows: + report["poems"] = {"written": 0} + return + out = assets / "poem" / "lines.tsv" + out.parent.mkdir(parents=True, exist_ok=True) + tmp = out.with_suffix(out.suffix + ".tmp") + with io.open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write("# Lines of the reference's poetry dictionary (sys_poem_gram), the\n") + f.write("# syllables they are typed by, and the line that follows each one.\n") + f.write("# Generated by scripts/import_doubao_data.py.\n") + for (syllables, text), follow in sorted(rows.items()): + f.write("%s\t%s\t%s\n" % (" ".join(syllables), text, follow)) + os.replace(tmp, out) + report["poems"] = {"lines": len(rows), "continued": sum(1 for v in rows.values() if v)} + print("poems: %d lines written (%d with a following line)" + % (len(rows), sum(1 for v in rows.values() if v))) + + def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--repo", default=str(Path(__file__).resolve().parent.parent)) @@ -495,6 +570,8 @@ def main(): ap.add_argument("--dry-run", action="store_true") ap.add_argument("--reading-fixes-only", action="store_true", help="write pinyin_reading_fixes.tsv and stop, without rewriting the tables") + ap.add_argument("--poems-only", action="store_true", + help="write poem/lines.tsv and stop, without rewriting the tables") args = ap.parse_args() repo = Path(args.repo) @@ -540,6 +617,14 @@ def main(): json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") return + print("[1c/5] write poem lines ...") + if not args.dry_run: + build_poems(src, assets, report) + if args.poems_only: + (repo / ".local" / "doubao-import" / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return + print("[2/5] read Doubao lexicon ...") doubao = [] # (text, pinyin, score) with io.open(lexicon_path, encoding="utf-8") as f: From 86ebaabb42858992f609ba9b28aaf44a5f62f5e3 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:59:18 +0800 Subject: [PATCH 87/94] Port four more reference input behaviours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policies and routing for the 快捷格式输入 batch, whose settings rows and service wiring follow in a separate commit (another session is editing the same files). 数字、英文前后加空格: LetterDigitSpacePolicy (core) and a letterDigitSpace hook in InputConnectionGateway.commitText, the single funnel every commit goes through. The reference's own title and subtitle are the rule (string/letter_digit_space_list_item_title/subtitle, res/xq.xml): a space between Chinese and digits/English, on typing and on voice alike. 双击空格键输入句号: DoubleSpacePeriodPolicy keeps the reference's 200 ms window (InputViewRoot$z.invoke, classes.dex code_off 0x4c1764, const-wide/16 0xc8) and its 31-character punctuation gate. 算式出结果: ArithmeticExpressionPolicy evaluates the expression that ends at the cursor. The reference computes it natively (libkeyboard.so InputModel::Impl::AssociateCalculator 0x26523c, GetCalculatorCandidate 0x255ca4, CommitCalculatorCand 0x264594) and its 智能联想 text lists 计算结果 among what that switch offers (string/associate_close_content). 使用耳机麦克风录音: VoiceInputRoutePolicy decides between a headset and the phone's own microphone, as the reference's enable_bluetooth_input does (string/enable_bluetooth_input_show, res/cU.xml defaultValue=false); VoiceAudioRouteManager and LocalAudioVoiceBackend read the switch per session. Unit tests for all four policies and the gateway spacing, plus an instrumented test for the service-side double space. Co-Authored-By: Claude Code --- ...oubleSpacePeriodServiceInstrumentedTest.kt | 162 +++++++++++++++++ .../core/ArithmeticExpressionPolicy.kt | 166 ++++++++++++++++++ .../openime/core/LetterDigitSpacePolicy.kt | 47 +++++ .../openime/editor/InputConnectionGateway.kt | 32 +++- .../keyboard/DoubleSpacePeriodPolicy.kt | 43 +++++ .../openime/voice/LocalAudioVoiceBackend.kt | 6 +- .../openime/voice/VoiceAudioRouteManager.kt | 25 +-- .../openime/voice/VoiceInputRoutePolicy.kt | 41 +++++ .../openime/ArithmeticExpressionPolicyTest.kt | 74 ++++++++ .../openime/DoubleSpacePeriodPolicyTest.kt | 56 ++++++ .../openime/InputConnectionGatewayTest.kt | 48 +++++ .../openime/LetterDigitSpacePolicyTest.kt | 53 ++++++ .../openime/VoiceInputRoutePolicyTest.kt | 62 +++++++ 13 files changed, 796 insertions(+), 19 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/core/ArithmeticExpressionPolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/core/LetterDigitSpacePolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/DoubleSpacePeriodPolicy.kt create mode 100644 app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt create mode 100644 app/src/test/java/llc/slacker/openime/ArithmeticExpressionPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/DoubleSpacePeriodPolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/LetterDigitSpacePolicyTest.kt create mode 100644 app/src/test/java/llc/slacker/openime/VoiceInputRoutePolicyTest.kt diff --git a/app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt new file mode 100644 index 00000000..bab17227 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/DoubleSpacePeriodServiceInstrumentedTest.kt @@ -0,0 +1,162 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.text.InputType +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.CandidateQueryCoordinator +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.rime.RimeEngine +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 双击空格键输入句号在服务这一侧:200 毫秒内的第二个空格把刚写下的空格换成 + * 「。」,落在标点后面或太慢的第二个空格仍是空格,开关关着(参考自己的默认) + * 从不换。参考的处理器是 `InputViewRoot$z.invoke`(classes.dex code_off + * 0x4c1764),窗口 `const-wide/16 0xc8`。 + */ +@RunWith(AndroidJUnit4::class) +class DoubleSpacePeriodServiceInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @After + fun restoreTheSwitch() { + ImeSettingsRepository.saveDoubleSpacePeriod(context, false) + } + + private fun withService( + enabled: Boolean, + textBeforeCursor: String? = "好", + test: (LocalVoiceImeService, MutableList) -> Unit, + ) { + val events = mutableListOf() + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val connection = Proxy.newProxyInstance( + InputConnection::class.java.classLoader, + arrayOf(InputConnection::class.java), + ) { _, method, args -> + when (method.name) { + "getTextBeforeCursor" -> textBeforeCursor + "commitText" -> { + events += "commit:${args?.firstOrNull()?.toString().orEmpty()}" + true + } + "deleteSurroundingTextInCodePoints", "deleteSurroundingText" -> { + events += "delete" + true + } + "sendKeyEvent" -> { + events += "key" + true + } + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as InputConnection + + val service = LocalVoiceImeService() + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + + ImeSettingsRepository.saveDoubleSpacePeriod(context, enabled) + setField(service, "gateway", InputConnectionGateway(context, { connection })) + val rime = RimeEngine(context) + setField(service, "rime", rime) + setField( + service, + "candidateQueries", + CandidateQueryCoordinator( + rime = rime, + mainHandler = Handler(Looper.getMainLooper()), + fallbackCandidatesFor = { emptyList() }, + maxInputLength = 32, + maxNineKeyPaths = 8, + maxCandidates = 20, + ), + ) + setField( + service, + "state", + ImeState( + keyboardMode = KeyboardMode.ENGLISH_26, + editorInfo = EditorInfo().apply { inputType = InputType.TYPE_CLASS_TEXT }, + ), + ) + test(service, events) + } + } + + private fun setField(service: LocalVoiceImeService, name: String, value: Any) { + LocalVoiceImeService::class.java.getDeclaredField(name).apply { + isAccessible = true + set(service, value) + } + } + + private fun readField(service: LocalVoiceImeService, name: String): Any? = + LocalVoiceImeService::class.java.getDeclaredField(name).apply { isAccessible = true }.get(service) + + private fun ageTheWindow(service: LocalVoiceImeService, millis: Long) { + setField(service, "lastSpaceAtMs", SystemClock.uptimeMillis() - millis) + } + + @Test + fun aSecondSpaceInsideTheWindowWritesThePeriod() = withService(enabled = true) { service, events -> + service.onSpace() + assertEquals(listOf("commit: "), events) + service.onSpace() + assertEquals("删掉那个空格,再把句号写上去", listOf("commit: ", "delete", "commit:。"), events) + } + + @Test + fun offTheSecondSpaceIsJustASpace() = withService(enabled = false) { service, events -> + service.onSpace() + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun aSecondSpaceTooLateStaysASpace() = withService(enabled = true) { service, events -> + service.onSpace() + ageTheWindow(service, 500L) + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun aSpaceAfterPunctuationStaysASpace() = withService(enabled = true) { service, events -> + // The character before the first space is what the reference gates on. + service.onSpace() + setField(service, "lastCharBeforeSpace", ',') + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun anEditorThatCannotAnswerStaysASpace() = withService(enabled = true, textBeforeCursor = null) { service, events -> + service.onSpace() + service.onSpace() + assertEquals(listOf("commit: ", "commit: "), events) + } + + @Test + fun theFirstSpaceKeepsWhatWasBeforeIt() = withService(enabled = true, textBeforeCursor = "你") { service, _ -> + service.onSpace() + assertEquals('你', readField(service, "lastCharBeforeSpace")) + assertEquals(0L < (readField(service, "lastSpaceAtMs") as Long), true) + } +} diff --git a/app/src/main/java/llc/slacker/openime/core/ArithmeticExpressionPolicy.kt b/app/src/main/java/llc/slacker/openime/core/ArithmeticExpressionPolicy.kt new file mode 100644 index 00000000..2dc16075 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/ArithmeticExpressionPolicy.kt @@ -0,0 +1,166 @@ +package llc.slacker.openime.core + +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.abs +import kotlin.math.floor + +/** + * 算式出结果: the result of the arithmetic expression that ends at the cursor. + * + * The reference computes this natively — `keyboard::InputModel::Impl:: + * AssociateCalculator` (libkeyboard.so 0x26523c), `GetCalculatorCandidate` + * (0x255ca4) and `CommitCalculatorCand` (0x264594), whose `cand_length` + * argument replaces the expression with the result — and its 智能联想 switch + * names the result among what that switch offers (`string/associate_close_content` + * 「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」). Neither the + * reference's resources nor its native library holds a 「=」 display string, so + * the candidate text is the result alone. + */ +internal object ArithmeticExpressionPolicy { + + /** How far back the trailing expression is read; longer than any real sum. */ + const val MAX_LOOKBACK = 48 + + /** A result and the length of the expression it replaces, in UTF-16 units. */ + data class Result(val text: String, val expressionLength: Int) + + /** The characters an expression is made of, half- and full-width. */ + private const val EXPRESSION_CHARS = "0123456789.+-*/()×÷" + + private const val OPERATORS = "+-*/" + + /** The result for the expression ending at the end of [textBefore], or null. */ + fun evaluateTrailing(textBefore: CharSequence?): Result? { + if (textBefore == null) return null + var start = textBefore.length + while (start > 0 && textBefore[start - 1] in EXPRESSION_CHARS) start-- + if (start == textBefore.length) return null + val trailing = textBefore.subSequence(start, textBefore.length).toString() + val value = evaluate(trailing) ?: return null + return Result(value, trailing.length) + } + + /** + * The value of [expression] as the candidate shows it, or null when it is + * not a complete expression (no operator, an unfinished tail, unbalanced + * brackets, a division by zero) or its value has no useful text. + */ + fun evaluate(expression: String): String? { + val normalized = normalize(expression) + if (normalized.isEmpty() || normalized.none { it in OPERATORS }) return null + // Only a finished expression has a result to offer: the reference reads + // the text before the cursor, so a trailing operator is still being typed. + if (normalized.last() !in '0'..'9' && normalized.last() != ')') return null + val parser = Parser(normalized) + val value = parser.parseWhole() ?: return null + if (!value.isFinite()) return null + return format(value) + } + + /** Full-width digits and operators read as their ASCII forms. */ + private fun normalize(expression: String): String = buildString(expression.length) { + for (char in expression) { + append( + when (char) { + in '0'..'9' -> '0' + (char - '0') + '+' -> '+' + '-' -> '-' + '*' -> '*' + '/' -> '/' + // The reference's own keys print these two forms. + '×' -> '*' + '÷' -> '/' + '(' -> '(' + ')' -> ')' + '.' -> '.' + else -> char + }, + ) + } + } + + private fun format(value: Double): String { + if (value == 0.0) return "0" + if (abs(value) < 1e15 && value == floor(value)) return value.toLong().toString() + val rounded = BigDecimal(value).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros() + val text = rounded.toPlainString() + if (text.length <= 18) return text + return BigDecimal(value).setScale(2, RoundingMode.HALF_UP).toPlainString() + } + + /** + * A recursive-descent reader: expression = term (+|- term)*, + * term = factor (*|/ factor)*, factor = -factor | +factor | number | + * ( expression ). Null means the text is not a whole expression. + */ + private class Parser(private val text: String) { + private var index = 0 + + fun parseWhole(): Double? { + val value = parseExpression() ?: return null + return if (index == text.length) value else null + } + + private fun parseExpression(): Double? { + var value = parseTerm() ?: return null + while (true) { + val operator = peek() ?: break + if (operator != '+' && operator != '-') break + index++ + val right = parseTerm() ?: return null + value = if (operator == '+') value + right else value - right + } + return value + } + + private fun parseTerm(): Double? { + var value = parseFactor() ?: return null + while (true) { + val operator = peek() ?: break + if (operator != '*' && operator != '/') break + index++ + val right = parseFactor() ?: return null + if (operator == '/') { + if (right == 0.0) return null + value /= right + } else { + value *= right + } + } + return value + } + + private fun parseFactor(): Double? { + when (peek()) { + '-' -> { + index++ + return parseFactor()?.let { -it } + } + '+' -> { + index++ + return parseFactor() + } + '(' -> { + index++ + val inner = parseExpression() ?: return null + if (peek() != ')') return null + index++ + return inner + } + else -> return parseNumber() + } + } + + private fun parseNumber(): Double? { + val start = index + while (peek()?.let { it in '0'..'9' || it == '.' } == true) index++ + if (index == start) return null + val number = text.substring(start, index) + if (number.count { it == '.' } > 1 || number == ".") return null + return number.toDoubleOrNull() + } + + private fun peek(): Char? = text.getOrNull(index) + } +} diff --git a/app/src/main/java/llc/slacker/openime/core/LetterDigitSpacePolicy.kt b/app/src/main/java/llc/slacker/openime/core/LetterDigitSpacePolicy.kt new file mode 100644 index 00000000..1a35f8f6 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/LetterDigitSpacePolicy.kt @@ -0,0 +1,47 @@ +package llc.slacker.openime.core + +/** + * 数字、英文前后加空格: a space is inserted where Chinese meets digits or + * English, on either side of the boundary. + * + * The reference ships the switch as `enable_letter_digit_space` on its + * 快捷格式输入 page (`res/xq.xml`); its own label and summary are the whole + * rule — `string/letter_digit_space_list_item_title` 「数字、英文前后加空格」 + * and `string/letter_digit_space_list_item_subtitle` 「在语音与打字输入时,在 + * 中文和数字、英文间将增加空格」, i.e. voice input and typing alike. That row + * is the only trace of the switch in the reference: `res/cU.xml` holds no + * `enable_letter_digit_space` preference and the whole APK has no such string, + * so no default can be read from it — ours ships off. + */ +internal object LetterDigitSpacePolicy { + + /** + * Whether a space belongs between [left] (the text already at that side) + * and [right] (the text being joined to it). True exactly when one side is + * Chinese and the other is a Latin letter or an ASCII digit; anything else + * — including a space already there — is left as it is. + */ + fun boundaryNeedsSpace(left: Char?, right: Char?): Boolean { + if (left == null || right == null) return false + val leftChinese = isChinese(left) + val rightChinese = isChinese(right) + return when { + leftChinese && isDigitOrLatin(right) -> true + rightChinese && isDigitOrLatin(left) -> true + else -> false + } + } + + /** Han ideographs, the scripts the reference calls 中文. */ + fun isChinese(char: Char): Boolean { + val code = char.code + return code in 0x3400..0x4DBF || // CJK Unified Ideographs Extension A + code in 0x4E00..0x9FFF || // CJK Unified Ideographs + code in 0xF900..0xFAFF || // CJK Compatibility Ideographs + code in 0xD840..0xD87F // high surrogates of the Extension B..F planes + } + + /** ASCII digits and letters, the scripts the reference calls 数字、英文. */ + fun isDigitOrLatin(char: Char): Boolean = + char in '0'..'9' || char in 'a'..'z' || char in 'A'..'Z' +} diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index 142ebcff..4492ba65 100644 --- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -10,6 +10,7 @@ import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection import android.view.inputmethod.InputContentInfo import llc.slacker.openime.R +import llc.slacker.openime.core.LetterDigitSpacePolicy import llc.slacker.openime.core.nextCodePointUtf16Length import llc.slacker.openime.core.previousCodePointUtf16Length @@ -53,6 +54,12 @@ class InputConnectionGateway( */ private val isRawKeys: () -> Boolean = { false }, private val keyEventsFor: (String) -> Array? = ::keyEventsForText, + /** + * 数字、英文前后加空格: when on, every commit gets the spaces the + * reference puts between Chinese and digits/English. Read per commit so a + * settings change takes effect at once. + */ + private val letterDigitSpace: () -> Boolean = { false }, ) { data class CursorSnapshot( @@ -81,12 +88,29 @@ class InputConnectionGateway( // One Binder transaction carries about 1 MB: committing a huge paste or // transcript in one call throws TransactionTooLargeException and takes the // keyboard down with it. Chunk it, never splitting a surrogate pair. - chunksForCommit(text).forEach { chunk -> ic.commitText(chunk, 1) } + chunksForCommit(spacedForCommit(ic, text)).forEach { chunk -> ic.commitText(chunk, 1) } // commitText replaces an active composing span. composingInEditor = false ownEditPending = true } + /** + * [text] with the boundary spaces 数字、英文前后加空格 asks for. The + * characters on both sides of the commit are read once; a password field and + * an editor that cannot answer leave the text exactly as it came in. + */ + private fun spacedForCommit(ic: InputConnection, text: String): String { + if (isPassword() || !letterDigitSpace()) return text + val first = text.first() + val last = text.last() + val before = runCatching { ic.getTextBeforeCursor(1, 0)?.lastOrNull() }.getOrNull() + val after = runCatching { ic.getTextAfterCursor(1, 0)?.firstOrNull() }.getOrNull() + var spaced = text + if (LetterDigitSpacePolicy.boundaryNeedsSpace(before, first)) spaced = " $spaced" + if (LetterDigitSpacePolicy.boundaryNeedsSpace(last, after)) spaced = "$spaced " + return spaced + } + /** * Set by our own commit, cleared by the selection update it causes. A * Chinese pre-edit has no span in the editor, so without this the update @@ -152,6 +176,12 @@ class InputConnectionGateway( return runCatching { connection()?.getTextBeforeCursor(1, 0)?.lastOrNull() }.getOrNull() } + /** Up to [maxChars] characters before the cursor, or null when unknown (and always in passwords). */ + fun textBeforeCursor(maxChars: Int): CharSequence? { + if (isPassword()) return null + return runCatching { connection()?.getTextBeforeCursor(maxChars, 0) }.getOrNull() + } + fun finishComposing() { connection()?.finishComposingText() composingInEditor = false diff --git a/app/src/main/java/llc/slacker/openime/keyboard/DoubleSpacePeriodPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/DoubleSpacePeriodPolicy.kt new file mode 100644 index 00000000..be117497 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/DoubleSpacePeriodPolicy.kt @@ -0,0 +1,43 @@ +package llc.slacker.openime.keyboard + +/** + * 双击空格键输入句号: a second space inside the window turns the space that + * was just written into 「。」. + * + * The reference ships the switch as `enable_double_click_space` + * (`defaultValue=false`) on its 快捷格式输入 page (`res/xq.xml`, label + * `string/double_click_space_to_input_period` 「双击空格键输入句号」). Its + * handler (`InputViewRoot$z.invoke`, classes.dex code_off 0x4c1764) measures + * `now - lastSpaceTime` against `0xc8` — **200 ms** — and calls + * `KeyboardJni.commitForSpaceDoubleClick(!isPunct(charBeforeFirstSpace), true)`. + * The punctuation gate keeps a doubled space after a mark as a plain space, so + * 「, 」 stays a space instead of becoming 「,。」. + */ +internal object DoubleSpacePeriodPolicy { + + /** The window the reference compares against: `const-wide/16 0xc8`. */ + const val WINDOW_MS = 200L + + /** The period the second space becomes. */ + const val PERIOD = "。" + + /** + * The reference's gate list, taken from the 31-element array built next to + * the handler: space, ,。?!;:, both quote pairs, ()【】《》 and their + * half-width forms ,.?!;:"'()[]<>. A space after any of these stays a space. + */ + private const val PUNCTUATION = " ,。?!;:“”‘’()【】《》,.?!;:\"'()[]<>" + + /** + * True when the space completed at [previousSpaceAtMs] is [elapsedMs] old or + * younger and the character before it was not punctuation. [charBeforeSpace] + * is read before the first space is committed; null (unreadable editor, or + * the field's start) leaves the space alone, as does an unset timestamp. + */ + fun converts(elapsedMs: Long, charBeforeSpace: Char?): Boolean { + if (elapsedMs > WINDOW_MS) return false + if (elapsedMs < 0) return false + if (charBeforeSpace == null) return false + return charBeforeSpace !in PUNCTUATION + } +} diff --git a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt index 992b9846..626849c0 100644 --- a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt @@ -9,6 +9,7 @@ import android.media.AudioRecord import android.media.MediaRecorder import android.os.SystemClock import android.util.Log +import llc.slacker.openime.data.ImeSettingsRepository import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -247,7 +248,10 @@ class LocalAudioVoiceBackend( return } - val routeSession = routeManager.beginSession() + // 使用耳机麦克风录音: read per session, so a change takes effect at once. + val routeSession = routeManager.beginSession( + preferHeadsetMic = ImeSettingsRepository.loadBluetoothHeadsetMic(context), + ) val record = runCatching { AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt index fca366e5..69e89220 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceAudioRouteManager.kt @@ -56,7 +56,7 @@ class VoiceAudioRouteManager(context: Context) { .getSystemService(Context.AUDIO_SERVICE) as AudioManager private val ownership = VoiceRouteOwnership() - fun beginSession(): Session { + fun beginSession(preferHeadsetMic: Boolean = true): Session { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // VOICE_RECOGNITION follows the system-selected wired/SCO route on // old Android versions. Do not force SCO and introduce a 500 ms @@ -65,7 +65,7 @@ class VoiceAudioRouteManager(context: Context) { return Session(onClose = {}) } - val lease = ownership.acquire { prepareManagedRoute() } + val lease = ownership.acquire { prepareManagedRoute(preferHeadsetMic) } val activeType = runCatching { audioManager.communicationDevice?.type ?: 0 }.getOrDefault(0) Log.i( TAG, @@ -81,10 +81,14 @@ class VoiceAudioRouteManager(context: Context) { } @TargetApi(Build.VERSION_CODES.S) - private fun prepareManagedRoute(): RouteBaseline { + private fun prepareManagedRoute(preferHeadsetMic: Boolean): RouteBaseline { val previousMode = audioManager.mode val previousDevice = runCatching { audioManager.communicationDevice }.getOrNull() - val preferred = if (previousDevice == null) preferredExternalDevice() else null + val previousType = previousDevice?.type + val devices = runCatching { audioManager.availableCommunicationDevices }.getOrDefault(emptyList()) + val preferred = VoiceInputRoutePolicy + .preferredType(devices.map { it.type }, previousType, preferHeadsetMic) + ?.let { type -> devices.firstOrNull { it.type == type } } var changedMode = false var changedDevice = false if (preferred != null) { @@ -122,19 +126,6 @@ class VoiceAudioRouteManager(context: Context) { } } - @TargetApi(Build.VERSION_CODES.S) - private fun preferredExternalDevice(): AudioDeviceInfo? { - val priority = listOf( - AudioDeviceInfo.TYPE_BLE_HEADSET, - AudioDeviceInfo.TYPE_BLUETOOTH_SCO, - AudioDeviceInfo.TYPE_WIRED_HEADSET, - AudioDeviceInfo.TYPE_USB_HEADSET, - AudioDeviceInfo.TYPE_USB_DEVICE, - ) - val devices = runCatching { audioManager.availableCommunicationDevices }.getOrDefault(emptyList()) - return priority.firstNotNullOfOrNull { type -> devices.firstOrNull { it.type == type } } - } - class Session internal constructor(private val onClose: () -> Unit) : AutoCloseable { private val closed = AtomicBoolean(false) diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt new file mode 100644 index 00000000..6a5a1c34 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceInputRoutePolicy.kt @@ -0,0 +1,41 @@ +package llc.slacker.openime.voice + +import android.media.AudioDeviceInfo + +/** + * 使用耳机麦克风录音: which microphone voice input records from. + * + * The reference ships the switch as `enable_bluetooth_input` with its own + * title and subtitle — `string/enable_bluetooth_input_show` 「使用耳机麦克风录音」 + * and `string/enable_bluetooth_input_show_sub` 「默认使用手机录音。佩戴耳机时, + * 使用耳机麦克风」 — so the default is the phone's own microphone and a headset + * is used only when the user asked for it. + */ +internal object VoiceInputRoutePolicy { + + /** The device types the reference calls 耳机, best first. */ + val HEADSET_TYPES = listOf( + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_USB_DEVICE, + ) + + /** + * The device type this session should record from, or null to leave the + * route as the system has it. [available] holds the types the platform + * offers for communication, [current] the type in use right now. + * + * With the switch on, the best available headset wins. With it off the + * phone's own microphone wins, but only when something else had routed + * recording to a headset: there is no reason to touch the route otherwise. + */ + fun preferredType(available: List, current: Int?, preferHeadsetMic: Boolean): Int? { + if (preferHeadsetMic) { + return HEADSET_TYPES.firstOrNull { it in available } + } + if (current == null || current !in HEADSET_TYPES) return null + return AudioDeviceInfo.TYPE_BUILTIN_MIC.takeIf { it in available } + } +} diff --git a/app/src/test/java/llc/slacker/openime/ArithmeticExpressionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ArithmeticExpressionPolicyTest.kt new file mode 100644 index 00000000..b13704ce --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/ArithmeticExpressionPolicyTest.kt @@ -0,0 +1,74 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.ArithmeticExpressionPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** 算式出结果: the result of the expression being typed. */ +class ArithmeticExpressionPolicyTest { + + @Test + fun theFourOperatorsAndTheirPrecedence() { + assertEquals("2", ArithmeticExpressionPolicy.evaluate("1+1")) + assertEquals("-3", ArithmeticExpressionPolicy.evaluate("5-8")) + assertEquals("36", ArithmeticExpressionPolicy.evaluate("12*3")) + assertEquals("2.5", ArithmeticExpressionPolicy.evaluate("10/4")) + assertEquals("7", ArithmeticExpressionPolicy.evaluate("1+2*3")) + assertEquals("9", ArithmeticExpressionPolicy.evaluate("(1+2)*3")) + assertEquals("-12", ArithmeticExpressionPolicy.evaluate("-(3*4)")) + assertEquals("7", ArithmeticExpressionPolicy.evaluate("3.5+3.5")) + } + + @Test + fun fractionsKeepUsefulDigitsAndNothingMore() { + assertEquals("0.333333", ArithmeticExpressionPolicy.evaluate("1/3")) + assertEquals("0", ArithmeticExpressionPolicy.evaluate("0.1-0.1")) + assertEquals("0.25", ArithmeticExpressionPolicy.evaluate("1/4")) + } + + @Test + fun theFullWidthKeysOfTheChineseLayoutWork() { + assertEquals("3", ArithmeticExpressionPolicy.evaluate("1+2")) + assertEquals("8", ArithmeticExpressionPolicy.evaluate("2×4")) + assertEquals("2", ArithmeticExpressionPolicy.evaluate("6÷3")) + assertEquals("5", ArithmeticExpressionPolicy.evaluate("(2+3)")) + } + + @Test + fun onlyAFinishedExpressionHasAResult() { + // Still being typed. + assertNull(ArithmeticExpressionPolicy.evaluate("1+")) + assertNull(ArithmeticExpressionPolicy.evaluate("(1+2")) + assertNull(ArithmeticExpressionPolicy.evaluate("")) + // Nothing to calculate: a plain number, and half of a number. + assertNull(ArithmeticExpressionPolicy.evaluate("12")) + assertNull(ArithmeticExpressionPolicy.evaluate(".")) + assertNull(ArithmeticExpressionPolicy.evaluate("1.2.3")) + } + + @Test + fun anImpossibleSumHasNoResult() { + assertNull(ArithmeticExpressionPolicy.evaluate("1/0")) + assertNull(ArithmeticExpressionPolicy.evaluate("3/(2-2)")) + } + + @Test + fun theTrailingExpressionCarriesTheLengthItReplaces() { + val result = ArithmeticExpressionPolicy.evaluateTrailing("价格1+1") + assertEquals("2", result?.text) + // 「1+1」 is three characters of the text before the cursor. + assertEquals(3, result?.expressionLength) + } + + @Test + fun aBrokenTrailingRunHasNoResult() { + // A space ends the expression: the result is offered while it is typed. + assertNull(ArithmeticExpressionPolicy.evaluateTrailing("1+1 ")) + assertNull(ArithmeticExpressionPolicy.evaluateTrailing("")) + assertNull(ArithmeticExpressionPolicy.evaluateTrailing("abc")) + assertNull(ArithmeticExpressionPolicy.evaluateTrailing(null)) + // A Chinese digit is not an expression digit. + assertNull(ArithmeticExpressionPolicy.evaluateTrailing("一加一")) + } +} diff --git a/app/src/test/java/llc/slacker/openime/DoubleSpacePeriodPolicyTest.kt b/app/src/test/java/llc/slacker/openime/DoubleSpacePeriodPolicyTest.kt new file mode 100644 index 00000000..c7db7c46 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/DoubleSpacePeriodPolicyTest.kt @@ -0,0 +1,56 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.DoubleSpacePeriodPolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 双击空格键输入句号: the second space inside the reference's 200 ms window. */ +class DoubleSpacePeriodPolicyTest { + + @Test + fun aSecondSpaceInsideTheWindowConverts() { + assertTrue(DoubleSpacePeriodPolicy.converts(0L, 'a')) + assertTrue(DoubleSpacePeriodPolicy.converts(150L, '好')) + // The window itself is inclusive: the reference compares with 0xc8. + assertTrue(DoubleSpacePeriodPolicy.converts(200L, 'a')) + } + + @Test + fun aSlowerSecondSpaceStaysASpace() { + assertFalse(DoubleSpacePeriodPolicy.converts(201L, 'a')) + assertFalse(DoubleSpacePeriodPolicy.converts(2_000L, 'a')) + } + + @Test + fun aFirstSpaceIsNeverConverted() { + // The timestamp the service keeps starts unset, so the elapsed time is + // the whole uptime and no reading of the text can turn a space into 。 + assertFalse(DoubleSpacePeriodPolicy.converts(System.currentTimeMillis(), 'a')) + // A char the editor could not answer leaves the space alone. + assertFalse(DoubleSpacePeriodPolicy.converts(10L, null)) + } + + @Test + fun aSpaceAfterPunctuationStaysASpace() { + // The reference's gate list, one from each group of it. + assertFalse(DoubleSpacePeriodPolicy.converts(10L, ',')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, '。')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, '?')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, ':')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, '“')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, '》')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, ',')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, '!')) + assertFalse(DoubleSpacePeriodPolicy.converts(10L, ']')) + // A space itself is in the list too. + assertFalse(DoubleSpacePeriodPolicy.converts(10L, ' ')) + // … is not: the reference only lists the marks above. + assertTrue(DoubleSpacePeriodPolicy.converts(10L, '…')) + } + + @Test + fun thePeriodIsTheFullWidthOne() { + assertTrue(DoubleSpacePeriodPolicy.PERIOD == "。") + } +} diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index 1307fd86..f69c0d1b 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -699,4 +699,52 @@ class InputConnectionGatewayTest { assertEquals(1, fake.selectedTextCalls) assertEquals(listOf("commit:"), fake.events) } + + @Test + fun letterDigitSpaceIsOffByDefault() { + val fake = FakeInputConnection(beforeText = "好") + InputConnectionGateway(null, { fake }).commitText("a") + assertEquals(listOf("commit:a"), fake.events) + } + + @Test + fun letterDigitSpaceFillsBothSidesOfTheBoundary() { + // Chinese before, Latin committed: the space goes in front. + val afterChinese = FakeInputConnection(beforeText = "好") + InputConnectionGateway(null, { afterChinese }, letterDigitSpace = { true }) + .commitText("a") + assertEquals(listOf("commit: a"), afterChinese.events) + + // Latin in the field, Chinese committed: the space goes in front too. + val afterLatin = FakeInputConnection(beforeText = "good") + InputConnectionGateway(null, { afterLatin }, letterDigitSpace = { true }) + .commitText("好") + assertEquals(listOf("commit: 好"), afterLatin.events) + + // Chinese committed before Latin that is already in the field: behind. + val beforeLatin = FakeInputConnection(beforeText = "好", afterText = "a") + InputConnectionGateway(null, { beforeLatin }, letterDigitSpace = { true }) + .commitText("中") + assertEquals(listOf("commit:中 "), beforeLatin.events) + } + + @Test + fun letterDigitSpaceLeavesSameScriptAndPasswordsAlone() { + val sameScript = FakeInputConnection(beforeText = "好") + InputConnectionGateway(null, { sameScript }, letterDigitSpace = { true }) + .commitText("中") + assertEquals(listOf("commit:中"), sameScript.events) + + val password = FakeInputConnection(beforeText = "好") + InputConnectionGateway(null, { password }, isPassword = { true }, letterDigitSpace = { true }) + .commitText("a") + assertEquals(listOf("commit:a"), password.events) + } + + @Test + fun letterDigitSpaceNeverDoublesASpaceThatIsAlreadyThere() { + val fake = FakeInputConnection(beforeText = "好 ") + InputConnectionGateway(null, { fake }, letterDigitSpace = { true }).commitText("a") + assertEquals(listOf("commit:a"), fake.events) + } } diff --git a/app/src/test/java/llc/slacker/openime/LetterDigitSpacePolicyTest.kt b/app/src/test/java/llc/slacker/openime/LetterDigitSpacePolicyTest.kt new file mode 100644 index 00000000..5b288b04 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/LetterDigitSpacePolicyTest.kt @@ -0,0 +1,53 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.LetterDigitSpacePolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 数字、英文前后加空格: where a space goes between Chinese and digits/English. */ +class LetterDigitSpacePolicyTest { + + @Test + fun chineseMeetsLettersOrDigits() { + assertTrue(LetterDigitSpacePolicy.boundaryNeedsSpace('好', 'a')) + assertTrue(LetterDigitSpacePolicy.boundaryNeedsSpace('好', 'Z')) + assertTrue(LetterDigitSpacePolicy.boundaryNeedsSpace('好', '7')) + assertTrue(LetterDigitSpacePolicy.boundaryNeedsSpace('a', '好')) + assertTrue(LetterDigitSpacePolicy.boundaryNeedsSpace('7', '好')) + } + + @Test + fun theTwoSidesOfTheSameScriptGetNothing() { + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('好', '好')) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('a', 'b')) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('7', '9')) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('a', '7')) + } + + @Test + fun aSpaceOrAnEdgeGetsNothing() { + // The space a previous commit inserted is not a boundary to fill again. + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace(' ', 'a')) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('好', ' ')) + // Chinese punctuation is not 中文 for this rule. + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('。', 'a')) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace(',', '7')) + // An empty editor side, or one the editor could not answer. + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace(null, 'a')) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace('好', null)) + assertFalse(LetterDigitSpacePolicy.boundaryNeedsSpace(null, null)) + } + + @Test + fun theScriptTestsTheReferenceNames() { + assertTrue(LetterDigitSpacePolicy.isChinese('好')) + assertTrue(LetterDigitSpacePolicy.isChinese('龙')) + assertFalse(LetterDigitSpacePolicy.isChinese('a')) + assertFalse(LetterDigitSpacePolicy.isChinese('2')) + assertTrue(LetterDigitSpacePolicy.isDigitOrLatin('5')) + assertTrue(LetterDigitSpacePolicy.isDigitOrLatin('q')) + assertFalse(LetterDigitSpacePolicy.isDigitOrLatin('好')) + assertFalse(LetterDigitSpacePolicy.isDigitOrLatin('。')) + } +} diff --git a/app/src/test/java/llc/slacker/openime/VoiceInputRoutePolicyTest.kt b/app/src/test/java/llc/slacker/openime/VoiceInputRoutePolicyTest.kt new file mode 100644 index 00000000..1c3f844a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/VoiceInputRoutePolicyTest.kt @@ -0,0 +1,62 @@ +package llc.slacker.openime + +import android.media.AudioDeviceInfo +import llc.slacker.openime.voice.VoiceInputRoutePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** 使用耳机麦克风录音: which microphone voice input records from. */ +class VoiceInputRoutePolicyTest { + + private val phone = AudioDeviceInfo.TYPE_BUILTIN_MIC + private val bluetooth = AudioDeviceInfo.TYPE_BLUETOOTH_SCO + private val ble = AudioDeviceInfo.TYPE_BLE_HEADSET + private val wired = AudioDeviceInfo.TYPE_WIRED_HEADSET + private val speaker = AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + + @Test + fun theSwitchOnTakesTheBestHeadsetThatIsThere() { + assertEquals( + ble, + VoiceInputRoutePolicy.preferredType(listOf(phone, bluetooth, ble), phone, preferHeadsetMic = true), + ) + assertEquals( + bluetooth, + VoiceInputRoutePolicy.preferredType(listOf(phone, bluetooth), phone, preferHeadsetMic = true), + ) + assertEquals( + wired, + VoiceInputRoutePolicy.preferredType(listOf(phone, wired), phone, preferHeadsetMic = true), + ) + } + + @Test + fun theSwitchOnWithoutAHeadsetLeavesTheRouteAlone() { + assertNull(VoiceInputRoutePolicy.preferredType(listOf(phone), phone, preferHeadsetMic = true)) + assertNull(VoiceInputRoutePolicy.preferredType(emptyList(), null, preferHeadsetMic = true)) + } + + @Test + fun theSwitchOffKeepsThePhoneMicrophone() { + // Recording was going to a headset (another app, or a previous session): + // the reference's 「默认使用手机录音」 takes it back. + assertEquals( + phone, + VoiceInputRoutePolicy.preferredType(listOf(phone, bluetooth), bluetooth, preferHeadsetMic = false), + ) + assertEquals( + phone, + VoiceInputRoutePolicy.preferredType(listOf(phone, ble), ble, preferHeadsetMic = false), + ) + } + + @Test + fun theSwitchOffTouchesNothingThatIsAlreadyThePhone() { + // Already on the phone's microphone: no reason to set the route again. + assertNull(VoiceInputRoutePolicy.preferredType(listOf(phone), phone, preferHeadsetMic = false)) + // A speaker is not a microphone and not a headset. + assertNull(VoiceInputRoutePolicy.preferredType(listOf(phone, speaker), speaker, preferHeadsetMic = false)) + assertNull(VoiceInputRoutePolicy.preferredType(listOf(phone, speaker), null, preferHeadsetMic = false)) + } +} From dfac7b136ae103aee9cff8279fb4e82c9ec3809c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:59:53 +0800 Subject: [PATCH 88/94] =?UTF-8?q?Add=20the=20=E5=BF=AB=E6=8D=B7=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=BE=93=E5=85=A5=20settings=20and=20wire=20the=20bat?= =?UTF-8?q?ch=20into=20the=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the batch, carrying both lanes' wiring: the settings rows for 数字、英文前后加空格 / 首字母自动大写 / 双击空格键输入句号 (the reference's own 快捷格式输入 page, res/xq.xml), 候选栏展示 and 使用耳机麦克风录音, plus 首字母自动大写 and 按键音量 from the other session working this tree. 首字母自动大写 reads the text before the cursor at every text change and raises Shift at a sentence start; it rides the reference's switch (enable_first_letter_capitalization, defaultValue=false) and the Shift key now announces the reference's own guide text (string/caps_lock_guide_text). 按键音量 scales the bundled click samples and the system click with the reference's key_sound_volume (0-100, default 50). 算式出结果 and 候选栏展示 fill the row between words: an expression that ends at the cursor offers its result, and with 候选栏展示 on the clipboard's content is offered as a candidate that writes what was copied. Candidate-bar clipboard display ships off even though the reference defaults it on (enable_clipboard_candidate_bar_show, defaultValue=true), because this batch's personalisation features are off until switched on; the divergence is recorded in the changelog and the decision log. 使用耳机麦克风录音 routes voice input to a headset when one is worn and back to the phone's own microphone when it is not, per the reference's own subtitle 「默认使用手机录音。佩戴耳机时,使用耳机麦克风」. User-data export and import carry all six new settings. The FuzzyRulesInstrumentedTest expectation now matches the shipped fuzzy defaults (z=zh among them, fcae0dd) instead of the pre-fcae0dd behaviour. Co-Authored-By: Claude Code --- CHANGELOG.md | 9 +- ...toCapitalizationServiceInstrumentedTest.kt | 227 ++++++++++++++++++ .../openime/FuzzyRulesInstrumentedTest.kt | 6 +- .../openime/KeySoundVolumeInstrumentedTest.kt | 34 +++ .../slacker/openime/ImeSettingsActivity.kt | 9 + .../slacker/openime/LocalVoiceImeService.kt | 158 +++++++++++- .../openime/data/ImeSettingsRepository.kt | 96 ++++++++ .../slacker/openime/data/UserDataArchive.kt | 38 +++ .../keyboard/AutoCapitalizationPolicy.kt | 35 +++ .../openime/keyboard/EnglishShiftPolicy.kt | 12 + .../openime/keyboard/ImeKeyboardView.kt | 57 ++++- .../llc/slacker/openime/keyboard/KeySounds.kt | 13 +- .../openime/panel/SettingsPanelController.kt | 17 ++ app/src/main/res/drawable/ic_pref_case.xml | 1 + .../openime/AutoCapitalizationPolicyTest.kt | 61 +++++ .../slacker/openime/EnglishShiftPolicyTest.kt | 9 + docs/DECISIONS.md | 27 +++ 17 files changed, 794 insertions(+), 15 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt create mode 100644 app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/AutoCapitalizationPolicy.kt create mode 100644 app/src/main/res/drawable/ic_pref_case.xml create mode 100644 app/src/test/java/llc/slacker/openime/AutoCapitalizationPolicyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f54eb6c..b19c6793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,14 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -- 诗句联想:打出来的拼音凑够一句诗的前三个音节,这一句就出现在候选栏里;整句打全时,紧跟着的那一句也一起出来(打 `chunmianbujuexiao` 出「春眠不觉晓」和「处处闻啼鸟」)。参考实现自带一份诗句词典(`sys_poem_gram`,类型 11010),里面每条线(LINE)是「行号+汉字+音节序列」,后面跟一组关联组(GROUP),记着这一句在自己那首诗里的下一句(`doubaoime-teardown/DECODE-BATCH-D.md` §4,逐字节走完整份 351,270 字节,5,966 行 + 10,542 组);它自己的设置文案把诗词列在联想推荐的内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」。我们原来没有这一层,现在由 `scripts/import_doubao_data.py` 从那份表生成 `poem/lines.tsv`(4 字以上的 5,498 句,其中 5,042 句带下一句),拼音词表旁新增 `PoemIndex`:候选栏先给引擎自己的词,诗句跟在后面。三个音节是量出来的门槛——词表里最常用的 3000 个读音,两个音节就能对上诗句的有 380 个(12.7%,「春风」这种日常输入会一直冒诗句),三个音节只有 3 个(0.1%)。这一层跟着「智能联想」开关走,与参考把诗词算在联想的同一个开关里一致。参考那边的「计算结果」我们没有对等物(我们没有算式候选),四字以下的短句和九键按数字查句都没有移植,句间标点仍留在表里没有用上。 +- 数字、英文前后加空格:参考在「快捷格式输入」页上的第三个开关,标题与说明就是这条规则本身——`string/letter_digit_space_list_item_title`「数字、英文前后加空格」、`string/letter_digit_space_list_item_subtitle`「在语音与打字输入时,在中文和数字、英文间将增加空格」,这一行在参考里只有那个行 id(`enable_letter_digit_space_list_item_id`)——`res/cU.xml` 的偏好表里没有它的开关,整个 APK 也找不到 `enable_letter_digit_space` 这个键,所以它的默认值读不出来;按「个性化功能默认关闭」定成默认关。我们在唯一的落字通道(`InputConnectionGateway.commitText`)里补空格:落字前读一次光标左右的字符,一侧是汉字、另一侧是 ASCII 字母或数字时补一个空格;汉字侧的标点(`。`、`,`)不算汉字,空格本身也不会再加一个空格;语音上屏走同一条通道,与参考文案里的「在语音与打字输入时」一致。开关默认关,关着的时候这条通道只多两次读光标(都只在开关打开时才读)。 +- 双击空格键输入句号:参考的处理器在 `InputViewRoot$z.invoke`(classes.dex code_off 0x4c1764):空格抬起时 `now - lastSpaceTime` 与 `const-wide/16 0xc8` 比较,也就是 **200 毫秒**,命中就调 `KeyboardJni.commitForSpaceDoubleClick(!isPunct(charBeforeFirstSpace), true)`;判定用的标点是一张 31 个字符的表(空格、`,。?!;:`、两对引号、`()【】《》` 与半角 `,.?!;:"'()[]<>`),落在这些字后面的第二个空格仍是空格。我们照抄这套:服务记下第一个空格落下的时刻与它前面那个字符(落字前读),第二个空格落在 200 毫秒内且前面不是标点时,删掉那个空格写上「。」。存储项 `enable_double_click_space` 默认关(另见 `res/xq.xml` 的 `string/double_click_space_to_input_period`「双击空格键输入句号」)。 +- 算式出结果:光标前是一段写完的算式时,候选栏给出结果(打 `1+2*3` 出 `7`),点一下用结果换掉算式。参考这条在 native 里:`keyboard::InputModel::Impl::AssociateCalculator`(libkeyboard.so 0x26523c)、`GetCalculatorCandidate`(0x255ca4)与 `CommitCalculatorCand`(0x264594)——后者带一个 `cand_length`,正是「用结果换掉整段算式」这个动作。它自己的设置文案把「计算结果」列在联想的推荐内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」(`string/associate_close_content`),所以这一层跟着「智能联想」走,没有单独的开关。我们读光标前最多 48 个字符里那一段由 `0-9.+-*/()×÷` 组成的尾巴,用一个小递归下降解析器求值(`+ - * /` 带优先级、一元负号、括号,全角数字与 `+-×÷()` 也认),写完才给结果:尾巴是运算符、没有运算符、括号不配、除以零都不出;结果是整数就照原样,否则最多六位小数、去掉末尾的零。显示上只给结果本身——参考的资源与 native 库里都没有「=」这个显示用字串。参考的算式文法与小数位数我们没逐字节还原,只按可见行为对齐。 +- 候选栏展示(剪贴板内容进候选栏):参考这一项的标题与说明是 `string/clipboard_candidate_bar_show_title`「候选栏展示」、`string/clipboard_candidate_bar_show_description`「可在候选栏快速使用剪切内容」,存储项 `enable_clipboard_candidate_bar_show` 在参考里是 `BooleanPreference`、`defaultValue="true"`(`res/cU.xml`,键 `0x7f110192`),也就是那边**默认开着**;我们这条默认关,按「个性化功能默认关闭」定,读剪切板这种事不替用户打开。我们原来只有工具栏上那条「复制内容提示」,现在多一条:开关打开、不是密码框、剪切板里有内容时,候选/联想行给出一条剪贴板候选,点一下就把复制的内容写进输入框(候选条按一行截断显示,落下去的是全文),用过就收起。两条互不影响,可以各自开关。 +- 使用耳机麦克风录音:参考的 `enable_bluetooth_input` 指的是耳机麦克风,不是外接键盘——`string/enable_bluetooth_input_show`「使用耳机麦克风录音」、`string/enable_bluetooth_input_show_sub`「默认使用手机录音。佩戴耳机时,使用耳机麦克风」。我们的 `VoiceAudioRouteManager` 在 Android 12 以上本来就会挑外接设备,只是没人管开关:现在由它决定——打开时按优先级取可用的耳机(BLE / 蓝牙 SCO / 有线 / USB),关着(默认)时取手机自带麦克风,且只在别的应用已经把录音改到耳机上时才动路由,本来就在手机麦克风上就不碰。Android 12 以下不碰路由(与原来的取舍一致:不为 SCO 付 500 毫秒的起录延迟),设置项写作「使用耳机麦克风录音」,说明照抄参考。外接键盘参考没有对应设置项,我们已有的实体键盘支持保持原样。 +- 首字母自动大写:参考把这条做成开关,不是默认行为——存储项 `enable_first_letter_capitalization` 是 `BooleanPreference`、默认 `false`(`res/cU.xml`),开关本体在它设置里的「快捷格式输入」页(`res/xq.xml`,页面标题 `string/key_auxiliary_input_title`「快捷格式输入」,行内标题 `string/first_letter_capitalization_title`「首字母自动大写」;`root_preferences.xml` 的「输入设置」分类里另有一份同样默认关闭的开关)。我们从 0.0.2-beta.1 起英文模式一律不自动大写,现在照参考补成开关,默认关,所以默认行为不变。打开后,英文 26 键按光标前的文本决定 Shift:输入框还空着、或光标前是句末标记(`.`、`!`、`?`、`…`、`。`、`!`、`?` 与换行;标记和光标之间的空格不算)时是大写一次,词中是小写,回看窗口 8 个字符。用户自己锁的 Caps Lock、以及要求全大写的输入框(`TYPE_TEXT_FLAG_CAP_CHARACTERS`)不动。跟着每一次改动文本的动作走:字母与符号上屏、退格、空格、回车与长按换行、候选上屏、进入新的输入框、切到英文 26 键。顺带把 Shift 键的无障碍描述换成参考自己的那句话——`string/caps_lock_guide_text`(0x7f1100de)「双击大写键锁定大写输入」。 +- 按键音量:参考自己的设置项 `key_sound_volume` 是 `IntPreference`、默认 `50`(`res/cU.xml`),就挨着总开关 `enable_key_sound`。我们原来没有音量这一档:自带采样固定按 1.0 播,系统点击音直接传 `-1`(系统默认音量)。现在「按键音效」下面多一条 0–100 的「按键音量」,默认 50,与参考一致,自带采样和系统点击音都按这个百分比播;键盘里的设置面板也补上了同一条滑块。 +- 诗句联想:打出来的拼音凑够一句诗的前三个音节,这一句就出现在候选栏里;整句打全时,紧跟着的那一句也一起出来(打 `chunmianbujuexiao` 出「春眠不觉晓」和「处处闻啼鸟」)。参考实现自带一份诗句词典(`sys_poem_gram`,类型 11010),里面每条线(LINE)是「行号+汉字+音节序列」,后面跟一组关联组(GROUP),记着这一句在自己那首诗里的下一句(`doubaoime-teardown/DECODE-BATCH-D.md` §4,逐字节走完整份 351,270 字节,5,966 行 + 10,542 组);它自己的设置文案把诗词列在联想推荐的内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」。我们原来没有这一层,现在由 `scripts/import_doubao_data.py` 从那份表生成 `poem/lines.tsv`(4 字以上的 5,498 句,其中 5,042 句带下一句),拼音词表旁新增 `PoemIndex`:候选栏先给引擎自己的词,诗句跟在后面。三个音节是量出来的门槛——词表里最常用的 3000 个读音,两个音节就能对上诗句的有 380 个(12.7%,「春风」这种日常输入会一直冒诗句),三个音节只有 3 个(0.1%)。这一层跟着「智能联想」开关走,与参考把诗词算在联想的同一个开关里一致。参考那边的「计算结果」当时还没有对等物——本次「算式出结果」一条已补上;四字以下的短句和九键按数字查句都没有移植,句间标点仍留在表里没有用上。 - 剪贴板列表也能拆词:长按任意一条进入「滑动选词」(参考自己的文案:列表上那句提示是「长按列表可进入拆词」`clipboard_history_word_seg_tip`,视图标题 `word_segmentation_title`「滑动选词」)。文字按词库切成词——每个位置取词表里最长的那个词(词表最长 8 字,「依法追究刑事责任」就是),字母数字连成一段,标点和空白是不能选的间隔——在词上滑动划出一段再点「添加到常用语」(`word_segmentation_add_to_common_phrase`,没选中时按钮变淡且不可点,对应参考的 `word_segmentation_add_to_common_phrase_bg_disabled`),随后面板落在常用语列表,那一段就在里面。参考这一页的成组颜色把词条分成可选与不可选两种(`ime_word_segmentation_item_bg_color`、`..._item_selected_bg_color`、`..._label_clickable_bg_color`、`..._label_un_clickable_bg_color`),我们的口径一样:词可以选,标点与空白只是间隔。参考另有「常用语数量已达上限」,我们的常用语本来不设上限,这条不移植。 - 发送键长按换行:参考自己的设置文案写着这条手势(`enable_key_enter_sub_title`「长按「发送」可换行。开关支持App范围」,开关本体 `enable_key_enter_send_msg` 在 `res/cU.xml` 里默认关闭、标题「回车键发送消息」,拨动开关时的确认框也重复这句)。编辑器声明了发送动作、键面读「发送」时长按插入一个换行,走的是「换行」那一面同样的真 Enter 键事件(没有动作时的那条路径),不再是发送。合成中键面读「确定」,「搜索」「前往」「完成」这些动作参考没有这条手势,都保持原来的一次按压;键的无障碍描述跟着念出这条手势(「发送,长按换行」,与角标键「,上滑或长按输入X」同一写法)。 - 修掉 0.0.10-beta.1 里浮动键盘整块收不到触摸的问题(卡片看着在,点什么都没反应)。浮动时服务在 `onComputeInsets` 里把内容顶和可见顶都报成卡片自己的下边缘,为的是让身后的应用既不 resize 也不 pan;但基类给触摸模式设的是 `TOUCHABLE_INSETS_VISIBLE`("the area inside of the visible insets can be touched",`ViewTreeObserver.InternalInsetsInfo`),可见顶等于整张卡片高度时,这块区域正好被剪成空集,卡片就一点可触摸区都不剩。现在这份报告按 AOSP 全屏分支的搭配补上 `TOUCHABLE_INSETS_FRAME`(`InputMethodService.onComputeInsets`:`info.contentInsets.top = info.visibleInsets.top = decor.getHeight(); info.setTouchableInsets(TOUCHABLE_INSETS_FRAME);`),整张卡片恢复可触摸,应用仍然不被 resize/pan;停靠键盘不走这条分支,行为不变。`FloatingInsetsInstrumentedTest` 固定这三项的值。 diff --git a/app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt new file mode 100644 index 00000000..977e9f9f --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/AutoCapitalizationServiceInstrumentedTest.kt @@ -0,0 +1,227 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.ContextWrapper +import android.os.Handler +import android.os.Looper +import android.text.InputType +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.candidate.CandidateQueryCoordinator +import llc.slacker.openime.core.ImeState +import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.ShiftState +import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.editor.InputConnectionGateway +import llc.slacker.openime.rime.RimeEngine +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +/** + * 首字母自动大写在服务这一侧:开着的时候,句末标记之后的英文要 Shift 抬起;关着的 + * 时候(参考自己的默认)什么都不做。密码框、要求全大写的输入框、用户自己锁的 Caps + * Lock 都不动,读不到输入框文字时也不猜。 + */ +@RunWith(AndroidJUnit4::class) +class AutoCapitalizationServiceInstrumentedTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @After + fun restoreTheSwitch() { + ImeSettingsRepository.saveFirstLetterCapitalization(context, false) + } + + private fun withService( + enabled: Boolean, + textBefore: () -> String?, + inputType: Int = InputType.TYPE_CLASS_TEXT, + passwordField: Boolean = false, + shiftState: ShiftState = ShiftState.LOWERCASE, + test: (LocalVoiceImeService) -> Unit, + ) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val connection = Proxy.newProxyInstance( + InputConnection::class.java.classLoader, + arrayOf(InputConnection::class.java), + ) { _, method, args -> + when (method.name) { + "getTextBeforeCursor" -> textBefore() + "commitText", "deleteSurroundingText", "sendKeyEvent", "performEditorAction", + "finishComposingText", "setComposingText", "setComposingRegion", + "requestCursorUpdates", "closeConnection", + -> true + else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null + } + } as InputConnection + + val service = LocalVoiceImeService() + // The service is constructed directly instead of being started by the + // platform, so it has no base context yet. + ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java) + .apply { isAccessible = true } + .invoke(service, context) + + ImeSettingsRepository.saveFirstLetterCapitalization(context, enabled) + setField(service, "gateway", InputConnectionGateway(context, { connection }, { passwordField })) + // The backspace path clears the pre-edit, which goes through the + // engine and the candidate queries; neither is started in a bare + // service, so the test hands both a not-ready instance. + val rime = RimeEngine(context) + setField(service, "rime", rime) + setField( + service, + "candidateQueries", + CandidateQueryCoordinator( + rime = rime, + mainHandler = Handler(Looper.getMainLooper()), + fallbackCandidatesFor = { emptyList() }, + maxInputLength = 32, + maxNineKeyPaths = 8, + maxCandidates = 20, + ), + ) + setField( + service, + "state", + ImeState( + keyboardMode = KeyboardMode.ENGLISH_26, + passwordField = passwordField, + shiftState = shiftState, + editorInfo = EditorInfo().apply { this.inputType = inputType }, + ), + ) + test(service) + } + } + + private fun setField(service: LocalVoiceImeService, name: String, value: Any) { + LocalVoiceImeService::class.java.getDeclaredField(name).apply { + isAccessible = true + set(service, value) + } + } + + private fun shiftState(service: LocalVoiceImeService): ShiftState { + val field = LocalVoiceImeService::class.java.getDeclaredField("state").apply { isAccessible = true } + return (field.get(service) as ImeState).shiftState + } + + @Test + fun offItLeavesTheShiftKeyWhereTheEditorPutIt() { + withService(enabled = false, textBefore = { "Hello. " }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun onAMarkAfterAWordTheNextLetterIsACapital() { + withService(enabled = true, textBefore = { "Hello. " }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + } + withService(enabled = true, textBefore = { "Hello wor" }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun theSwitchItselfTakesEffectOnTheFieldThatIsUp() { + var before = "Hello. " + withService(enabled = false, textBefore = { before }) { service -> + service.onCharacter("a") + assertEquals("关着的时候不动", ShiftState.LOWERCASE, shiftState(service)) + + // The panel writes the setting and calls this; the field is re-read. + ImeSettingsRepository.saveFirstLetterCapitalization(context, true) + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + + // Turning it back off leaves the last decision alone: Shift is the + // user's from here on. + ImeSettingsRepository.saveFirstLetterCapitalization(context, false) + before = "Hello" + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + } + } + + @Test + fun deletingTheMarkLowersTheShiftAgain() { + var before = "Hi. " + withService(enabled = true, textBefore = { before }) { service -> + service.onCharacter("a") + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + + before = "Hi" + service.onBackspace() + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun typingTheMarkItselfRaisesTheShift() { + var before = "Hello" + withService(enabled = true, textBefore = { before }) { service -> + before = "Hello." + service.onSymbolSelected(".") + assertEquals(ShiftState.SHIFT_ONCE, shiftState(service)) + } + } + + @Test + fun aSentenceMarkInAFieldThatOnlyWantsLowercaseIsStillRespected() { + withService( + enabled = true, + textBefore = { "Hello. " }, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS, + ) { service -> + service.onAutoCapitalizationChanged() + assertEquals( + "全大写输入框由它自己决定,这里不改", + ShiftState.LOWERCASE, + shiftState(service), + ) + } + } + + @Test + fun theUserSLockedCapsIsNeverReleased() { + withService( + enabled = true, + textBefore = { "Hello wor" }, + shiftState = ShiftState.CAPS_LOCK, + ) { service -> + service.onCharacter("a") + assertEquals(ShiftState.CAPS_LOCK, shiftState(service)) + } + } + + @Test + fun aPasswordFieldIsNeverRead() { + withService( + enabled = true, + textBefore = { "Hello. " }, + passwordField = true, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + ) { service -> + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } + + @Test + fun anEditorThatCannotAnswerLeavesTheShiftAlone() { + withService(enabled = true, textBefore = { null }) { service -> + service.onAutoCapitalizationChanged() + assertEquals(ShiftState.LOWERCASE, shiftState(service)) + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt index 8b021443..e5f911d4 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FuzzyRulesInstrumentedTest.kt @@ -71,11 +71,13 @@ class FuzzyRulesInstrumentedTest { assertTrue("huang = wang: wang offers 黄", "黄" in rime.candidates("wang").take(40)) assertTrue("ei = ui: hui offers 飞", "飞" in rime.candidates("hui").take(40)) - // Back to the shipped defaults: nothing fuzzy even with 模糊音 on. + // Back to the shipped defaults: the reference's own six pairs stay on + // (z = zh among them), and the nine it does not default to fall away. ImeSettingsRepository.saveFuzzyRules(context, FuzzyRule.DEFAULTS) redeploy(rime) assertEquals(FuzzyRule.rimeYaml(FuzzyRule.DEFAULTS), userRules.readText()) - assertFalse("defaults: zi does not offer 知", "知" in rime.candidates("zi").take(30)) + assertTrue("defaults: zi offers 知", "知" in rime.candidates("zi").take(30)) + assertFalse("defaults: hui does not offer 飞", "飞" in rime.candidates("hui").take(40)) // 模糊音 off: the plain schema, nothing fuzzy. ImeSettingsRepository.saveFuzzy(context, false) diff --git a/app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt new file mode 100644 index 00000000..4830f499 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/KeySoundVolumeInstrumentedTest.kt @@ -0,0 +1,34 @@ +package llc.slacker.openime + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import llc.slacker.openime.data.ImeSettingsRepository +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 按键音量 keeps the reference's own scale: `key_sound_volume` is an + * IntPreference with `defaultValue=50`, so the stored percent is 0-100 and the + * default is 50. + */ +@RunWith(AndroidJUnit4::class) +class KeySoundVolumeInstrumentedTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun theVolumeDefaultsToHalfAndClampsWhatIsSaved() { + context.getSharedPreferences("ime_settings", Context.MODE_PRIVATE) + .edit().remove("key_sound_volume").commit() + assertEquals(50, ImeSettingsRepository.loadKeySoundVolume(context)) + + ImeSettingsRepository.saveKeySoundVolume(context, 120) + assertEquals(100, ImeSettingsRepository.loadKeySoundVolume(context)) + ImeSettingsRepository.saveKeySoundVolume(context, -5) + assertEquals(0, ImeSettingsRepository.loadKeySoundVolume(context)) + + ImeSettingsRepository.saveKeySoundVolume(context, ImeSettingsRepository.SOUND_VOLUME_DEFAULT) + } +} diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index b01df238..4e061098 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -211,6 +211,15 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { override fun onHapticStrengthChanged(percent: Int) { refreshLiveIme() } + override fun onKeySoundVolumeChanged(percent: Int) { + refreshLiveIme() + } + override fun onAutoCapitalizationChanged() { + refreshLiveIme() + } + override fun onClipboardCandidateChanged() { + refreshLiveIme() + } override fun onFeedbackStyleChanged() { refreshLiveIme() } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index f6f7dc1f..d3021202 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -6,6 +6,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.text.InputType import android.text.TextUtils import android.util.Log @@ -33,6 +34,7 @@ import llc.slacker.openime.candidate.FuzzyPinyin import llc.slacker.openime.candidate.Stroke import llc.slacker.openime.candidate.StrokeLexicon import llc.slacker.openime.candidate.personalizedLearningAllowed +import llc.slacker.openime.core.ArithmeticExpressionPolicy import llc.slacker.openime.core.CrashGuard import llc.slacker.openime.core.EmojiCatalog import llc.slacker.openime.core.ImeState @@ -58,6 +60,8 @@ 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.AutoCapitalizationPolicy +import llc.slacker.openime.keyboard.DoubleSpacePeriodPolicy import llc.slacker.openime.keyboard.EnglishShiftPolicy import llc.slacker.openime.keyboard.HardwareContext import llc.slacker.openime.keyboard.HardwareKey @@ -140,6 +144,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private var landscapeFloatingDeclined = false private var voiceComposing = false private var voiceAutoCommitOnFinal = true + /** + * 双击空格键输入句号: when the last space was written, and the character + * before it (read before that space was committed), both from the reference. + */ + private var lastSpaceAtMs = 0L + private var lastCharBeforeSpace: Char? = null private val voiceCorrectionTracker by lazy { VoiceCorrectionTracker( snapshot = { gateway.absoluteCursorSnapshot() }, @@ -255,6 +265,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can connection = { currentInputConnection }, isPassword = { state.passwordField }, isRawKeys = { EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS }, + letterDigitSpace = { ImeSettingsRepository.loadLetterDigitSpace(this) }, ) state = ImeState( theme = ImeSettingsRepository.loadTheme(this), @@ -515,6 +526,13 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can rime.clear() keyboardView?.clearAssociationCandidates() keyboardView?.setShiftState(initialShiftState) + // A field the user opened can already hold text: 首字母自动大写 reads it + // now rather than waiting for the first letter (off, it keeps the + // input-type state above). + refreshEnglishAutoCapitalization() + // 算式结果 and 候选栏展示 are read from the field and the clipboard, so a + // field that opens on a finished expression or a fresh copy shows both. + refreshIdleCandidates() keyboardView?.setMode(state.keyboardMode, notifyListener = false) keyboardView?.renderState(state) if (preserve) { @@ -965,6 +983,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can commitPendingComposition() keyboardView?.clearAssociationCandidates() gateway.commitText(char) + // 算式出结果: the digit that finishes an expression brings its result. + refreshIdleCandidates() + refreshEnglishAutoCapitalization() } override fun onBackspace() = guarded("onBackspace") { @@ -992,6 +1013,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else { gateway.deleteBackwards() clearImeCompositionState(render = true) + // Deleting the sentence mark (or leaving a fresh field) moves the + // sentence start, so 首字母自动大写 re-reads the text. + refreshEnglishAutoCapitalization() } } @@ -1042,7 +1066,32 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } voiceCorrectionTracker.finalizeIfNeeded() keyboardView?.clearAssociationCandidates() + commitPlainSpace() + refreshEnglishAutoCapitalization() + } + + /** + * Write the space this key press means — or, with 双击空格键输入句号 on and + * a second space inside the reference's 200 ms window, replace the space + * just written with 「。」. The gate is the character before that first + * space, read then and kept here, exactly as the reference keeps it; a space + * after punctuation stays a space. + */ + private fun commitPlainSpace() { + val now = SystemClock.uptimeMillis() + val elapsed = now - lastSpaceAtMs + val previousChar = lastCharBeforeSpace + lastSpaceAtMs = 0L + lastCharBeforeSpace = null + val doubleSpacePeriod = ImeSettingsRepository.loadDoubleSpacePeriod(this) + if (doubleSpacePeriod && DoubleSpacePeriodPolicy.converts(elapsed, previousChar)) { + gateway.deleteBackwards() + gateway.commitText(DoubleSpacePeriodPolicy.PERIOD) + return + } + if (doubleSpacePeriod) lastCharBeforeSpace = gateway.charBeforeCursor() gateway.commitText(" ") + lastSpaceAtMs = now } override fun onVoiceToggle() { @@ -1198,6 +1247,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } else { gateway.sendKeyDownUp(KeyEvent.KEYCODE_ENTER) } + // A line break ends the sentence (the policy counts 「\n」 as one). + refreshEnglishAutoCapitalization() } /** @@ -1214,6 +1265,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } voiceCorrectionTracker.finalizeIfNeeded() gateway.sendKeyDownUp(KeyEvent.KEYCODE_ENTER) + refreshEnglishAutoCapitalization() } override fun onCompositionChanged(composition: String, candidates: List) = guarded("onCompositionChanged") { @@ -1394,12 +1446,70 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can prepareForManualInput() if (state.passwordField || text.isEmpty()) return commitPendingComposition() + // 算式出结果 replaces the expression it came from: the reference's + // CommitCalculatorCand carries the candidate's own length for exactly that. + val calculator = arithmeticCandidate() + if (calculator != null && calculator.text == text) { + repeat(calculator.expressionLength) { gateway.deleteBackwards() } + gateway.commitText(text) + gateway.finishComposing() + refreshIdleCandidates() + return + } + // 候选栏展示: the chip writes the clip itself, and the row is done with + // it — the user took what they copied. + if (text == clipboardCandidate()) { + gateway.commitText(text) + gateway.finishComposing() + keyboardView?.clearAssociationCandidates() + return + } gateway.commitText(text) gateway.finishComposing() keyboardView?.clearAssociationCandidates() keyboardView?.setAssociationCandidates(associationsAfterCommit(text)) } + /** + * 算式出结果 rides the 智能联想 switch, which the reference names the + * result under too (`string/associate_close_content` 「关闭联想后,将不会 + * 推荐诗词、计算结果、emoji表情。」). The result is read from the field at + * the cursor, so it follows the expression as it is typed, backspaced or + * moved past. + */ + private fun arithmeticCandidate(): ArithmeticExpressionPolicy.Result? { + if (state.passwordField) return null + if (!ImeSettingsRepository.loadEmojiAssociation(this)) return null + val before = gateway.textBeforeCursor(ArithmeticExpressionPolicy.MAX_LOOKBACK) + return ArithmeticExpressionPolicy.evaluateTrailing(before) + } + + /** + * What the candidate row offers between words: 算式结果 and, with 候选栏 + * 展示 on, the clipboard's content — 「可在候选栏快速使用剪切内容」. + */ + private fun refreshIdleCandidates() { + keyboardView?.clearAssociationCandidates() + val chips = ArrayList(2) + arithmeticCandidate()?.let { chips += it.text } + clipboardCandidate()?.let { chips += it } + if (chips.isNotEmpty()) keyboardView?.setAssociationCandidates(chips) + } + + /** + * The clipboard's content as a candidate, or null when 候选栏展示 is off, + * the field is a password, or the clipboard holds nothing. The chip carries + * the whole clip: the row clips it to one line, and a tap writes what was + * copied, not what is on screen. + */ + private fun clipboardCandidate(): String? { + if (state.passwordField) return null + if (!ImeSettingsRepository.loadClipboardCandidate(this)) return null + val text = gateway.readClipboard() + if (text.isEmpty() || text.isBlank()) return null + return text + } + private val emojiAssociations: EmojiAssociationIndex by lazy { EmojiAssociationIndex.obtain(this) } @@ -1414,8 +1524,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can /** Next-word suggestions, with the emoji that go with the committed text first. */ private fun associationsAfterCommit(committed: String): List { val words = candidatePipeline.associationsFor(committed) - if (state.passwordField || !ImeSettingsRepository.loadEmojiAssociation(this)) return words - return emojiAssociations.emojiFor(committed) + words + val calculator = arithmeticCandidate()?.let { listOf(it.text) } ?: emptyList() + if (state.passwordField || !ImeSettingsRepository.loadEmojiAssociation(this)) return calculator + words + return calculator + emojiAssociations.emojiFor(committed) + words } override fun onCompositionBackspace() { @@ -1457,6 +1568,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onFuzzyRulesChanged() = syncFuzzyRules() + /** 首字母自动大写 switched; the field that is up re-reads its text at once. */ + override fun onAutoCapitalizationChanged() = refreshEnglishAutoCapitalization() + + /** 候选栏展示 switched; the row re-reads the clipboard at once. */ + override fun onClipboardCandidateChanged() = refreshIdleCandidates() + /** * Hand the 模糊音 switches to both candidate sources: the Kotlin fallback * reads [FuzzyPinyin.rules]; librime re-deploys when the rules file changes. @@ -1505,6 +1622,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.clearAssociationCandidates() SymbolRecentRepository.record(this, symbol) gateway.commitText(symbol) + // 算式出结果: 「)」 or a full-width figure can finish the expression. + refreshIdleCandidates() + // A 「.」「!」「?」 (or their full-width forms) starts the next sentence. + refreshEnglishAutoCapitalization() } override fun onEmojiSelected(emoji: String) = guarded("onEmojiSelected") { @@ -1901,6 +2022,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can state = state.copy(composition = "", candidates = emptyList()) keyboardView?.clearAssociationCandidates() keyboardView?.renderState(state) + refreshEnglishAutoCapitalization() } private fun selectCandidate(candidate: String) { @@ -2013,6 +2135,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can state = state.copy(composition = "", candidates = emptyList()) keyboardView?.renderState(state) keyboardView?.setAssociationCandidates(associationsAfterCommit(committed)) + refreshEnglishAutoCapitalization() } /** @@ -2047,6 +2170,37 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private fun resetEnglishShiftForEditor() { if (state.keyboardMode != KeyboardMode.ENGLISH_26 || state.passwordField || lastComposition.isNotEmpty()) return val next = desiredEnglishShiftState(state.editorInfo) + if (next != state.shiftState) { + state = state.copy(shiftState = next) + keyboardView?.setShiftState(next) + } + // 首字母自动大写 refines the input-type default with the text already + // in the field; off, it leaves the default in place. + refreshEnglishAutoCapitalization() + } + + /** + * 首字母自动大写: after an English edit, Shift is up when the text before + * the cursor ends a sentence and down inside one. Off by default, exactly + * as the reference ships it; a field that demands capitals and the user's + * own Caps Lock are left alone. + */ + private fun refreshEnglishAutoCapitalization() { + if (state.keyboardMode != KeyboardMode.ENGLISH_26 || state.passwordField) return + if (!ImeSettingsRepository.loadFirstLetterCapitalization(this)) return + if (state.shiftState == ShiftState.CAPS_LOCK) return + val info = state.editorInfo + if (info != null && + info.inputType and android.text.InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS != 0 + ) { + return + } + val before = gateway.textBeforeCursor(AutoCapitalizationPolicy.LOOKBACK) ?: return + val next = if (AutoCapitalizationPolicy.atSentenceStart(before)) { + ShiftState.SHIFT_ONCE + } else { + ShiftState.LOWERCASE + } if (next == state.shiftState) return state = state.copy(shiftState = next) keyboardView?.setShiftState(next) 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 822f03fe..5dfef90d 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -22,14 +22,19 @@ object ImeSettingsRepository { private const val KEY_HAPTIC_STRENGTH = "haptic_strength_percent" private const val KEY_HAPTIC_STYLE = "haptic_style" private const val KEY_SOUND_STYLE = "key_sound_style" + private const val KEY_SOUND_VOLUME = "key_sound_volume" private const val KEY_POPUP = "popup" private const val KEY_FUZZY = "fuzzy" private const val KEY_FUZZY_RULES = "fuzzy_rules" private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits" private const val KEY_LETTER_HINTS = "letter_hints" private const val KEY_EMOJI_ASSOCIATION = "emoji_association" + private const val KEY_FIRST_LETTER_CAPITALIZATION = "first_letter_capitalization" + private const val KEY_DOUBLE_SPACE_PERIOD = "double_space_period" + private const val KEY_LETTER_DIGIT_SPACE = "letter_digit_space" private const val KEY_VOICE_STRIP_FILLERS = "voice_strip_fillers" private const val KEY_VOICE_PUNCTUATION_AS_SPACE = "voice_punctuation_as_space" + private const val KEY_BLUETOOTH_HEADSET_MIC = "bluetooth_headset_mic" private const val KEY_PREFERRED_CHINESE_MODE = "preferred_chinese_mode" private const val KEY_KEYBOARD_HEIGHT = "keyboard_height_percent" private const val KEY_LANDSCAPE_KEYBOARD_HEIGHT = "landscape_keyboard_height_percent" @@ -41,6 +46,7 @@ object ImeSettingsRepository { 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" + private const val KEY_CLIPBOARD_CANDIDATE = "clipboard_candidate" private const val KEY_PINYIN_LOCATION = "pinyin_location" /** @@ -141,6 +147,25 @@ object ImeSettingsRepository { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLIPBOARD_CHIP, enabled).apply() } + /** + * 候选栏展示: the clipboard's content offered as a candidate. The + * reference's own title and description for the switch are `string/ + * clipboard_candidate_bar_show_title` 「候选栏展示」 and `string/ + * clipboard_candidate_bar_show_description` 「可在候选栏快速使用剪切内容」. + * Its `enable_clipboard_candidate_bar_show` is a `BooleanPreference` with + * `defaultValue="true"` (`res/cU.xml`, key `0x7f110192`); ours ships off, + * because the owner asked for every personalisation feature of this batch + * to be off until it is switched on — reading the clipboard is exactly the + * kind of thing not to enable on the user's behalf. + */ + fun loadClipboardCandidate(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_CLIPBOARD_CANDIDATE, false) + + fun saveClipboardCandidate(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLIPBOARD_CANDIDATE, enabled).apply() + } + fun loadLandscapeLayout(context: Context): LandscapeLayout = LandscapeLayout.fromKey( context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LANDSCAPE_LAYOUT, null), @@ -253,6 +278,21 @@ object ImeSettingsRepository { .edit().putString(KEY_SOUND_STYLE, key).apply() } + /** 按键音量 0-100; the reference's `key_sound_volume` default is 50. */ + fun loadKeySoundVolume(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_SOUND_VOLUME, SOUND_VOLUME_DEFAULT) + + fun saveKeySoundVolume(context: Context, percent: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putInt(KEY_SOUND_VOLUME, percent.coerceIn(SOUND_VOLUME_MIN, SOUND_VOLUME_MAX)).apply() + } + + /** 按键音量's range and the reference default. */ + const val SOUND_VOLUME_MIN = 0 + const val SOUND_VOLUME_MAX = 100 + const val SOUND_VOLUME_DEFAULT = 50 + fun loadSwipeUpDigits(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_SWIPE_UP_DIGITS, true) @@ -282,6 +322,45 @@ object ImeSettingsRepository { .edit().putBoolean(KEY_EMOJI_ASSOCIATION, enabled).apply() } + /** + * 首字母自动大写: English sentence starts capitalize themselves. The + * reference's `enable_first_letter_capitalization` defaults to false. + */ + fun loadFirstLetterCapitalization(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_FIRST_LETTER_CAPITALIZATION, false) + + fun saveFirstLetterCapitalization(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_FIRST_LETTER_CAPITALIZATION, enabled).apply() + } + + /** + * 双击空格键输入句号: a second space within 200 ms becomes 「。」. The + * reference's `enable_double_click_space` defaults to false. + */ + fun loadDoubleSpacePeriod(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_DOUBLE_SPACE_PERIOD, false) + + fun saveDoubleSpacePeriod(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_DOUBLE_SPACE_PERIOD, enabled).apply() + } + + /** + * 数字、英文前后加空格: a space between Chinese and digits/English. The + * reference's `enable_letter_digit_space` defaults to false. + */ + fun loadLetterDigitSpace(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_LETTER_DIGIT_SPACE, false) + + fun saveLetterDigitSpace(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_LETTER_DIGIT_SPACE, enabled).apply() + } + /** Drop hesitation sounds such as 嗯 / 呃 from voice input. */ fun loadVoiceStripFillers(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -302,6 +381,23 @@ object ImeSettingsRepository { .edit().putBoolean(KEY_VOICE_PUNCTUATION_AS_SPACE, enabled).apply() } + /** + * 使用耳机麦克风录音: record through a headset's microphone when one is + * worn. The reference's `enable_bluetooth_input` says exactly that — + * `string/enable_bluetooth_input_show` 「使用耳机麦克风录音」 with `string/ + * enable_bluetooth_input_show_sub` 「默认使用手机录音。佩戴耳机时,使用耳机 + * 麦克风」 — and it defaults to false: the phone's own microphone is ours + * until the user asks otherwise. + */ + fun loadBluetoothHeadsetMic(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_BLUETOOTH_HEADSET_MIC, false) + + fun saveBluetoothHeadsetMic(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_BLUETOOTH_HEADSET_MIC, enabled).apply() + } + fun loadPopup(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_POPUP, true) diff --git a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt index 6f1caa62..04af7fb5 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -48,6 +48,15 @@ internal data class ArchiveSettings( val emojiAssociation: Boolean = true, val voiceStripFillers: Boolean = true, val voicePunctuationAsSpace: Boolean = false, + val bluetoothHeadsetMic: Boolean = false, + /** 快捷格式输入: the three switches the reference keeps on its own page. */ + val firstLetterCapitalization: Boolean = false, + val letterDigitSpace: Boolean = false, + val doubleSpacePeriod: Boolean = false, + /** 候选栏展示. */ + val clipboardCandidate: Boolean = false, + /** 按键音量, the reference's `key_sound_volume` default. */ + val keySoundVolume: Int = 50, val hapticStrengthPercent: Int = 100, val hapticStyle: String = "crisp", val keySoundStyle: String = "system", @@ -205,6 +214,12 @@ internal object UserDataArchiveCodec { .put("emoji_association", value.emojiAssociation) .put("voice_strip_fillers", value.voiceStripFillers) .put("voice_punctuation_as_space", value.voicePunctuationAsSpace) + .put("bluetooth_headset_mic", value.bluetoothHeadsetMic) + .put("first_letter_capitalization", value.firstLetterCapitalization) + .put("letter_digit_space", value.letterDigitSpace) + .put("double_space_period", value.doubleSpacePeriod) + .put("clipboard_candidate", value.clipboardCandidate) + .put("key_sound_volume", value.keySoundVolume) .apply { value.railSymbols?.let { put("rail_symbols", JSONArray(it)) } } .apply { value.fuzzyRules?.let { put("fuzzy_rules", JSONArray(it)) } } @@ -247,6 +262,15 @@ internal object UserDataArchiveCodec { voiceStripFillers = value.optBoolean("voice_strip_fillers", true), voicePunctuationAsSpace = value.optBoolean("voice_punctuation_as_space", false), + bluetoothHeadsetMic = value.optBoolean("bluetooth_headset_mic", false), + firstLetterCapitalization = value.optBoolean("first_letter_capitalization", false), + letterDigitSpace = value.optBoolean("letter_digit_space", false), + doubleSpacePeriod = value.optBoolean("double_space_period", false), + clipboardCandidate = value.optBoolean("clipboard_candidate", false), + keySoundVolume = value.optInt( + "key_sound_volume", + ImeSettingsRepository.SOUND_VOLUME_DEFAULT, + ), railSymbols = value.optJSONArray("rail_symbols")?.let { array -> RailSymbolRepository.normalize((0 until array.length()).map { array.optString(it) }) }, @@ -389,6 +413,14 @@ internal object UserDataRepository { voiceStripFillers = ImeSettingsRepository.loadVoiceStripFillers(context), voicePunctuationAsSpace = ImeSettingsRepository.loadVoicePunctuationAsSpace(context), + bluetoothHeadsetMic = + ImeSettingsRepository.loadBluetoothHeadsetMic(context), + firstLetterCapitalization = + ImeSettingsRepository.loadFirstLetterCapitalization(context), + letterDigitSpace = ImeSettingsRepository.loadLetterDigitSpace(context), + doubleSpacePeriod = ImeSettingsRepository.loadDoubleSpacePeriod(context), + clipboardCandidate = ImeSettingsRepository.loadClipboardCandidate(context), + keySoundVolume = ImeSettingsRepository.loadKeySoundVolume(context), railSymbols = RailSymbolRepository.load(context) .takeIf { RailSymbolRepository.isCustomized(context) }, fuzzyRules = FuzzyRule.entries @@ -503,6 +535,12 @@ internal object UserDataRepository { context, value.voicePunctuationAsSpace, ) + ImeSettingsRepository.saveBluetoothHeadsetMic(context, value.bluetoothHeadsetMic) + ImeSettingsRepository.saveFirstLetterCapitalization(context, value.firstLetterCapitalization) + ImeSettingsRepository.saveLetterDigitSpace(context, value.letterDigitSpace) + ImeSettingsRepository.saveDoubleSpacePeriod(context, value.doubleSpacePeriod) + ImeSettingsRepository.saveClipboardCandidate(context, value.clipboardCandidate) + ImeSettingsRepository.saveKeySoundVolume(context, value.keySoundVolume) // Backups from before the editable rail carry none; keep the current one. value.railSymbols?.let { RailSymbolRepository.save(context, it) } value.fuzzyRules?.let { keys -> diff --git a/app/src/main/java/llc/slacker/openime/keyboard/AutoCapitalizationPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/AutoCapitalizationPolicy.kt new file mode 100644 index 00000000..15462ef1 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/AutoCapitalizationPolicy.kt @@ -0,0 +1,35 @@ +package llc.slacker.openime.keyboard + +/** + * 首字母自动大写: whether the next English letter starts a sentence. + * + * The reference ships the switch as `enable_first_letter_capitalization` on + * its 快捷格式输入 page (`res/xq.xml`, the `…_list_item` of + * `string/first_letter_capitalization_title` 「首字母自动大写」); the stored + * default is false (`res/cU.xml`: `BooleanPreference defaultValue="false"`). + * With the switch on, the English Shift key must be up after a sentence mark + * or at the start of the field, and down inside a word. + */ +internal object AutoCapitalizationPolicy { + + /** How far back the policy reads; a mark plus following spaces fit easily. */ + const val LOOKBACK = 8 + + /** Sentence enders, half- and full-width, plus an explicit line break. */ + private val sentenceEnders = setOf('.', '!', '?', '…', '。', '!', '?', '\n') + + /** + * True when [textBefore] ends a sentence (or is empty, the field's start), + * so the next letter is a capital. Null means the editor could not be + * read — the policy then leaves Shift alone rather than guessing. + */ + fun atSentenceStart(textBefore: CharSequence?): Boolean { + if (textBefore == null) return false + var end = textBefore.length + // Trailing spaces sit between the mark and the next word; a newline is + // itself an ender, so it is not skipped. + while (end > 0 && textBefore[end - 1].isWhitespace() && textBefore[end - 1] != '\n') end-- + if (end == 0) return true + return textBefore[end - 1] in sentenceEnders + } +} 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 4ab44f03..05215ab6 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt @@ -45,4 +45,16 @@ internal object EnglishShiftPolicy { ShiftState.SHIFT_ONCE -> R.drawable.ic_shift_on ShiftState.CAPS_LOCK -> R.drawable.ic_caps_lock } + + /** + * The accessible name of the Shift key. The tap gesture is ours to draw but + * the reference announces it, so the unlocked states carry its own guide + * text 「双击大写键锁定大写输入」 (`string/caps_lock_guide_text`, + * 0x7f1100de) the way the enter key announces 「发送,长按换行」. + */ + fun description(state: ShiftState): String = when (state) { + ShiftState.LOWERCASE -> "大写,双击锁定大写输入" + ShiftState.SHIFT_ONCE -> "大写一次,双击锁定大写输入" + ShiftState.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 8066eb44..325db244 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -151,9 +151,18 @@ open class ImeKeyboardView( fun onKeyboardHeightChanged(percent: Int) {} fun onHapticStrengthChanged(percent: Int) {} + /** 按键音量 changed; the new percent is already saved and in effect. */ + fun onKeySoundVolumeChanged(percent: Int) {} + /** 震动手感 or 按键音效 changed; the new value is already saved. */ fun onFeedbackStyleChanged() {} + /** 首字母自动大写 changed; the new value is already saved. */ + fun onAutoCapitalizationChanged() {} + + /** 候选栏展示 changed; the new value is already saved. */ + fun onClipboardCandidateChanged() {} + /** 输入拼音显示位置改了(候选栏 ↔ 输入框);新值已经存好。 */ fun onPinyinLocationChanged(location: PinyinLocation) {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} @@ -532,6 +541,7 @@ open class ImeKeyboardView( private var lastStrengthPreviewMs = 0L private val keySounds = KeySounds(context).apply { style = KeySoundStyle.fromKey(ImeSettingsRepository.loadKeySoundStyle(context)) + volume = ImeSettingsRepository.loadKeySoundVolume(context) / 100f } private var popupEnabled = ImeSettingsRepository.loadPopup(context) private var fuzzyEnabled = ImeSettingsRepository.loadFuzzy(context) @@ -877,6 +887,7 @@ open class ImeKeyboardView( currentDoublePinyinScheme = { ImeSettingsRepository.loadDoublePinyinScheme(context) }, currentPinyinLocation = { pinyinLocation }, currentKeySoundStyle = { keySounds.style }, + currentKeySoundVolume = { (keySounds.volume * 100).toInt() }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, currentExtraToggle = ::onState, @@ -905,6 +916,7 @@ open class ImeKeyboardView( onDoublePinyinSchemeChanged = ::setDoublePinyinScheme, onPinyinLocationChanged = ::setPinyinLocation, onKeySoundStyleChanged = ::setKeySoundStyle, + onKeySoundVolumeChanged = ::setKeySoundVolume, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, onFuzzyRulesChanged = listener::onFuzzyRulesChanged, @@ -1991,6 +2003,23 @@ open class ImeKeyboardView( } } + /** Save 按键音量 and click once at the new loudness, rate-limited like the strength preview. */ + private fun setKeySoundVolume(percent: Int) { + val bounded = percent.coerceIn( + ImeSettingsRepository.SOUND_VOLUME_MIN, + ImeSettingsRepository.SOUND_VOLUME_MAX, + ) + if ((keySounds.volume * 100).toInt() == bounded) return + keySounds.volume = bounded / 100f + ImeSettingsRepository.saveKeySoundVolume(context, bounded) + listener.onKeySoundVolumeChanged(bounded) + val now = SystemClock.uptimeMillis() + if (now - lastStrengthPreviewMs >= STRENGTH_PREVIEW_INTERVAL_MS) { + lastStrengthPreviewMs = now + keySounds.play() + } + } + /** 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 = @@ -2182,6 +2211,7 @@ open class ImeKeyboardView( keyHaptics.strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) keyHaptics.style = HapticStyle.fromKey(ImeSettingsRepository.loadHapticStyle(context)) keySounds.style = KeySoundStyle.fromKey(ImeSettingsRepository.loadKeySoundStyle(context)) + keySounds.volume = ImeSettingsRepository.loadKeySoundVolume(context) / 100f if (heightChanged) { keyboardHeightPercent = persistedHeight renderedMode = null @@ -2857,12 +2887,17 @@ open class ImeKeyboardView( "按键气泡" -> popupEnabled "上滑输入数字" -> ImeSettingsRepository.loadSwipeUpDigits(context) "复制内容提示" -> ImeSettingsRepository.loadClipboardChip(context) + "候选栏展示" -> ImeSettingsRepository.loadClipboardCandidate(context) "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context) && context.checkSelfPermission(android.Manifest.permission.READ_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED "数字和符号提示" -> ImeSettingsRepository.loadLetterHints(context) + "首字母自动大写" -> ImeSettingsRepository.loadFirstLetterCapitalization(context) "智能联想" -> ImeSettingsRepository.loadEmojiAssociation(context) + "数字、英文前后加空格" -> ImeSettingsRepository.loadLetterDigitSpace(context) + "双击空格键输入句号" -> ImeSettingsRepository.loadDoubleSpacePeriod(context) "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) "标点用空格代替" -> ImeSettingsRepository.loadVoicePunctuationAsSpace(context) + "使用耳机麦克风录音" -> ImeSettingsRepository.loadBluetoothHeadsetMic(context) else -> true } @@ -2909,9 +2944,25 @@ open class ImeKeyboardView( renderedMode = null if (panel == Panel.NONE) renderModeBody() } + // Read at commit time, so both of these need no listener round trip. + "数字、英文前后加空格" -> ImeSettingsRepository.saveLetterDigitSpace(context, enabled) + "双击空格键输入句号" -> ImeSettingsRepository.saveDoubleSpacePeriod(context, enabled) + "候选栏展示" -> { + ImeSettingsRepository.saveClipboardCandidate(context, enabled) + // The row is the service's; it re-reads the clipboard right away. + listener.onClipboardCandidateChanged() + } "智能联想" -> ImeSettingsRepository.saveEmojiAssociation(context, enabled) + "首字母自动大写" -> { + ImeSettingsRepository.saveFirstLetterCapitalization(context, enabled) + // The Shift key is the service's to paint; it re-reads the text + // around the cursor for the field that is up right now. + listener.onAutoCapitalizationChanged() + } "语音去语气词" -> ImeSettingsRepository.saveVoiceStripFillers(context, enabled) "标点用空格代替" -> ImeSettingsRepository.saveVoicePunctuationAsSpace(context, enabled) + // Read when the next recording starts, so it needs no listener. + "使用耳机麦克风录音" -> ImeSettingsRepository.saveBluetoothHeadsetMic(context, enabled) } } @@ -3628,11 +3679,7 @@ open class ImeKeyboardView( ShiftState.CAPS_LOCK -> "key-shift-caps" } setIcon(EnglishShiftPolicy.icon(shiftState)) - contentDescription = when (shiftState) { - ShiftState.LOWERCASE -> "大写" - ShiftState.SHIFT_ONCE -> "大写一次" - ShiftState.CAPS_LOCK -> "大写锁定" - } + contentDescription = EnglishShiftPolicy.description(shiftState) } applyShiftTheme(shift) } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt index 57b72500..4fdc9e97 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt @@ -26,16 +26,22 @@ internal class KeySounds(context: Context) { if (value != KeySoundStyle.SYSTEM) ensureLoaded(value) } + /** + * 0f-1f, from the 按键音量 setting (the reference's `key_sound_volume`, + * default 50). Applied to both the bundled samples and the system click. + */ + var volume: Float = 0.5f + fun play() { val current = style if (current == KeySoundStyle.SYSTEM) { - runCatching { audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, DEFAULT_VOLUME) } + runCatching { audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, volume) } return } val sample = ensureLoaded(current) ?: return // A click requested before its sample finished decoding is dropped, // not queued: a late click would sound like lag. - if (sample in ready) pool?.play(sample, 1f, 1f, 1, 0, 1f) + if (sample in ready) pool?.play(sample, volume, volume, 1, 0, 1f) } fun release() { @@ -70,8 +76,5 @@ internal class KeySounds(context: Context) { private companion object { /** Fast typing overlaps a few clicks; more would only pile up noise. */ const val MAX_STREAMS = 4 - - /** playSoundEffect's "use the default UI click volume" value. */ - const val DEFAULT_VOLUME = -1f } } 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 8662c99d..2ca89e09 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -67,6 +67,7 @@ internal class SettingsPanelController( private val currentDoublePinyinScheme: () -> DoublePinyinScheme, private val currentPinyinLocation: () -> PinyinLocation, private val currentKeySoundStyle: () -> KeySoundStyle, + private val currentKeySoundVolume: () -> Int, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, /** State of the toggles added after the first four, looked up by their label. */ @@ -87,6 +88,7 @@ internal class SettingsPanelController( private val onDoublePinyinSchemeChanged: (DoublePinyinScheme) -> Unit, private val onPinyinLocationChanged: (PinyinLocation) -> Unit, private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, + private val onKeySoundVolumeChanged: (Int) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, /** A 模糊音 pair was switched; the new set is already saved. */ @@ -211,6 +213,7 @@ internal class SettingsPanelController( content.addCard( toggleRow("按键音效", "按键时播放提示音", R.drawable.ic_pref_sound), soundStyleRow(NESTED_ROW), + sliderRow("按键音量", NESTED_ROW, ImeSettingsRepository.SOUND_VOLUME_MIN, ImeSettingsRepository.SOUND_VOLUME_MAX, currentKeySoundVolume(), ImeSettingsRepository.SOUND_VOLUME_DEFAULT, onChange = onKeySoundVolumeChanged), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), hapticStyleRow(NESTED_ROW), sliderRow("震动强度", NESTED_ROW, ImeGeometryTokens.HAPTIC_STRENGTH_MIN_PERCENT, ImeGeometryTokens.HAPTIC_STRENGTH_MAX_PERCENT, currentHapticStrengthPercent(), ImeGeometryTokens.HAPTIC_STRENGTH_DEFAULT_PERCENT, onChange = onHapticStrengthChanged), @@ -218,9 +221,16 @@ 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_space), + toggleRow("首字母自动大写", "英文句子开头自动切换到大写", R.drawable.ic_pref_case), + toggleRow("双击空格键输入句号", "连按两次空格键输入句号", R.drawable.ic_pref_symbols), + ) content.addSection("工具栏提示") content.addCard( toggleRow("复制内容提示", "刚复制的文字显示在工具栏,点一下输入", R.drawable.ic_pref_clipboard), + toggleRow("候选栏展示", "可在候选栏快速使用剪切内容", R.drawable.ic_pref_clipboard), toggleRow("短信验证码", "最近 5 分钟的验证码显示在工具栏,需要短信权限", R.drawable.ic_pref_shield), ) content.addSection("智能输入") @@ -232,6 +242,7 @@ internal class SettingsPanelController( content.addCard( toggleRow("语音去语气词", "去掉“嗯”“呃”等口头停顿", R.drawable.ic_pref_waveform), toggleRow("标点用空格代替", "语音里的逗号、句号等写成空格", R.drawable.ic_pref_space), + toggleRow("使用耳机麦克风录音", "默认使用手机录音。佩戴耳机时,使用耳机麦克风", R.drawable.ic_pref_mic), ) content.addSection("关于与数据") content.addCard( @@ -260,6 +271,7 @@ internal class SettingsPanelController( content.addSection("按键反馈") content.addCard( soundStyleRow(0), + sliderRow("按键音量", 0, ImeSettingsRepository.SOUND_VOLUME_MIN, ImeSettingsRepository.SOUND_VOLUME_MAX, currentKeySoundVolume(), ImeSettingsRepository.SOUND_VOLUME_DEFAULT, onChange = onKeySoundVolumeChanged), hapticStyleRow(0), ) content.addSection("横屏") @@ -276,7 +288,11 @@ internal class SettingsPanelController( content.addSection("输入") content.addCard( toggleRow("上滑输入数字", null, 0), + toggleRow("首字母自动大写", null, 0), + toggleRow("数字、英文前后加空格", null, 0), + toggleRow("双击空格键输入句号", null, 0), toggleRow("复制内容提示", null, 0), + toggleRow("候选栏展示", null, 0), toggleRow("短信验证码", null, 0), toggleRow("智能联想", null, 0), navigationRow("模糊音与智能纠错", null, 0, onShowFuzzySettings), @@ -285,6 +301,7 @@ internal class SettingsPanelController( content.addCard( toggleRow("语音去语气词", null, 0), toggleRow("标点用空格代替", null, 0), + toggleRow("使用耳机麦克风录音", null, 0), ) content.addSection("关于与数据") content.addCard( diff --git a/app/src/main/res/drawable/ic_pref_case.xml b/app/src/main/res/drawable/ic_pref_case.xml new file mode 100644 index 00000000..b98412fd --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_case.xml @@ -0,0 +1 @@ + diff --git a/app/src/test/java/llc/slacker/openime/AutoCapitalizationPolicyTest.kt b/app/src/test/java/llc/slacker/openime/AutoCapitalizationPolicyTest.kt new file mode 100644 index 00000000..6365a012 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/AutoCapitalizationPolicyTest.kt @@ -0,0 +1,61 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.AutoCapitalizationPolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 首字母自动大写: where the English Shift key comes up by itself. */ +class AutoCapitalizationPolicyTest { + + @Test + fun anEmptyFieldOrAnUnreadableOne() { + // Start of the field: the first letter is a capital. + assertTrue(AutoCapitalizationPolicy.atSentenceStart("")) + // The editor could not be read; the policy must leave Shift alone. + assertFalse(AutoCapitalizationPolicy.atSentenceStart(null)) + } + + @Test + fun aSentenceMarkEndsTheSentence() { + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Hello.")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Hi!")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Why?")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Hi…")) + // The spaces a keyboard inserts after the mark are not the mark. + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Hello. ")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Hi! ")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("Hi.\t")) + } + + @Test + fun aFullWidthMarkCountsTooAndALineBreakIsItsOwnMark() { + assertTrue(AutoCapitalizationPolicy.atSentenceStart("你好。")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("好!")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("好?")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("first line\n")) + // The newline is the mark, so the space after it is skipped like any other. + assertTrue(AutoCapitalizationPolicy.atSentenceStart("first line\n ")) + } + + @Test + fun insideTheSentenceShiftStaysDown() { + assertFalse(AutoCapitalizationPolicy.atSentenceStart("Hello")) + assertFalse(AutoCapitalizationPolicy.atSentenceStart("Hello wor")) + // A comma joins clauses; it does not end a sentence. + assertFalse(AutoCapitalizationPolicy.atSentenceStart("Hello, ")) + assertFalse(AutoCapitalizationPolicy.atSentenceStart("Hello;")) + } + + @Test + fun onlySpacesReadAsTheStartOfTheField() { + assertTrue(AutoCapitalizationPolicy.atSentenceStart(" ")) + assertTrue(AutoCapitalizationPolicy.atSentenceStart("\t")) + } + + /** The window the caller reads must hold a mark plus the spaces after it. */ + @Test + fun theLookbackHoldsAMarkWithItsTrailingSpaces() { + assertTrue(AutoCapitalizationPolicy.LOOKBACK >= 4) + } +} diff --git a/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt index 903215da..c00aa65a 100644 --- a/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt @@ -49,4 +49,13 @@ class EnglishShiftPolicyTest { assertEquals(ShiftState.CAPS_LOCK, EnglishShiftPolicy.afterLongPress(ShiftState.SHIFT_ONCE)) assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.afterLongPress(ShiftState.CAPS_LOCK)) } + + @Test + fun theGuideTextAnnouncesTheDoubleTapWhereItLocks() { + // The reference's own string/caps_lock_guide_text, kept out of the state + // that already says what it is. + assertEquals("大写,双击锁定大写输入", EnglishShiftPolicy.description(ShiftState.LOWERCASE)) + assertEquals("大写一次,双击锁定大写输入", EnglishShiftPolicy.description(ShiftState.SHIFT_ONCE)) + assertEquals("大写锁定", EnglishShiftPolicy.description(ShiftState.CAPS_LOCK)) + } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 296b39ae..25a0a4c0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -372,3 +372,30 @@ Add new entries at the end. - **The candidate row recommends the poem line the composition spells.** The reference ships a poetry grammar — `assets/dict/8ce02a28dedf`, type 11010 `sys_poem_gram`, whose loader is `FUN_003f1258` — and the app decodes it end to end: the data section walks all 351,270 bytes with no residue as LINE records `[u32 line_id][u16 0x0190][u16 nchar][u16 chars ×nchar/2][u16 nsyl][u16 syls ×nsyl/2]` followed by association groups `[u16 glen][ENTRY ×k]`, an ENTRY being `[u32 ref_id ×1 or 2][u16 punctuation ×2 or 3][u16 0x0190]` whose reference is the line that follows in the same poem (`doubaoime-teardown/DECODE-BATCH-D.md` §4, `poem-decoded.tsv` 5,966 lines, `poem-assoc.tsv` 10,542 groups / 10,687 entries, the samples `春眠不觉晓` → `处处闻啼鸟`, `落日故人情` → `挥手自兹去`). Its own settings text puts poetry among what 联想 recommends — 「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」 (`resources.arsc`, next to the switch 「智能联想」 this port already has) — and the dictionary's index is `53,921 × 9B` of `(u8 key, u32 child, u32 data_off)` whose leaves all point at line starts, i.e. a trie over the letters the lines are typed with, not over their characters. This port had no poetry at all. `scripts/import_doubao_data.py --poems-only` now generates `poem/lines.tsv` from those two decodes — 5,498 lines of four Han characters or more, each with its syllable sequence and the line that follows it (5,042 of them) — and `PoemIndex` answers `CandidatePipeline.candidatesFor` for 26-key compositions, behind the engine's own candidates (`withPoemLines`). *Reason:* the reference recommends a line while its pinyin is being typed, which is a candidate, not a post-commit chip — this port's association row exists only after a commit by its own contract (`ImeKeyboardView.Listener.onAssociationSelected`), so the line ships as a candidate the same way the engine's words do. Three syllables is the floor, measured against this port's own tables rather than guessed: of the 3,000 most frequent pinyin keys, 380 (12.7%) are a two-syllable prefix of some line and 3 (0.1%) are a three-syllable one — `春风` would recommend a line all day, `chunfengbu` would not. The line the composition spells out completely ranks before the lines it merely starts and brings its own next line with it, and the whole layer follows the 智能联想 switch, which is the reference's own grouping. Three parts of the feature are deliberately not ported: the reference's 计算结果 has no counterpart here (this port has no expression candidates), the lines shorter than four characters are dropped (42 single-character and 107 two-character entries are ordinary typing, not poetry), and nine-key is left out — its composition is digits, and the poem lookup is a pinyin one. The association punctuation (the 2 or 3 marks per entry) is written out but unused: the reference's decoder keeps the field, and nothing in the data says which mark joins which pair. + +## 2026-10-16 + +- **首字母自动大写 ships as the reference's own switch, off by default.** + The reference keeps the feature behind `enable_first_letter_capitalization`: a `BooleanPreference` with `defaultValue="false"` in its preferences XML (`res/cU.xml`; the raw path of the file apktool names `res/xml/settings_preferences.xml`, line 20) and a `SwitchPreference` with the same default on the 输入设置 page (`root_preferences.xml:33`). The switch itself sits on its 快捷格式输入 page (`res/xq.xml` — the raw path of the layout apktool names `res/layout/fragment_key_auxiliary_input.xml`, which also carries the 字母数字空格 and 双击空格句号 rows), titled `string/first_letter_capitalization_title` 「首字母自动大写」. This port has had no auto-capital at all since 0.0.2-beta.1, which removed it deliberately: English mode never capitalizes on its own and the caps-only fields get Caps Lock from the editor contract (`EnglishShiftPolicy.initial`). The switch turns it back on for the user who asks for it, and defines its behaviour as: an empty field, or text before the cursor ending in `.`, `!`, `?`, `…`, `。`, `!`, `?` or a line break — spaces between the mark and the cursor ignored — puts Shift up once; anything else keeps it down. A field that demands capitals (`TYPE_TEXT_FLAG_CAP_CHARACTERS`) and a Caps Lock the user set are left alone, and the reading is re-taken at every text change (letter and symbol commits, backspace, space, enter and its long-press newline, candidate commits, a new editor, and the switch to English 26-key). + *Reason:* the feature must stay opt-in, because the reference's own default is off and this port already decided against auto-capitals as a always-on behaviour; matching a switch's default keeps that decision intact while the reference's grouping (its 快捷格式输入 page) is where our row now sits too. `AutoCapitalizationPolicy.LOOKBACK` reads 8 characters, which holds a mark plus the spaces a keyboard inserts after it; the ender set is the reference's own sentence marks plus the newline this port's enter key writes. The reading is skipped in password fields and whenever the editor cannot be read, so an unreadable field never guesses a capital. +- **The Shift key announces the reference's own guide text.** + The reference's `string/caps_lock_guide_text` is 「双击大写键锁定大写输入」 (id `0x7f1100de`, `res/values/strings.xml:233`, `res/values/public.xml:11168`), and it is the reference that announces this port's double-tap-to-lock gesture: `EnglishShiftPolicy.description` now reads 「大写,双击锁定大写输入」 / 「大写一次,双击锁定大写输入」 / 「大写锁定」 where it said 「大写」 / 「大写一次」 / 「大写锁定」. + *Reason:* the double tap is a gesture this port draws from its own policy (`EnglishShiftPolicy.DOUBLE_TAP_MS`), but the reference's string is exactly the guide a screen reader needs for it — the same way the enter key already announces 「发送,长按换行」 from the reference's `enable_key_enter_sub_title`. The locked state keeps the shorter name: the guide belongs to the unlocked states where the double tap still has to be taught. +- **按键音量 is the reference's 0-100 setting, default 50, applied to both click sources.** + `key_sound_volume` is an `IntPreference` with `defaultValue="50"` (`res/cU.xml`), stored next to the sound master switch `enable_key_sound` (`defaultValue="false"`). The port had no volume of its own: the bundled samples always played at 1.0 and the system click passed `-1`, `playSoundEffect`'s "use the default UI click volume" value. The setting now scales both (`KeySounds.volume`, 0.0-1.0 from the stored percent) and appears as a 0-100 slider under 按键音效 in both settings surfaces. + *Reason:* the reference gives the user this one number rather than leaving loudness to the system stream, and 50 is its own default — an install that never touches the slider is deliberately half-loudness, which is also roughly where the system's default click sits. Keeping a single stored percent means the bundled samples and the system click cannot drift apart. +- **数字、英文前后加空格 goes in the commit funnel, and the reference's own default is unreadable.** + The reference's 快捷格式输入 page (`res/xq.xml`) carries the row as an `ImeListItemView` whose title and subtitle *are* the rule: `string/letter_digit_space_list_item_title` 「数字、英文前后加空格」 and `string/letter_digit_space_list_item_subtitle` 「在语音与打字输入时,在中文和数字、英文间将增加空格」. There is no `enable_letter_digit_space` in `res/cU.xml`'s preference list and no such string anywhere in the APK — only the row's own id, `enable_letter_digit_space_list_item_id` (`0x7f0a0301`), whose `R` field R8 inlined, so the switch's default cannot be read from the reference at all. The port inserts the space in [InputConnectionGateway.commitText], the single funnel every text commit already goes through, reading one character on each side of the cursor once per commit — so voice transcripts, candidates and typed letters all get it, exactly as the subtitle promises. `LetterDigitSpacePolicy` decides: one side Han ideograph, the other an ASCII letter or digit. + *Reason:* one funnel means no commit path can be left out, and the two extra cursor reads happen only while the switch is on. Han ideographs and ASCII only: the reference's subtitle names 中文 and 数字、英文, so Chinese punctuation (`。`, `,`) and full-width figures do not count as either. The switch ships off because the owner asked for this batch's personalisation features to be off by default, and because the reference's own value for it cannot be cited either way. +- **双击空格键输入句号 copies the reference's 200 ms window and its 31-character gate.** + The handler is `InputViewRoot$z.invoke` (classes.dex, code_off `0x4c1764`): on the space key's up event it compares `now - lastSpaceTime` against `const-wide/16 0xc8` — 200 ms — and on a hit calls `KeyboardJni.commitForSpaceDoubleClick(!isPunct(charBeforeFirstSpace), true)`. The punctuation it tests is a 31-element array built beside the handler: space, `,。?!;:`, both quote pairs, `()【】《》` and their half-width forms `,.?!;:"'()[]<>`. `enable_double_click_space` is a `BooleanPreference` with `defaultValue="false"` (`res/cU.xml`; the row's label is `string/double_click_space_to_input_period` 「双击空格键输入句号」). The port keeps the timestamp and the character that preceded the first space in the service, reads that character *before* committing the space, and on the second space deletes the space and writes 「。」. + *Reason:* 200 ms is short enough that two deliberate spaces on a Chinese keyboard still mean two spaces, and the punctuation gate is what keeps 「, 」 a space rather than turning it into 「,。」 — a doubled space after a mark is a formatting choice, not a sentence end. Reading the character before the space rather than after it is the only reading that makes the gate do anything at all. +- **算式出结果 rides 智能联想 and replaces its own expression.** + The reference computes results natively — `keyboard::InputModel::Impl::AssociateCalculator` (libkeyboard.so `0x26523c`), `GetCalculatorCandidate` (`0x255ca4`), `CommitCalculatorCand` (`0x264594`), whose `cand_length` argument is the expression being replaced. Its 智能联想 switch names the result among what it offers: `string/associate_close_content` 「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」. The port reads the trailing run of `0-9.+-*/()×÷` before the cursor (up to 48 characters), evaluates it with a small recursive-descent parser (`+ - * /` with precedence, unary sign, brackets, full-width digits and `+-×÷()`), and offers the result only for a finished expression: a trailing operator, no operator at all, unbalanced brackets and division by zero give nothing. The chip carries the bare result, and tapping it deletes the expression and writes the result. + *Reason:* the switch to ride is not a choice — the reference's own words put 计算结果 under 智能联想, the same switch 诗句联想 already rides. The bare result is what the reference shows: neither its resources nor `libkeyboard.so` holds a 「=」 display string. Offering nothing for an unfinished expression matters because the row is recomputed on every keystroke; a result for `1+` would flicker under the user's fingers. The reference's exact grammar and decimal places are not byte-for-byte reproduced — the visible behaviour is what this port matches. +- **候选栏展示 ships off although the reference ships it on.** + The reference's switch is `enable_clipboard_candidate_bar_show`, a `BooleanPreference` with `defaultValue="true"` (`res/cU.xml`, key id `0x7f110192`), titled `string/clipboard_candidate_bar_show_title` 「候选栏展示」 with `string/clipboard_candidate_bar_show_description` 「可在候选栏快速使用剪切内容」. The port shows the clip as a candidate chip when the switch is on, the field is not a password and the clipboard holds text; the chip carries the whole clip (the row clips it to one line) and a tap writes it and clears the row. It sits next to 复制内容提示 in the settings, which is the toolbar chip and a separate switch. + *Reason:* this is the one place of the batch where the port deliberately differs from the reference's default — the owner asked for every personalisation feature here to be off until switched on, and reading the clipboard is exactly the kind of behaviour not to wake up on the user's behalf. Keeping the chip's text the whole clip while the row shows one line is what makes "tap to use what was copied" true for a copied paragraph. +- **使用耳机麦克风录音 is a routing switch, not a new recorder.** + `enable_bluetooth_input` is a `BooleanPreference` with `defaultValue="false"` (`res/cU.xml`, key id `0x7f11018e`); its own words are `string/enable_bluetooth_input_show` 「使用耳机麦克风录音」 and `string/enable_bluetooth_input_show_sub` 「默认使用手机录音。佩戴耳机时,使用耳机麦克风」 — it is about the headset's microphone, not about a physical keyboard. `VoiceAudioRouteManager` already preferred an external device on Android 12+ (`setCommunicationDevice`, BLE / SCO / wired / USB), but only when nothing else had set one; `VoiceInputRoutePolicy` now decides from the switch instead: on, the best available headset; off, the phone's own microphone, and only when something else had routed recording to a headset at all. + *Reason:* the reference's promise is "the phone's microphone by default", so the off state has to be able to take the route back from a headset another app selected — but it must not touch a route that is already the phone's, or every recording would come with a mode change it did not need. Below Android 12 nothing is touched, keeping the port's existing trade-off: forcing SCO there costs a ~500 ms recording start-up. 外接键盘 has no counterpart in the reference's settings, and this port's existing hardware-key path (`HardwareKeyPolicy`) is unchanged. From 3070e33c53eedbd742210f501cb810df7e431a9a Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:55:10 +0800 Subject: [PATCH 89/94] Give nine-key the reference's reading list and its digit-level fuzzy The reference enumerates the whole open digit tail on every key press and cuts nothing. Its left-list builder (libshell.so 0x5ba1e0) pushes one string per marked syllable id, then the letters of the open head key -- each with its `+0x20` twin (0x5ba6c4) -- and the leading digit item, reserving only (marked + 8) at 0x5ba430; decoding syllable.dat end to end gives 64 -> 10 items, 9426 -> 18, 5426 -> 15. Fuzzy widens the lattice at the digit level rather than selecting a lookup: every data3 record carries the smallest rule mask that reaches its spelling and pass2 (0x5b962c) admits it while `mask` is a subset of the reader's fuzzy set, so node 64 carries `li` (0x0008), 426 carries `fang` (0x0050), and 9426 runs from 19 readings to 32 with every rule on. This port listed syllables only, offered letters for a single key press only, cut the rail at 12 and never used fuzzy while decoding, so those readings could not appear at all. The rail now lists the head key's syllable-capable letters for a tail of any length (i/u/v stay out as dead ends), the rail bound goes 12 -> 48, the reading bound 12 -> 24 and the librime reading count 6 -> 12; the decoder files one fuzzy-variant entry per syllable under its plain spelling's digits carrying the rules it needs, admitted only while all of them are on, while the rail keeps plain spellings the way the reference's left builder does (mask == 0, 0x5ba378). ENGINE-SPEC.md:222 records the 0x6c0508 family as a nine-key fuzzy path; the disassembly places it in the wubi engine, so that line is recorded as wrong and not followed. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + .../slacker/openime/LocalVoiceImeService.kt | 10 +- .../openime/candidate/NineKeyLocalDecoder.kt | 157 +++++++++++++++--- .../keyboard/NineKeySymbolRailController.kt | 8 +- .../slacker/openime/CandidatePipelineTest.kt | 20 +++ .../llc/slacker/openime/NineKeyChineseTest.kt | 30 ++++ docs/DECISIONS.md | 3 + 7 files changed, 205 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b19c6793..b938c4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 九键的拼音组合补到参考实现的规模。参考每按一次键都对整条开放数字尾做全量格枚举,三处一起给出「组合」:左栏构造器(libshell.so `0x5ba1e0`)把音节、**首键字母**(每个字母连同 `add w8, w8, #0x20` 的大小写孪生各一条,`0x5ba608`–`0x5ba704`)和首位数字项 push 进同一个 vector,全程没有 `take`,只有 `reserve(新标记数 + 8)`(`0x5ba430`)——逐条解码 `syllable.dat` 的数:`64` 十项、`9426` 十八项、`5426` 十五项;**模糊音在数字层就扩充读法**,data3 每条记录带「达到这条读法所需的最小模糊掩码」,pass2(`0x5b962c`)按 `mask ⊆ 用户fuzzy` 放行,于是节点 `64` 在 n↔l 打开时多一条 `li`(data3[275],mask 0x0008)、节点 `426` 多出 `fang`(0x0010)、`hang`(0x0040),全开时 `9426` 的读法从 19 涨到 32、`5426` 从 18 涨到 30;候选侧同样是全量枚举,只有一条「单调最远前沿」剪枝,没有计数上限。我们这边左栏只列音节、字母仅在下一次按键时给、`readingOptions` 截 12 条(喂给 librime 的读数只取 6 条),而模糊音只传给了词表查询、解码一条都不用,所以 `li`、`xiang` 这类读法永远出不来,`64` 的左栏只有 3 条。现在照参考补齐:左栏对任意长度的开放尾都列出首键能起音节的字母(`i`/`u`/`v` 起不了音节,仍是死路不列;参考另推的大写孪生是九键英文用的,按已定范围不做),上限 12→48(参考不截断,这里是滚动列表的上界);解码器按参考 data3 的结构给每个音节建「模糊变体」条目——挂在原音节的数字节点下、带着所需规则集,查询时只有规则全开才放行——`64` 开 n↔l 后给出 `li` 与「里」,`9426` 给出 `xiang`/`zhang`/`zang`/`zan`/`zao`;路径上限 12→24,送 librime 的读数 6→12(参考无上限,我们每条读数一次 JNI 查询,保留上限但放宽)。左栏仍只收明文拼写(参考的左栏构造器只取 mask 为 0 的记录,`0x5ba378`),所以模糊读法落在候选与拆分里,不进左栏。另记一笔:`ENGINE-SPEC.md:222` 把「独立模糊展开路径 0x6c0508 族」记成九键的是错的,那一族属于五笔引擎(`oime_engine::wubi::Engine`,调用点 `0x6b394c`),按「他们错了,我们保持一致」处理,不照抄。 - 数字、英文前后加空格:参考在「快捷格式输入」页上的第三个开关,标题与说明就是这条规则本身——`string/letter_digit_space_list_item_title`「数字、英文前后加空格」、`string/letter_digit_space_list_item_subtitle`「在语音与打字输入时,在中文和数字、英文间将增加空格」,这一行在参考里只有那个行 id(`enable_letter_digit_space_list_item_id`)——`res/cU.xml` 的偏好表里没有它的开关,整个 APK 也找不到 `enable_letter_digit_space` 这个键,所以它的默认值读不出来;按「个性化功能默认关闭」定成默认关。我们在唯一的落字通道(`InputConnectionGateway.commitText`)里补空格:落字前读一次光标左右的字符,一侧是汉字、另一侧是 ASCII 字母或数字时补一个空格;汉字侧的标点(`。`、`,`)不算汉字,空格本身也不会再加一个空格;语音上屏走同一条通道,与参考文案里的「在语音与打字输入时」一致。开关默认关,关着的时候这条通道只多两次读光标(都只在开关打开时才读)。 - 双击空格键输入句号:参考的处理器在 `InputViewRoot$z.invoke`(classes.dex code_off 0x4c1764):空格抬起时 `now - lastSpaceTime` 与 `const-wide/16 0xc8` 比较,也就是 **200 毫秒**,命中就调 `KeyboardJni.commitForSpaceDoubleClick(!isPunct(charBeforeFirstSpace), true)`;判定用的标点是一张 31 个字符的表(空格、`,。?!;:`、两对引号、`()【】《》` 与半角 `,.?!;:"'()[]<>`),落在这些字后面的第二个空格仍是空格。我们照抄这套:服务记下第一个空格落下的时刻与它前面那个字符(落字前读),第二个空格落在 200 毫秒内且前面不是标点时,删掉那个空格写上「。」。存储项 `enable_double_click_space` 默认关(另见 `res/xq.xml` 的 `string/double_click_space_to_input_period`「双击空格键输入句号」)。 - 算式出结果:光标前是一段写完的算式时,候选栏给出结果(打 `1+2*3` 出 `7`),点一下用结果换掉算式。参考这条在 native 里:`keyboard::InputModel::Impl::AssociateCalculator`(libkeyboard.so 0x26523c)、`GetCalculatorCandidate`(0x255ca4)与 `CommitCalculatorCand`(0x264594)——后者带一个 `cand_length`,正是「用结果换掉整段算式」这个动作。它自己的设置文案把「计算结果」列在联想的推荐内容里:「关闭联想后,将不会推荐诗词、计算结果、emoji表情。」(`string/associate_close_content`),所以这一层跟着「智能联想」走,没有单独的开关。我们读光标前最多 48 个字符里那一段由 `0-9.+-*/()×÷` 组成的尾巴,用一个小递归下降解析器求值(`+ - * /` 带优先级、一元负号、括号,全角数字与 `+-×÷()` 也认),写完才给结果:尾巴是运算符、没有运算符、括号不配、除以零都不出;结果是整数就照原样,否则最多六位小数、去掉末尾的零。显示上只给结果本身——参考的资源与 native 库里都没有「=」这个显示用字串。参考的算式文法与小数位数我们没逐字节还原,只按可见行为对齐。 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index d3021202..76be374e 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -2259,7 +2259,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can const val TAG = "OpenIme" const val CANDIDATE_REFRESH_IDLE_POLL_MS = 16L const val MAX_RIME_INPUT_LENGTH = 256 - const val MAX_RIME_NINE_KEY_PATHS = 6 + + /** + * Readings handed to librime per key press. The reference enumerates its + * whole nine-key lattice with no count limit; each reading here costs one + * JNI query, so this stays a bound — but a small one starved the + * candidates the owner missed, because the readings a nine-key press + * offers run past the two or three a plain split produces. + */ + const val MAX_RIME_NINE_KEY_PATHS = 12 const val MAX_CANDIDATES = 96 const val TYPO_CORRECTION_NATIVE_THRESHOLD = 8 @Volatile diff --git a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt index 25a02655..0cc0ccb1 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -1,5 +1,6 @@ package llc.slacker.openime.candidate +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.ImeData import kotlin.math.ln import kotlin.math.roundToInt @@ -26,6 +27,16 @@ internal class NineKeyLocalDecoder( val candidates: List, val phrase: Boolean, val weight: Int, + /** + * Fuzzy rules a reader must have switched on for this reading. Empty + * for a plain spelling. The reference keeps the same field on every + * syllable record (`syllable.dat` data3, `fuzzy` u16 — n↔l 0x0008, + * f↔h 0x0010, an↔ang 0x0040, zh↔z 0x0001, ian↔iang 0x0200) and lets a + * record in only when `mask ⊆ 用户fuzzy` (pass2 of CreateLattice9Key + * 0x5b962c): node "64" carries `li` with 0x0008, node "426" carries + * `fang` with 0x0050. + */ + val mask: Set = emptySet(), ) private data class Decode( @@ -73,9 +84,14 @@ internal class NineKeyLocalDecoder( private var previousDigits = "" private var previousPreview = "" + /** Plain spellings -> their characters, for the fuzzy variants that copy a reading. */ + private val plainValuesByPinyin = LinkedHashMap>() + init { val entries = buildEntries() - entries.forEach(::insert) + val variants = fuzzyVariantEntries(entries) + val all = entries + variants + all.forEach(::insert) sortTrie(root) buildReadings(entries) entries.forEach { entry -> @@ -109,8 +125,8 @@ internal class NineKeyLocalDecoder( } val node = nodeFor(bounded) - val exactEntries = node?.exact.orEmpty().take(MAX_PATHS) - val decoded = decodePaths(bounded) + val exactEntries = usableEntries(node, fuzzy).take(MAX_PATHS) + val decoded = decodePaths(bounded, fuzzy) val preferred = preferredSuffix ?.lowercase() ?.trim() @@ -366,7 +382,10 @@ internal class NineKeyLocalDecoder( for (end in start until minOf(n, start + MAX_SYLLABLE_LENGTH)) { node = node.children[bounded[end]] ?: break val chunk = bounded.substring(start, end + 1) - val whole = node.exact.filter(::isSyllableEntry).sortedByDescending(::entryScore).take(SPELL_BRANCHES) + val whole = node.exact + .filter { it.mask.isEmpty() && isSyllableEntry(it) } + .sortedByDescending(::entryScore) + .take(SPELL_BRANCHES) for (before in from) { for (entry in whole) { val parts = before.parts + entry.pinyin @@ -450,7 +469,7 @@ internal class NineKeyLocalDecoder( var node = root for (end in start until minOf(digits.length, start + MAX_SYLLABLE_LENGTH)) { node = node.children[digits[end]] ?: break - if (node.exact.any(::isSyllableEntry)) reachable[end + 1] = true + if (node.exact.any { it.mask.isEmpty() && isSyllableEntry(it) }) reachable[end + 1] = true } } return reachable[digits.length] @@ -478,7 +497,10 @@ internal class NineKeyLocalDecoder( var node = root for (end in start until minOf(n, start + MAX_SYLLABLE_LENGTH)) { node = node.children[digits[end]] ?: break - val syllables = node.exact.filter(::isSyllableEntry) + // The rail the reference builds carries plain spellings only + // (its left builder takes records with mask == 0, 0x5ba378); + // masked readings reach the candidate lattice, not this list. + val syllables = node.exact.filter { it.mask.isEmpty() && isSyllableEntry(it) } if (syllables.isEmpty()) continue val target = states[end + 1] for (entry in syllables) { @@ -532,7 +554,7 @@ internal class NineKeyLocalDecoder( for (depth in 1..minOf(bounded.length, MAX_SYLLABLE_LENGTH)) { node = node.children[bounded[depth - 1]] ?: break node.exact.forEach { entry -> - if (isSyllableEntry(entry) && entry.pinyin.length == depth) { + if (entry.mask.isEmpty() && isSyllableEntry(entry) && entry.pinyin.length == depth) { found += Option(entry.pinyin, depth, entryScore(entry)) } } @@ -552,14 +574,18 @@ internal class NineKeyLocalDecoder( .map { it.pinyin } .distinct() .toMutableList() - if (bounded.length == 1) { - ImeData.keypad9Map[bounded] - .orEmpty() - .filter { it.length == 1 && it[0] in 'a'..'z' } - // A bare letter no syllable starts with (i, u, v) is a dead end. - .filter { letter -> completeSyllables.any { it.startsWith(letter) } } - .forEach { if (it !in ordered) ordered += it } - } + // The letters of the first open key follow the syllables, for any tail + // length: the reference's left list always carries them (its builder + // pushes every letter of the open head key both ways, 0x5ba608-0x5ba704 + // `add w8, w8, #0x20`), and tapping one fixes the first letter the way a + // syllable does. A bare letter no syllable starts with (i, u, v) is a + // dead end and stays out; the upper-case twins only matter for the + // nine-key English the owner ruled out, so we keep the lower one. + ImeData.keypad9Map[bounded.first().toString()] + .orEmpty() + .filter { it.length == 1 && it[0] in 'a'..'z' } + .filter { letter -> completeSyllables.any { it.startsWith(letter) } } + .forEach { if (it !in ordered) ordered += it } return ordered.take(limit) } @@ -577,6 +603,8 @@ internal class NineKeyLocalDecoder( merged.getOrPut(pinyin) { mutableListOf() }.addAll(values) } + merged.forEach { (pinyin, values) -> plainValuesByPinyin[pinyin] = values.distinct() } + return merged.flatMap { (pinyin, rawValues) -> val digits = digitsForPinyin(pinyin) ?: return@flatMap emptyList() val values = rawValues.distinct().take(MAX_CANDIDATES) @@ -600,6 +628,74 @@ internal class NineKeyLocalDecoder( } } + /** + * The masked readings the reference keeps beside every plain one: a spelling + * a reader reaches by confusing two sounds (n↔l, f↔h, an↔ang ...), filed + * under the plain syllable's digits, so a key press that means `ni` can also + * offer `li` once n↔l is on. Each copy carries the rules it needs, like the + * reference's data3 records, and a query lets it in only when all of them + * are switched on. + */ + private fun fuzzyVariantEntries(entries: List): List { + val rules = FuzzyRule.entries.toList() + val variants = LinkedHashMap, Entry>() + entries.forEach { entry -> + if (!isSyllableEntry(entry)) return@forEach + fuzzyVariantsOf(entry.pinyin, rules).forEach { (variant, mask) -> + val values = plainValuesByPinyin[variant].orEmpty() + if (values.isEmpty()) return@forEach + val known = variants[variant to entry.digits] + if (known == null || mask.size < known.mask.size) { + variants[variant to entry.digits] = Entry( + pinyin = variant, + digits = entry.digits, + candidates = values, + phrase = false, + weight = values.maxOfOrNull { value -> PinyinLexicon.weightFor(variant, value) } ?: 0, + mask = mask, + ) + } + } + } + return variants.values.toList() + } + + /** + * Every spelling reachable from [syllable] by applying [rules], each with the + * smallest set of rules that produces it. Two rules together cover the + * combined masks the reference carries (zang = zh↔z + an↔ang, fang = f↔h + + * an↔ang), so a second pass applies one more rule to each first result. + */ + private fun fuzzyVariantsOf(syllable: String, rules: List): List>> { + val out = LinkedHashMap>() + rules.forEach { rule -> + val variant = rule.variant(syllable) ?: return@forEach + if (variant != syllable) out.putIfAbsent(variant, setOf(rule)) + } + out.toList().forEach { (single, mask) -> + rules.forEach { rule -> + if (rule in mask) return@forEach + val variant = rule.variant(single) ?: return@forEach + if (variant != syllable) out.putIfAbsent(variant, mask + rule) + } + } + return out.map { it.key to it.value } + } + + /** + * The readings a query may use: every plain spelling, and the masked ones + * whose rules are on — the reference's `mask ⊆ 用户fuzzy` gate. One spelling + * can arrive both ways (plain and through a rule); the plain one leads. + */ + private fun usableEntries(node: TrieNode?, fuzzy: Boolean): List { + val entries = node?.exact.orEmpty() + if (entries.isEmpty()) return emptyList() + val enabled = if (fuzzy) FuzzyPinyin.rules else emptySet() + return entries + .filter { it.mask.isEmpty() || it.mask.all { rule -> rule in enabled } } + .distinctBy { it.pinyin } + } + private fun insert(entry: Entry) { var node = root entry.digits.forEach { digit -> @@ -615,7 +711,9 @@ internal class NineKeyLocalDecoder( .thenBy { it.pinyin }, ) val childBest = node.children.values.mapNotNull(::sortTrie) - node.bestDescendant = (node.exact.asSequence() + childBest.asSequence()) + // The guess a preview falls back on is always a plain spelling: a masked + // reading has no business deciding the pre-edit while its rule is off. + node.bestDescendant = (node.exact.filter { it.mask.isEmpty() }.asSequence() + childBest.asSequence()) .maxWithOrNull( compareBy(::entryScore) .thenByDescending { it.pinyin }, @@ -627,7 +725,7 @@ internal class NineKeyLocalDecoder( if (node == null) return null var best = node.exact .asSequence() - .filter { it.pinyin.startsWith(pinyinPrefix) } + .filter { it.mask.isEmpty() && it.pinyin.startsWith(pinyinPrefix) } .maxWithOrNull(compareBy(::entryScore).thenByDescending { it.pinyin }) node.children.values.forEach { child -> val candidate = bestDescendantStartingWith(child, pinyinPrefix) ?: return@forEach @@ -650,7 +748,7 @@ internal class NineKeyLocalDecoder( } /** Beam DP over the digit trie; boundaries are retained as spaces. */ - private fun decodePaths(digits: String): List { + private fun decodePaths(digits: String, fuzzy: Boolean): List { val states = Array(digits.length + 1) { mutableListOf() } states[0] += Decode(pinyin = "", text = "", score = 0, parts = 0) @@ -660,8 +758,9 @@ internal class NineKeyLocalDecoder( var node = root for (end in start until digits.length) { node = node.children[digits[end]] ?: break - if (node.exact.isEmpty()) continue - node.exact.take(MAX_BRANCHES).forEach { entry -> + val entries = usableEntries(node, fuzzy).take(MAX_BRANCHES) + if (entries.isEmpty()) continue + entries.forEach { entry -> previous.forEach { before -> val candidate = Decode( pinyin = if (before.pinyin.isEmpty()) { @@ -734,11 +833,25 @@ internal class NineKeyLocalDecoder( companion object { const val MAX_DIGITS = 64 - const val MAX_SYLLABLE_OPTIONS = 12 + + /** + * The rail is a scrolling list and the reference copies its whole vector + * into it without a `take` (left builder 0x5ba1e0 reserves, never cuts), + * so this only bounds what a prefix can turn up: a long tail lists its + * first syllables, their letters and nothing else. + */ + const val MAX_SYLLABLE_OPTIONS = 48 private const val READING_BEAM = 24 private const val MAX_PHRASE_SYLLABLES = 6 private const val MAX_SYLLABLE_LENGTH = 6 - private const val MAX_PATHS = 12 + + /** + * Readings turned into candidates. The reference enumerates its lattice + * without a count limit; we keep a bound because every reading costs a + * librime query, but it has to leave room for the masked readings an + * open fuzzy set adds (9426 grows from 19 to 32 of them). + */ + private const val MAX_PATHS = 24 private const val MAX_BEAM = 12 private const val SPELL_BEAM = 8 private const val SPELL_BRANCHES = 8 diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt index 32cf24f7..937263dd 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -295,7 +295,13 @@ internal class NineKeySymbolRailController( const val FILTER_TAG = "nine-pinyin-path-filter" const val SELECTED_FILTER_TAG = "nine-pinyin-path-selected" const val PINYIN_PANEL_TAG = "nine-pinyin-panel" - const val MAX_VISIBLE_PATHS = 16 + /** + * The rail scrolls and the reference copies its whole filter vector into + * the list without cutting it, so this only has to match what + * [llc.slacker.openime.candidate.NineKeyLocalDecoder] is willing to + * produce for one open tail. + */ + const val MAX_VISIBLE_PATHS = 48 /** The reference reads and marks the same way: one list, one item font. */ const val RAIL_ITEM_TEXT_SP = SymbolRailRenderer.ITEM_TEXT_SP diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt index af832c7d..1255a89c 100644 --- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt @@ -224,6 +224,26 @@ class CandidatePipelineTest { assertTrue(pipeline.nineKeyReadingsFor("", null).isEmpty()) } + /** + * The reference pushes the letters of the open head key into the left list + * for every tail, not only for a single key press (left builder 0x5ba608 - + * 0x5ba704, one push per letter and one for its `+0x20` twin; 64 -> 10 items + * = o mi ni + g h i G H I + the digit). We keep the lower-case letter only, + * because the upper-case twin exists for the nine-key English the owner + * ruled out, and drop a letter no syllable can start (i, u, v). + */ + @Test + fun theHeadsLettersFollowTheSyllablesForAnyOpenTail() { + val short = pipeline.nineKeyReadingsFor("64", null).map { it.display } + assertTrue("m and n must follow mi/ni: $short", short.takeLast(2) == listOf("m", "n")) + assertTrue("i cannot start a syllable and stays out: $short", "i" !in short) + val letters = mapOf("94" to listOf("w", "x", "y", "z"), "5426" to listOf("j", "k", "l")) + letters.forEach { (digits, expected) -> + val shown = pipeline.nineKeyReadingsFor(digits, null).map { it.display } + assertTrue("$digits must end with $expected: $shown", shown.takeLast(expected.size) == expected) + } + } + @Test fun previewReadingFollowsTheWordNotTheDecoderGuess() { // The unit environment only has the compact lexicon; the full sentence diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index 9b04bec4..03e9b6b4 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -2,7 +2,9 @@ package llc.slacker.openime import llc.slacker.openime.candidate.CandidateEngine import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.candidate.FuzzyPinyin import llc.slacker.openime.candidate.NineKeyLocalDecoder +import llc.slacker.openime.core.FuzzyRule import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -115,6 +117,34 @@ class NineKeyChineseTest { assertEquals(null, pipeline.nineKeyReadingFor("44", "和")) } + /** + * The reference keeps a masked reading beside every plain one and lets it in + * when its rule is on: node "64" of syllable.dat carries `li` with the n↔l + * mask 0x0008 (data3[275]; pass2 0x5b962c gates on `mask ⊆ 用户fuzzy`), so a + * press of 64 offers 里 once n = l is switched on and never before. + */ + @Test + fun aFuzzyRuleAddsTheReadingsItCoversAndNothingWhileOff() { + val previous = FuzzyPinyin.rules + try { + FuzzyPinyin.rules = setOf(FuzzyRule.N_L) + val on = pipeline.resolveNineKey("64", "", null, fuzzy = true) + assertTrue("li must read 64 under n = l: ${on.displayPinyinPaths}", "li" in on.displayPinyinPaths) + assertTrue("里 must be offered for 64: ${on.candidates}", "里" in on.candidates) + val off = pipeline.resolveNineKey("64", "", null, fuzzy = false) + assertFalse("no li while fuzzy is off: ${off.displayPinyinPaths}", "li" in off.displayPinyinPaths) + // The rail carries plain spellings only, exactly like the reference's + // left builder, which takes records whose mask is zero (0x5ba378). + assertTrue( + "a masked reading stays out of the rail: " + + pipeline.nineKeyReadingsFor("64", null).map { it.display }, + "li" !in pipeline.nineKeyReadingsFor("64", null).map { it.display }, + ) + } finally { + FuzzyPinyin.rules = previous + } + } + private fun resolve(digits: String): CandidatePipeline.NineKeyResolution = pipeline.resolveNineKey( digits = digits, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 25a0a4c0..549d4cef 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -399,3 +399,6 @@ Add new entries at the end. - **使用耳机麦克风录音 is a routing switch, not a new recorder.** `enable_bluetooth_input` is a `BooleanPreference` with `defaultValue="false"` (`res/cU.xml`, key id `0x7f11018e`); its own words are `string/enable_bluetooth_input_show` 「使用耳机麦克风录音」 and `string/enable_bluetooth_input_show_sub` 「默认使用手机录音。佩戴耳机时,使用耳机麦克风」 — it is about the headset's microphone, not about a physical keyboard. `VoiceAudioRouteManager` already preferred an external device on Android 12+ (`setCommunicationDevice`, BLE / SCO / wired / USB), but only when nothing else had set one; `VoiceInputRoutePolicy` now decides from the switch instead: on, the best available headset; off, the phone's own microphone, and only when something else had routed recording to a headset at all. *Reason:* the reference's promise is "the phone's microphone by default", so the off state has to be able to take the route back from a headset another app selected — but it must not touch a route that is already the phone's, or every recording would come with a mode change it did not need. Below Android 12 nothing is touched, keeping the port's existing trade-off: forcing SCO there costs a ~500 ms recording start-up. 外接键盘 has no counterpart in the reference's settings, and this port's existing hardware-key path (`HardwareKeyPolicy`) is unchanged. +- **The nine-key reading list and its lattice follow the reference's size, not this port's caps.** + The reference enumerates the whole open digit tail on every key press, and nothing along the way cuts a list. Its left-list builder (`libshell.so` `0x5ba1e0`) pushes one string per marked syllable id (`0x5ba574 bl 0x3ce68c` → `0x5ba5f0 vector::push_back`), then one per letter of the open head key plus one per `+0x20` twin (`0x5ba650 ldrb w8,[x21]` → push at `0x5ba6ac` → `0x5ba6c4 add w8, w8, #0x20` → push at `0x5ba704`), then sorts and dedups (`0x5ba74c bl 0x5bd8a8`); its only vector call is `reserve(marked + 8)` (`0x5ba430`), the handler assigns the whole vector into the task result (`0x467034`) and the UI assigns the whole table into the filter list (`0x2d26d0`). Decoding `syllable.dat` end to end gives 64 → 10 items, 9426 → 18, 5426 → 15, against 3 / 9 / 8 here. Fuzzy is a digit-level widening of the lattice rather than a lookup option: every data3 record carries the smallest rule mask that reaches its spelling and pass2 (`0x5b962c`) admits it when `mask ⊆ 用户fuzzy` — node `64` holds `li` with mask `0x0008`, `426` holds `fang` `0x0050` and `hang` `0x0040`, and with every rule on `9426` runs from 19 readings to 32. The port now lists the head key's syllable-capable letters for a tail of any length, raises the rail bound 12 → 48, the reading bound 12 → 24 and the librime reading count 6 → 12, and files one fuzzy-variant entry per syllable under its plain spelling's digits carrying the rules it needs, admitted only while every one of them is switched on. + *Reason:* the reference's own shape is a table keyed by digits whose records carry a mask; copying that shape, instead of filtering paths after the fact, is what makes the extra readings appear exactly when the user switched those rules on and never before. The rail keeps plain spellings only, which is what the reference's left builder does (it takes records whose mask is zero, `0x5ba378`), so a fuzzy reading reaches the candidates and the splits without inventing a rail item the reference would not show. The bounds that remain are stated as such: one reading costs one librime JNI query here, so 12 is a latency guard rather than a coverage decision, and the rail's 48 is the scroll list's own ceiling. Two pieces of the reference's list are deliberately not ported: the upper-case letter twins exist for the nine-key English the owner ruled out, and the leading digit item's rendered string is unverified — the id table names it `digit0..digit9` but no pushed string was checked byte for byte, so it is left out rather than guessed. One correction is recorded instead of followed: `ENGINE-SPEC.md:222` calls the `0x6c0508` family a nine-key fuzzy path, while the disassembly places it in the wubi engine (`oime_engine::wubi::Engine` vtable, caller `0x6b394c`). From 0f5a71ead31d952cd943ad1ed28cf90067c08682 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:27:57 +0800 Subject: [PATCH 90/94] Restore the original keyboard layouts and return key styling Keep the input improvements while restoring the five-key bottom row, equal letter widths, nine-key zero and segmentation keys, stroke grid, numeric arrangement, and uniform key gaps. Restore the numeric return key to its original function-key color and cover the layouts and phone symbols. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 + .../openime/KeyMarginsInstrumentedTest.kt | 19 +- .../KeyboardBottomRowInstrumentedTest.kt | 58 ++++--- .../NumericKeyboardLayoutInstrumentedTest.kt | 87 ++++++++++ ...aceCursorAndLetterHintsInstrumentedTest.kt | 6 +- .../openime/keyboard/ImeKeyboardView.kt | 5 +- .../openime/keyboard/ImeThemeApplier.kt | 20 +-- .../keyboard/NumericKeyboardRenderer.kt | 163 ++++++++++-------- .../keyboard/Pinyin26KeyboardRenderer.kt | 44 ++--- .../keyboard/Pinyin9KeyboardRenderer.kt | 38 ++-- .../openime/keyboard/ProductionKeyPolicy.kt | 26 +-- .../keyboard/StrokeKeyboardRenderer.kt | 18 +- .../slacker/openime/theme/ImeDesignTokens.kt | 20 +-- .../openime/ProductionKeyPolicyTest.kt | 50 ++---- docs/DECISIONS.md | 6 +- 15 files changed, 299 insertions(+), 263 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b938c4e3..d8c8ad92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 恢复豆包对齐前的按键布局:26 键第二行等宽、Shift/退格原宽度和五键底行;九键独立 0 键与原底行比例;笔画、数字页恢复原排列。键帽恢复统一 6 单位间距,工具栏恢复无常驻底色图标。保留词库、候选、设置、字母角标与手势修复。 + - 九键的拼音组合补到参考实现的规模。参考每按一次键都对整条开放数字尾做全量格枚举,三处一起给出「组合」:左栏构造器(libshell.so `0x5ba1e0`)把音节、**首键字母**(每个字母连同 `add w8, w8, #0x20` 的大小写孪生各一条,`0x5ba608`–`0x5ba704`)和首位数字项 push 进同一个 vector,全程没有 `take`,只有 `reserve(新标记数 + 8)`(`0x5ba430`)——逐条解码 `syllable.dat` 的数:`64` 十项、`9426` 十八项、`5426` 十五项;**模糊音在数字层就扩充读法**,data3 每条记录带「达到这条读法所需的最小模糊掩码」,pass2(`0x5b962c`)按 `mask ⊆ 用户fuzzy` 放行,于是节点 `64` 在 n↔l 打开时多一条 `li`(data3[275],mask 0x0008)、节点 `426` 多出 `fang`(0x0010)、`hang`(0x0040),全开时 `9426` 的读法从 19 涨到 32、`5426` 从 18 涨到 30;候选侧同样是全量枚举,只有一条「单调最远前沿」剪枝,没有计数上限。我们这边左栏只列音节、字母仅在下一次按键时给、`readingOptions` 截 12 条(喂给 librime 的读数只取 6 条),而模糊音只传给了词表查询、解码一条都不用,所以 `li`、`xiang` 这类读法永远出不来,`64` 的左栏只有 3 条。现在照参考补齐:左栏对任意长度的开放尾都列出首键能起音节的字母(`i`/`u`/`v` 起不了音节,仍是死路不列;参考另推的大写孪生是九键英文用的,按已定范围不做),上限 12→48(参考不截断,这里是滚动列表的上界);解码器按参考 data3 的结构给每个音节建「模糊变体」条目——挂在原音节的数字节点下、带着所需规则集,查询时只有规则全开才放行——`64` 开 n↔l 后给出 `li` 与「里」,`9426` 给出 `xiang`/`zhang`/`zang`/`zan`/`zao`;路径上限 12→24,送 librime 的读数 6→12(参考无上限,我们每条读数一次 JNI 查询,保留上限但放宽)。左栏仍只收明文拼写(参考的左栏构造器只取 mask 为 0 的记录,`0x5ba378`),所以模糊读法落在候选与拆分里,不进左栏。另记一笔:`ENGINE-SPEC.md:222` 把「独立模糊展开路径 0x6c0508 族」记成九键的是错的,那一族属于五笔引擎(`oime_engine::wubi::Engine`,调用点 `0x6b394c`),按「他们错了,我们保持一致」处理,不照抄。 - 数字、英文前后加空格:参考在「快捷格式输入」页上的第三个开关,标题与说明就是这条规则本身——`string/letter_digit_space_list_item_title`「数字、英文前后加空格」、`string/letter_digit_space_list_item_subtitle`「在语音与打字输入时,在中文和数字、英文间将增加空格」,这一行在参考里只有那个行 id(`enable_letter_digit_space_list_item_id`)——`res/cU.xml` 的偏好表里没有它的开关,整个 APK 也找不到 `enable_letter_digit_space` 这个键,所以它的默认值读不出来;按「个性化功能默认关闭」定成默认关。我们在唯一的落字通道(`InputConnectionGateway.commitText`)里补空格:落字前读一次光标左右的字符,一侧是汉字、另一侧是 ASCII 字母或数字时补一个空格;汉字侧的标点(`。`、`,`)不算汉字,空格本身也不会再加一个空格;语音上屏走同一条通道,与参考文案里的「在语音与打字输入时」一致。开关默认关,关着的时候这条通道只多两次读光标(都只在开关打开时才读)。 - 双击空格键输入句号:参考的处理器在 `InputViewRoot$z.invoke`(classes.dex code_off 0x4c1764):空格抬起时 `now - lastSpaceTime` 与 `const-wide/16 0xc8` 比较,也就是 **200 毫秒**,命中就调 `KeyboardJni.commitForSpaceDoubleClick(!isPunct(charBeforeFirstSpace), true)`;判定用的标点是一张 31 个字符的表(空格、`,。?!;:`、两对引号、`()【】《》` 与半角 `,.?!;:"'()[]<>`),落在这些字后面的第二个空格仍是空格。我们照抄这套:服务记下第一个空格落下的时刻与它前面那个字符(落字前读),第二个空格落在 200 毫秒内且前面不是标点时,删掉那个空格写上「。」。存储项 `enable_double_click_space` 默认关(另见 `res/xq.xml` 的 `string/double_click_space_to_input_period`「双击空格键输入句号」)。 diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt index e9de3998..052a8f1b 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyMarginsInstrumentedTest.kt @@ -19,15 +19,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.lang.reflect.Proxy -/** - * Every key cap is painted inside its cell by that key class's own margins: the - * 26-key board's keys keep 2.25 units sideways and 4.75 up and down - * (`cls_keyboard_button_margin`, `style.xml:289-290`), the nine-key family — - * nine-key, stroke and the number page — 2.5 and 2.75 - * (`cls_keyboard9_button_margin`, `style.xml:373-374`), and the 26-key board's - * two big side keys clear the letter column by 8 units on their inner side - * (`cls_button_left_big`, `cls_button_right_big`, `style.xml:907-912`). - */ +/** Key caps keep their original uniform six-unit gap on every board. */ @RunWith(AndroidJUnit4::class) class KeyMarginsInstrumentedTest { private val listener = Proxy.newProxyInstance( @@ -93,11 +85,12 @@ class KeyMarginsInstrumentedTest { bottom: Float, ) { val unit = unitOf(keyboard) + fun pixels(margin: Float): Int = if (margin == 3f) kotlin.math.round(6 * unit).toInt() / 2 else Math.round(margin * unit) val insets = capInsets(key) - assertEquals("$label keeps the reference's left margin", Math.round(left * unit).toLong(), insets.left.toLong()) - assertEquals("$label keeps the reference's top margin", Math.round(top * unit).toLong(), insets.top.toLong()) - assertEquals("$label keeps the reference's right margin", Math.round(right * unit).toLong(), insets.right.toLong()) - assertEquals("$label keeps the reference's bottom margin", Math.round(bottom * unit).toLong(), insets.bottom.toLong()) + assertEquals("$label keeps its left margin", pixels(left).toLong(), insets.left.toLong()) + assertEquals("$label keeps its top margin", pixels(top).toLong(), insets.top.toLong()) + assertEquals("$label keeps its right margin", pixels(right).toLong(), insets.right.toLong()) + assertEquals("$label keeps its bottom margin", pixels(bottom).toLong(), insets.bottom.toLong()) } private fun allViews(root: View): Sequence = sequence { diff --git a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt index a3798958..cec1fe86 100644 --- a/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/KeyboardBottomRowInstrumentedTest.kt @@ -70,30 +70,24 @@ class KeyboardBottomRowInstrumentedTest { private fun settled(harness: DirectActivityHarness, keyboard: ImeKeyboardView, tag: String): View = harness.awaitMain { keyboard.findViewWithTag(tag)?.takeIf { it.width > 0 } } - /** - * The bottom row carries the reference's own key widths — 123 70.6, the - * merged key 37.6, both symbol keys 35.5, the space bar 133.6, 中/英 38.6, - * 确定 74.6 (`style.xml:960-978`) — in the order its layout puts them - * (`input_kbd_pinyin26.xml:134-158`). The reference's two sides are not - * equal, so the space bar sits where its own numbers put it, a hair left of - * the keyboard's centre line rather than exactly on it. - */ + /** The original five keys keep their widths and the space bar stays centred. */ @Test - fun theBottomRowCarriesTheReferencesOwnKeyWidths() = withKeyboard { harness, _, keyboard -> + fun theBottomRowKeepsTheOriginalFiveKeyWidths() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } val space = settled(harness, keyboard, "key-space") val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() val shares = listOf( "key:123" to weights.leftOuter, - "key-punctuation" to weights.merged, - "key-symbol-comma" to weights.symbol, + "key-punctuation" to weights.leftInner, "key-space" to weights.space, - "key-symbol-period" to weights.symbol, "key:mode" to weights.rightInner, "key-enter" to weights.rightOuter, ) harness.awaitMain { val row = space.parent as ViewGroup + assertEquals("the bottom row has five keys", 5, row.childCount) + assertNull(keyboard.findViewWithTag("key-symbol-comma")) + assertNull(keyboard.findViewWithTag("key-symbol-period")) val unit = row.width / shares.sumOf { it.second.toDouble() }.toFloat() shares.forEach { (tag, weight) -> val key = keyboard.findViewWithTag(tag) @@ -108,7 +102,7 @@ class KeyboardBottomRowInstrumentedTest { val before = shares.takeWhile { it.first != "key-space" } .sumOf { it.second.toDouble() }.toFloat() assertEquals( - "the space bar sits where the reference's own weights put it", + "the space bar stays centred", (before + weights.space / 2f) * unit, space.centerX(row), 2f, @@ -117,14 +111,15 @@ class KeyboardBottomRowInstrumentedTest { } } - /** The reference draws this column as delete, retype and a send key twice as tall. */ @Test - fun theNineKeySideColumnIsDeleteRetypeAndATallSend() = withKeyboard { harness, _, keyboard -> + fun theSideColumnsKeepTheirOriginalKeysAndHeights() = withKeyboard { harness, _, keyboard -> listOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE).forEach { mode -> harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); keyboard.setMode(mode); true } settled(harness, keyboard, "key-space") val ok = harness.awaitMain { - assertNull("$mode has no separate zero key", keyboard.findViewWithTag("key-9:0")) + val zero = keyboard.findViewWithTag("key-9:0") + if (mode == KeyboardMode.PINYIN_9) assertNotNull("nine-key keeps its separate zero", zero) + else assertNull("stroke keeps its three-key side column", zero) val retype = keyboard.findViewWithTag("key-retype") val enter = keyboard.findViewWithTag("key-enter") val backspace = keyboard.findViewWithTag("key-backspace") @@ -132,25 +127,34 @@ class KeyboardBottomRowInstrumentedTest { assertNotNull("$mode send", enter) assertNotNull("$mode delete", backspace) assertEquals("$mode delete matches retype", retype.height.toFloat(), backspace.height.toFloat(), 2f) - assertEquals("$mode send is twice as tall", (retype.height * 2).toFloat(), enter.height.toFloat(), 2f) + assertEquals("$mode send keeps the same height", retype.height.toFloat(), enter.height.toFloat(), 2f) + if (zero != null) assertEquals("zero matches the other keys", retype.height.toFloat(), zero.height.toFloat(), 2f) true } check(ok) } } - /** The reference widens A and L by half a letter (weight 15 against 10). */ @Test - fun theEdgeLetterKeysAreHalfALetterWider() = withKeyboard { harness, _, keyboard -> - harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } - settled(harness, keyboard, "key:q") - harness.awaitMain { - fun width(tag: String) = keyboard.findViewWithTag(tag).width - assertEquals("A is half a letter wider", width("key:s") * 1.5f, width("key:a").toFloat(), 2f) - assertEquals("L is half a letter wider", width("key:s") * 1.5f, width("key:l").toFloat(), 2f) - assertEquals("the middle letters stay equal", width("key:s"), width("key:d")) - true + fun theIndentedLetterRowKeepsTheTopRowsKeyPitch() = withKeyboard { harness, _, keyboard -> + listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26).forEach { mode -> + harness.awaitMain { keyboard.setMode(mode); true } + settled(harness, keyboard, "key:q") + harness.awaitMain { + val q = keyboard.findViewWithTag("key:q") + val w = keyboard.findViewWithTag("key:w") + val a = keyboard.findViewWithTag("key:a") + "asdfghjkl".forEach { letter -> + val key = keyboard.findViewWithTag("key:$letter") + assertEquals("$mode $letter keeps the top row's width", q.width.toFloat(), key.width.toFloat(), 2f) + } + assertEquals("$mode keeps the half-key indent", q.centerX(keyboard) + q.width / 2f, a.centerX(keyboard), 2f) + val s = keyboard.findViewWithTag("key:s") + assertEquals("$mode keeps the top row's pitch", w.centerX(keyboard) - q.centerX(keyboard), s.centerX(keyboard) - a.centerX(keyboard), 2f) + true + } } + true } /** diff --git a/app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt new file mode 100644 index 00000000..20eb3f21 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/NumericKeyboardLayoutInstrumentedTest.kt @@ -0,0 +1,87 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.keyboard.NumericKeyboardRenderer +import llc.slacker.openime.widget.ImeKeyView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** The original numeric arrangement must survive phone-field remapping. */ +@RunWith(AndroidJUnit4::class) +class NumericKeyboardLayoutInstrumentedTest { + @Test + fun phoneKeepsTheOriginalPositionsAndCommitsAllDialerSymbols() { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { context -> + val committed = mutableListOf() + var spaces = 0 + val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + fun key(text: String, onTap: () -> Unit) = ImeKeyView(context, text).apply { + setOnClickListener { onTap() } + } + NumericKeyboardRenderer( + context = context, + keyboardBody = body, + toPx = { it }, + keyRowHeightDp = { 54 }, + nineBodyHeightDp = { 216 }, + createKey = { text, _, _, onTap -> key(text, onTap) }, + createBackspaceKey = { key("删除") {}.apply { tag = "key-backspace" } }, + createSpaceVoiceKey = { text, onTap -> key(text, onTap).apply { tag = "key-space" } }, + markSideKey = {}, + markWhiteKey = {}, + markAccentKey = {}, + onCommitCharacter = { committed += it }, + onFeedback = {}, + onShowSymbols = {}, + onReturnToText = {}, + onSpace = { spaces++ }, + onEnter = {}, + ).render(EditorInfoAdapter.EditorKind.PHONE, "确定") + + fun find(tag: String) = body.findViewWithTag(tag) + val star = find("key-space") + val zero = find("key:0") + val back = find("key:mode") + val bottom = star.parent as ViewGroup + assertEquals(back, bottom.getChildAt(0)) + assertEquals(star, bottom.getChildAt(1)) + assertEquals(zero, bottom.getChildAt(2)) + assertEquals("*", star.currentMainText) + assertEquals("+", find("key:.").currentMainText) + assertEquals("#", find("key:@").currentMainText) + assertTrue(star.performClick()) + assertTrue(find("key:.").performClick()) + assertTrue(find("key:@").performClick()) + assertEquals(listOf("*", "+", "#"), committed) + assertEquals(0, spaces) + + val actions = body.findViewWithTag("digits-actions") + assertEquals(4, actions.childCount) + assertEquals( + listOf("key-backspace", "key:.", "key:@", "key-enter"), + (0 until actions.childCount).map { actions.getChildAt(it).tag }, + ) + assertTrue((0 until actions.childCount).all { actions.getChildAt(it).layoutParams.height == 54 }) + val grid = body.findViewWithTag("digits-grid") + assertEquals(3, grid.childCount) + assertEquals( + listOf(listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9")), + (0 until grid.childCount).map { rowIndex -> + val row = grid.getChildAt(rowIndex) as ViewGroup + (0 until row.childCount).map { (row.getChildAt(it) as ImeKeyView).currentMainText } + }, + ) + assertEquals(View.GONE, body.findViewWithTag("digits-symbol-scroll").visibility) + true + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index e04382a1..6b2f007e 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -393,15 +393,15 @@ class SpaceCursorAndLetterHintsInstrumentedTest { assertTrue("a tap types the letter, not the hint: ${recorder.characters}", "1" !in recorder.characters) } - /** The reference prints 0 on the nine-key and stroke space bar and types it on an up swipe. */ + /** Keep the digit gesture without moving the original zero key onto the space bar. */ @Test - fun swipeUpOnTheNineKeySpaceTypesZero() = withKeyboard { harness, recorder, keyboard -> + fun spaceKeepsItsOriginalFaceAndRetainsTheZeroSwipe() = withKeyboard { harness, recorder, keyboard -> listOf(KeyboardMode.PINYIN_9, KeyboardMode.STROKE).forEach { mode -> harness.awaitMain { keyboard.setMode(KeyboardMode.DIGITS); keyboard.setMode(mode); true } recorder.characters.clear() recorder.spaces = 0 val space = settled(harness, keyboard, "key-space") - harness.awaitMain { assertTrue("$mode space carries the 0 mark", "0" in texts(space)); true } + harness.awaitMain { assertFalse("$mode space keeps its original face", "0" in texts(space)); true } swipeUp(harness, space) assertEquals("$mode up swipe on the space bar types 0", listOf("0"), recorder.characters.toList()) recorder.characters.clear() 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 325db244..a11632ae 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -645,13 +645,14 @@ open class ImeKeyboardView( }, createBackspaceKey = ::backspaceKey, createSpaceVoiceKey = { label, onTap -> - spaceVoiceKey(label, white = true, onTap = onTap, hint = "0") + spaceVoiceKey(label, white = true, onTap = onTap) }, createSymbolRail = { requireNineKeySymbolRailController().buildRail() }, markSideKey = { key -> key.setTag(MARK_SIDE_KEY, true) }, markWhiteKey = { key -> key.setTag(MARK_WHITE_KEY, true) }, onDigitKeyCreated = nineKeySegmentRepairController::bindDigitKey, onNineKey = ::onNineKey, + onPinyinSegment = ::onPinyinSegment, onShowChoicePopup = ::showChoicePopup, onShowChoiceRows = ::showChoiceRows, swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, @@ -686,7 +687,7 @@ open class ImeKeyboardView( }, createBackspaceKey = ::backspaceKey, createSpaceVoiceKey = { label, onTap -> - spaceVoiceKey(label, white = true, onTap = onTap, hint = "0") + spaceVoiceKey(label, white = true, onTap = onTap) }, // The nine-key rail: stroke glyphs are not digits, so it stays on symbols. createSymbolRail = { requireNineKeySymbolRailController().buildRail() }, 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 efe1be02..ff328a3f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -137,11 +137,11 @@ internal class ImeThemeApplier( keyRadius, toPx(1), ) - // The cap is painted inside the cell by the key's own margins, as the - // reference's key classes set them (2.25 × 4.75 on the 26-key board, - // 2.5 × 2.75 on the nine-key family, 8 on a big side key's inner side). - val unit = view.resources.displayMetrics.density * referenceScale() - fun cap(dp: Float): Int = Math.round(dp * unit) + // Keep the original cap spacing on every board, including rounding. + val halfGap = toPx(ImeGeometryTokens.KEY_GAP_DP) / 2 + fun cap(dp: Float): Int = if (dp == ImeGeometryTokens.KEY_GAP_DP / 2f) halfGap else { + Math.round(dp * view.resources.displayMetrics.density * referenceScale()) + } view.background = InsetDrawable( keyCap, cap(view.marginLeftDp), @@ -525,15 +525,11 @@ internal class ImeThemeApplier( }, ) if (view.isClickable) { - // Every idle-toolbar key wears its own pill face at rest - // (`cls_toolbar_idle_button`: `bkcolor="@color/toolbar_idle_button_bk"`, - // `bkround="999"`, `pushedbkcolor`, style.xml:237-248), which on the - // toolbar surface is the key cap colour, and presses to the - // reference's own shade instead of a derived one. + // Keep the original bare icons; only a press paints a face. view.background = statefulRounded( - t.keyBackground, + Color.TRANSPARENT, ImeSurfacePolicy.pressedOnGround(t.toolbarBackground, t), - toPx(ImeGeometryTokens.PILL_RADIUS_DP), + toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt index ca8c44af..f0e749bb 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -1,7 +1,6 @@ package llc.slacker.openime.keyboard import android.content.Context -import android.view.Gravity import android.view.View import android.widget.LinearLayout import android.widget.ScrollView @@ -12,15 +11,11 @@ import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView /** - * Concrete first-frame renderer for the number/decimal/phone page. + * Concrete first-frame renderer for number/decimal/phone keyboards. * - * The page is the reference's own number layout - * (`input_kbd_number_from26.xml:5-42`): a symbol rail over the return key on - * the left, three columns of digits whose last cell is 符号 / 0 / `.`, and a - * delete / space / send column on the right. - * - * Phone remapping and the symbol rail are resolved before the view is attached, - * so no post-build hierarchy scan is needed. + * Keeps the original three-column digit grid, 返回 / 空格 / 0 bottom row, + * and delete / . / @ / enter action column. Phone remapping is applied while + * constructing each key, before the view is attached. */ internal class NumericKeyboardRenderer( private val context: Context, @@ -60,12 +55,7 @@ internal class NumericKeyboardRenderer( tag = "digits-layout" } - // The rail takes three key rows and the return key the fourth, as the - // reference splits the column (input_kbd_number_from26.xml:10-15). - val left = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.BOTTOM - } + val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } val rail = SymbolRailRenderer.build( context = context, railTag = DIGITS_RAIL_TAG, @@ -85,73 +75,101 @@ internal class NumericKeyboardRenderer( rail, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(keyRowHeightDp() * NumberPadPolicy.RAIL_ROWS), + toPx(keyRowHeightDp() * 3), ), ) - // The reference draws this key on its theme colour, not on gray - // (`cls_button_back_26key` over `cls_button_back`, style.xml:857-858, - // :838-843, and `cls_bk_theme_circle` there is `theme_color`, - // :282-286). Its label is our own text: the skin's key carries a glyph - // from its icon font (a private-use glyph), which this build has no face for. left.addView( - createKey("返回", true, ImeTypographyTokens.BODY_SP, onReturnToText).apply { - tag = "key:mode" - markAccentKey(this) + createKey("符号", true, ImeTypographyTokens.BODY_SP, onShowSymbols).apply { + tag = "key-symbols" + markSideKey(this) }, - columnCellParams(keyRowHeightDp() * NumberPadPolicy.BACK_ROWS), + sideKeyParams(), ) - container.addView(left, adaptiveColumnParams(NumberPadPolicy.RAIL_COLUMN_WEIGHT)) + container.addView(left, adaptiveColumnParams(1f)) - // Three digit columns, each closed by the reference's own fourth cell - // (input_kbd_number_from26.xml:17-34). - val stacks = NumberPadPolicy.DIGIT_COLUMN_TOPS.map { column -> - LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - column.forEach { digit -> addView(digitKey(digit), columnCellParams()) } - } + val grid = LinearLayout(context).apply { + tag = "digits-grid" + orientation = LinearLayout.VERTICAL + } + DIGIT_ROWS.forEach { digits -> + val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + digits.forEach { digit -> row.addView(digitKey(digit), flexKeyParams()) } + grid.addView( + row, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(keyRowHeightDp()), + ), + ) } - val tails = NumberPadPolicy.COLUMN_TAILS - stacks[SYMBOL_COLUMN].addView( - createKey(literal(SYMBOL_CELL_TAG, tails[SYMBOL_COLUMN]), true, ImeTypographyTokens.BODY_SP) { - if (phone) onCommitCharacter(literal(SYMBOL_CELL_TAG, tails[SYMBOL_COLUMN])) else onShowSymbols() - }.apply { - tag = SYMBOL_CELL_TAG - markWhiteKey(this) + val center = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + center.addView( + grid, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(keyRowHeightDp() * 3), + ), + ) + val bottom = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + bottom.addView( + createKey("返回", true, ImeTypographyTokens.BODY_SP, onReturnToText).apply { + tag = "key:mode" + markSideKey(this) }, - columnCellParams(), + flexKeyParams(), ) - stacks[ZERO_COLUMN].addView( - digitKey(tails[ZERO_COLUMN], value = literal("key:0", tails[ZERO_COLUMN]), tag = "key:0"), - columnCellParams(), + bottom.addView( + if (phone) { + val value = literal("key-space", "*") + createKey(value, true, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter(value) }.apply { + tag = "key-space" + markWhiteKey(this) + } + } else { + createSpaceVoiceKey("空格", onSpace) + }, + flexKeyParams(), ) - stacks[PERIOD_COLUMN].addView( - digitKey(literal("key:.", tails[PERIOD_COLUMN]), tag = "key:."), - columnCellParams(), + bottom.addView(digitKey(literal("key:0", "0"), tag = "key:0"), flexKeyParams()) + center.addView( + bottom, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + toPx(keyRowHeightDp()), + ), ) - stacks.forEach { container.addView(it, adaptiveColumnParams(NumberPadPolicy.DIGIT_COLUMN_WEIGHT)) } + container.addView(center, adaptiveColumnParams(25f / 7f)) val side = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL tag = "digits-actions" } + side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams()) + val period = literal("key:.", ".") side.addView( - createBackspaceKey().apply { markSideKey(this) }, - columnCellParams(), + createKey(period, true, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter(period) }.apply { + tag = "key:." + markSideKey(this) + }, + sideKeyParams(), ) + // Keep the existing phone policy's # mapping on the restored @ cell. + val at = literal("key:symbol", "@") side.addView( - createSpaceVoiceKey("空格", onSpace), - columnCellParams(), + createKey(at, true, ImeTypographyTokens.BODY_SP) { onCommitCharacter(at) }.apply { + tag = "key:@" + markSideKey(this) + }, + sideKeyParams(), ) side.addView( createKey(enterLabel, true, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - // The reference draws send twice as tall as the keys above it - // (input_kbd_number_from26.xml:38-40). - columnCellParams(keyRowHeightDp() * NumberPadPolicy.SEND_ROWS), + sideKeyParams(), ) - container.addView(side, adaptiveColumnParams(NumberPadPolicy.SEND_COLUMN_WEIGHT)) + container.addView(side, adaptiveColumnParams(1f)) keyboardBody.addView( container, @@ -162,9 +180,8 @@ internal class NumericKeyboardRenderer( ) } - /** A digit cell: the reference draws every digit on a white key (`cls_keyboard_number9_button`). */ - private fun digitKey(digit: String, value: String = digit, tag: String = "key:$digit"): ImeKeyView = - createKey(digit, false, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter(value) }.apply { + private fun digitKey(digit: String, tag: String = "key:$digit"): ImeKeyView = + createKey(digit, false, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter(digit) }.apply { this.tag = tag markWhiteKey(this) } @@ -192,28 +209,22 @@ internal class NumericKeyboardRenderer( .filter { it.isNotBlank() } ).distinct() - private fun columnCellParams(heightDp: Int = keyRowHeightDp()) = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(heightDp), - ) + private fun flexKeyParams(weight: Float = 1f) = + LinearLayout.LayoutParams(0, toPx(keyRowHeightDp()), weight) + + private fun sideKeyParams() = + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp())) private fun adaptiveColumnParams(weight: Float) = - LinearLayout.LayoutParams( - 0, - toPx(nineBodyHeightDp()), - weight, - ) + LinearLayout.LayoutParams(0, toPx(nineBodyHeightDp()), weight) private companion object { const val DIGITS_RAIL_TAG = "digits-symbol-scroll" const val DIGITS_CONTENT_TAG = "digits-symbol-scroll-content" - - /** The symbol-page cell of the first column; a phone field puts the dialer's `#` here. */ - const val SYMBOL_CELL_TAG = "key:symbol" - - const val SYMBOL_COLUMN = 0 - const val ZERO_COLUMN = 1 - const val PERIOD_COLUMN = 2 + val DIGIT_ROWS = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + ) } } 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 2884af5e..8cf9312f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -67,19 +67,17 @@ internal class Pinyin26KeyboardRenderer( ) } - rowText.forEachIndexed { index, character -> - // The reference widens A and L by half a letter (weight 15 - // against 10) and the function keys of the last row likewise. - val wide = rowIndex == 1 && (index == 0 || index == rowText.lastIndex) + rowText.forEach { character -> + // The host already centres the nine-letter row at 90% width. + // Equal cells keep its letters on the same pitch as Q–P. row.addView( letterKey(character, english, shiftState), - flexKeyParams(if (wide) EDGE_KEY_WEIGHT else 1f), + flexKeyParams(), ) } if (rowIndex == 2) { - // The right big key clears the letter column by 8 units on its - // inner side (`cls_button_right_big`, `style.xml:911-912`). + // Keep the same cap gap as the adjacent letter keys. row.addView( createBackspaceKey().apply { setReferenceMargins( @@ -101,16 +99,11 @@ internal class Pinyin26KeyboardRenderer( createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), flexKeyParams(weights.leftOuter), ) - // The merged ,。 key sits left of the space bar, 中/英 right of it. - bottom.addView(punctuationKey(punctuation), flexKeyParams(weights.merged)) - // The reference's two symbol keys bookend the space bar: ,and 。in - // Chinese, , and . in English, the address marks in a URL field. - bottom.addView(symbolKey("key-symbol-comma", punctuation.comma), flexKeyParams(weights.symbol)) + bottom.addView(punctuationKey(punctuation), flexKeyParams(weights.leftInner)) bottom.addView( createSpaceVoiceKey("空格", onSpace), flexKeyParams(weights.space), ) - bottom.addView(symbolKey("key-symbol-period", punctuation.period), flexKeyParams(weights.symbol)) bottom.addView( createKey( "中/英", @@ -178,9 +171,7 @@ internal class Pinyin26KeyboardRenderer( contentDescription = "${spec.merged},上滑或长按输入${spec.mergedCorner}" setSecondaryVisible(true) setSecondaryAlpha(1f) - // The key is a mark and a corner in 8.8% of the row: the mark shrinks - // rather than showing an ellipsis when the key or the system font - // leaves it no room. + // Keep the mark visible when the key or system font leaves less room. allowLabelShrink() // Up swipes the corner mark, as the reference keyboard does. onSwipeUp = { onCommitCharacter(spec.mergedCorner) } @@ -191,20 +182,6 @@ internal class Pinyin26KeyboardRenderer( } } - /** - * One of the reference's two symbol keys beside the space bar - * (`input_kbd_pinyin26.xml:153,156`). It carries no corner mark and no - * bubble: the key is its own character. - */ - private fun symbolKey(tag: String, text: String): ImeKeyView = - createKey(text, false, null, ImeTypographyTokens.KEY_LETTER_COMPACT_SP, 0) { - onCommitCharacter(text) - }.apply { - this.tag = tag - contentDescription = text - allowLabelShrink() - } - private fun segmentKey(): ImeKeyView = createKey("分词", true, null, ImeTypographyTokens.BODY_SP, 0, onPinyinSegment).apply { // `cls_button_shift` draws the label alone: the reference's 分词 key @@ -216,8 +193,7 @@ internal class Pinyin26KeyboardRenderer( private fun shiftKey(shiftState: ShiftState): ImeKeyView { val iconRes = EnglishShiftPolicy.icon(shiftState) return createKey("", true, null, null, iconRes, onShift).apply { - // The reference's left big key clears the letter column by 8 units - // on its inner side (`cls_button_left_big`, `style.xml:907-908`). + // Keep the original gap beside the letter column. setReferenceMargins( left = ImeGeometryTokens.KEY_MARGIN_X_DP, right = ImeGeometryTokens.KEY_INNER_MARGIN_DP, @@ -307,8 +283,8 @@ internal class Pinyin26KeyboardRenderer( */ fun modeKeyTag(english: Boolean): String = if (english) "key:mode-en" else "key:mode" - /** The reference skin's weight 15 against a letter's 10. */ - const val EDGE_KEY_WEIGHT = 1.5f + /** Original Shift and backspace width relative to a letter. */ + const val EDGE_KEY_WEIGHT = 1.25f 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 b53462c6..8f963ef9 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -35,6 +35,7 @@ internal class Pinyin9KeyboardRenderer( private val markWhiteKey: (ImeKeyView) -> Unit, private val onDigitKeyCreated: (String, ImeKeyView) -> Unit, private val onNineKey: (String) -> Unit, + private val onPinyinSegment: () -> Unit, private val onShowChoicePopup: (View, List) -> Unit, private val onShowChoiceRows: (View, List>) -> Unit, private val swipeUpEnabled: () -> Boolean, @@ -112,8 +113,7 @@ internal class Pinyin9KeyboardRenderer( orientation = LinearLayout.VERTICAL tag = "pinyin9-actions" } - // Delete, retype and send, with send twice as tall, as the reference - // keyboard draws this column; the 0 sits on the space bar's corner. + // Keep the original four equal cells: delete, retype, zero and enter. val quarter = nineBodyHeightDp() / 4 side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams(quarter)) side.addView( @@ -123,12 +123,20 @@ internal class Pinyin9KeyboardRenderer( }, sideKeyParams(quarter), ) + side.addView( + createKey("0", true, null, ImeTypographyTokens.KEY_NUMBER_SP) { onCommitCharacter("0") }.apply { + tag = "key-9:0" + contentDescription = "0" + markSideKey(this) + }, + sideKeyParams(quarter), + ) side.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(quarter * 2), + sideKeyParams(quarter), ) if (splitLayout()) { // Keep all Chinese keys in their familiar three-by-three order. @@ -220,27 +228,22 @@ internal class Pinyin9KeyboardRenderer( } private fun createNineDigitKey(digit: String): ImeKeyView { - // The 1 cell is the reference's `@#` key: the label names the symbols it - // offers, and the corner shows the digit a tap and an up swipe type - // (`input_kbd_pinyin9.xml:23-28`). The board has no 分词 key: that key - // exists on the 26-key row alone. - val atSymbol = digit == "1" + // Keep the original segment key in the 1 cell; its digit swipe and + // symbol choices remain available without changing the key arrangement. + val segmentation = digit == "1" val labels = NINE_KEYS.flatten().first { it.first == digit }.second val key = createKey( - labels, + if (segmentation) "分词" else labels, false, digit, - // The reference gives the `@#` key its own, larger face - // (`key_letter9_at_symbol`, 54 units) and the letters - // `key_letter9`, 48 (`input_kbd_pinyin9.xml:23-28`, 32). - if (atSymbol) NINE_KEY_SYMBOL_SP else ImeTypographyTokens.NINE_KEY_LETTER_SP, + if (segmentation) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.NINE_KEY_LETTER_SP, ) { - onNineKey(digit) + if (segmentation) onPinyinSegment() else onNineKey(digit) }.apply { tag = "key-9:$digit" - contentDescription = if (atSymbol) "@#,角标 1" else digit + contentDescription = if (segmentation) "1,分词" else digit markWhiteKey(this) - if (atSymbol) { + if (segmentation) { setOnLongClickListener { onShowChoicePopup(this, NineKeyLongPressPolicy.AT_SYMBOL_CHOICES) true @@ -282,9 +285,6 @@ internal class Pinyin9KeyboardRenderer( ) private companion object { - /** The `@#` key's own face: `key_letter9_at_symbol`, 54 units. */ - const val NINE_KEY_SYMBOL_SP = 18f - val NINE_KEYS = listOf( listOf("1" to "@#", "2" to "ABC", "3" to "DEF"), listOf("4" to "GHI", "5" to "JKL", "6" to "MNO"), diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt index 01afd116..ce8c785f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt @@ -9,35 +9,21 @@ internal object ProductionKeyPolicy { data class BottomRowWeights( val leftOuter: Float, - val merged: Float, - val symbol: Float, + val leftInner: Float, val space: Float, val rightInner: Float, val rightOuter: Float, ) - /** - * Nine-key bottom row: 123 | 空格 | 中/英, in the reference's own weights. - * Its nine-key board and its stroke board give that row the same three - * figures — 45.5, 134, 45.5 — inside the 225-unit centre column - * (`input_kbd_pinyin9.xml:87-100`, `input_kbd_stroke.xml:69-84`). - */ + /** Nine-key bottom row: 123 | 空格 | 中/英. The space bar is the wide key. */ data class NineKeyBottomRowWeights(val side: Float, val space: Float) - fun nineKeyBottomRowWeights(): NineKeyBottomRowWeights = NineKeyBottomRowWeights(side = 45.5f, space = 134f) + fun nineKeyBottomRowWeights(): NineKeyBottomRowWeights = NineKeyBottomRowWeights(side = 0.7f, space = 1.6f) - /** - * Visual reference geometry for the shared Chinese/English 26-key bottom row: - * 123 | ,。 | , | 空格 | 。 | 中/英 | 确定, in the reference's own weights - * (`style.xml:957-978`: 123 70.6, the merged key 37.6, both symbol keys 35.5, - * space 133.6, 中/英 38.6, 确定 74.6). The row mirrors around the space key so - * the space bar sits on the keyboard's centre line. - */ + /** The original five-key row keeps the space bar centred in either orientation. */ fun twentySixKeyBottomRowWeights(landscape: Boolean = false): BottomRowWeights { - // 横屏按参考键盘的横屏皮肤放大空格键:其空格权重 133.6 -> 182.2(×1.3638), - // 其余各键均不变(skin-reference/land/style.xml:115)。 - val space = if (landscape) 182.2f else 133.6f - return BottomRowWeights(70.6f, 37.6f, 35.5f, space, 38.6f, 74.6f) + val space = if (landscape) 210f else 154f + return BottomRowWeights(70f, 48f, space, 48f, 70f) } /** diff --git a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt index 020c93bc..f3a640b7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/StrokeKeyboardRenderer.kt @@ -10,9 +10,8 @@ import llc.slacker.openime.widget.ImeKeyView /** * The 笔画 keyboard. It keeps the nine-key frame (symbol rail over 符号, * 删除/重输/确定 column, 123 · 空格 · 中/英 row) so switching between the two moves nothing - * the user already knows, and lays the strokes out as the reference does: a - * 3x3 grid whose last row carries 通配, @# and the two punctuation cells - * (`input_kbd_stroke.xml:16-66`, see [StrokeGridPolicy]). + * the user already knows. The original six cells stay in two rows: + * 一 丨 丿 / 丶 折 通配. Corner-digit gestures remain on those cells. */ internal class StrokeKeyboardRenderer( private val context: Context, @@ -102,23 +101,22 @@ internal class StrokeKeyboardRenderer( orientation = LinearLayout.VERTICAL tag = "stroke-actions" } - // Delete, retype and send, with send twice as tall, as the reference - // keyboard draws this column (the nine-key column has the same shape). - val sideQuarterDp = bodyHeightDp() / 4 - side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams(sideQuarterDp)) + // The original stroke layout gives these three actions equal height. + val sideHeightDp = bodyHeightDp() / 3 + side.addView(createBackspaceKey().apply { markSideKey(this) }, sideKeyParams(sideHeightDp)) side.addView( createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetype).apply { tag = "key-retype" markSideKey(this) }, - sideKeyParams(sideQuarterDp), + sideKeyParams(sideHeightDp), ) side.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(sideQuarterDp * 2), + sideKeyParams(sideHeightDp), ) container.addView(side, columnParams(1f)) @@ -129,7 +127,7 @@ internal class StrokeKeyboardRenderer( } private fun buildStrokeGrid(): LinearLayout { - val rows = StrokeGridPolicy.ROWS + val rows = StrokeGridPolicy.ROWS.take(2) val rowHeightDp = gridHeightDp() / rows.size val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } rows.forEach { cells -> 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 3af67791..ff08ef2f 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -72,20 +72,12 @@ internal object ImeGeometryTokens { const val KEY_ROW_GAP_DP = KEY_GAP_DP const val KEY_SIDE_MARGIN_DP = 2 - /** - * The reference paints every key cap inside its cell with that key class's - * own margins: the 26-key board's keys keep 2.25 units sideways and 4.75 up - * and down (`cls_keyboard_button_margin`, `style.xml:289-290`), the nine-key - * family — nine-key, stroke and the number page — 2.5 and 2.75 - * (`cls_keyboard9_button_margin`, `style.xml:373-374`). The 26-key board's - * two big side keys clear the letter column by 8 units on their inner side - * (`cls_button_left_big`, `cls_button_right_big`, `style.xml:907-912`). - */ - const val KEY_MARGIN_X_DP = 2.25f - const val KEY_MARGIN_Y_DP = 4.75f - const val NINE_KEY_MARGIN_X_DP = 2.5f - const val NINE_KEY_MARGIN_Y_DP = 2.75f - const val KEY_INNER_MARGIN_DP = 8f + /** All boards keep the original six-unit gap between key caps. */ + const val KEY_MARGIN_X_DP = 3f + const val KEY_MARGIN_Y_DP = 3f + const val NINE_KEY_MARGIN_X_DP = 3f + const val NINE_KEY_MARGIN_Y_DP = 3f + const val KEY_INNER_MARGIN_DP = 3f /** * The expanded candidates page insets its content 2.5 units a side, diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt index b5a2faac..a6c2f889 100644 --- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt @@ -5,55 +5,42 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -/** Regression coverage for current-main production key geometry. */ +/** Regression coverage for the original production key geometry. */ class ProductionKeyPolicyTest { @Test - fun twentySixKeyBottomRowCarriesTheReferenceWeights() { + fun twentySixKeyBottomRowKeepsTheOriginalFiveKeyWidths() { val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() - // 123 | ,。 | , | 空格 | 。 | 中/英 | 确定, in the reference's own units - // (style.xml:957-978). - assertEquals(70.6f, weights.leftOuter, 0.0001f) - assertEquals(37.6f, weights.merged, 0.0001f) - assertEquals(35.5f, weights.symbol, 0.0001f) - assertEquals(133.6f, weights.space, 0.0001f) - assertEquals(38.6f, weights.rightInner, 0.0001f) - assertEquals(74.6f, weights.rightOuter, 0.0001f) + assertEquals(70f, weights.leftOuter, 0.0001f) + assertEquals(48f, weights.leftInner, 0.0001f) + assertEquals(154f, weights.space, 0.0001f) + assertEquals(48f, weights.rightInner, 0.0001f) + assertEquals(70f, weights.rightOuter, 0.0001f) } @Test - fun twentySixKeyBottomRowKeepsTheSpaceBarNearTheCentreLine() { - val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() - // The reference's own row is a touch heavier on the right: 确定 is wider - // than 123, and the two symbol keys bookend the space bar. - val left = weights.leftOuter + weights.merged + weights.symbol - val right = weights.symbol + weights.rightInner + weights.rightOuter - assertEquals(143.7f, left, 0.0001f) - assertEquals(148.7f, right, 0.0001f) - assertEquals(5f, right - left, 1f) + fun twentySixKeyBottomRowKeepsTheSpaceBarCentred() { + listOf(false, true).forEach { landscape -> + val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights(landscape) + assertEquals(weights.leftOuter + weights.leftInner, weights.rightInner + weights.rightOuter, 0.0001f) + } } @Test - fun twentySixKeyBottomRowGivesLandscapeASpaceBarLikeTheReference() { + fun landscapeKeepsItsWiderSpaceBar() { val portrait = ProductionKeyPolicy.twentySixKeyBottomRowWeights() val landscape = ProductionKeyPolicy.twentySixKeyBottomRowWeights(landscape = true) - // 参考键盘横屏皮肤只放大空格键(133.6 -> 182.2,×1.3638),其余键不动。 assertEquals(portrait.leftOuter, landscape.leftOuter, 0.0001f) - assertEquals(portrait.merged, landscape.merged, 0.0001f) - assertEquals(portrait.symbol, landscape.symbol, 0.0001f) + assertEquals(portrait.leftInner, landscape.leftInner, 0.0001f) assertEquals(portrait.rightInner, landscape.rightInner, 0.0001f) assertEquals(portrait.rightOuter, landscape.rightOuter, 0.0001f) - assertEquals(182.2f, landscape.space, 0.0001f) - assertEquals(182.2f / 133.6f, landscape.space / portrait.space, 0.0001f) + assertEquals(210f, landscape.space, 0.0001f) } @Test - fun nineKeyBottomRowUsesTheReferencesOwnWeights() { + fun nineKeyBottomRowKeepsTheOriginalWidths() { val weights = ProductionKeyPolicy.nineKeyBottomRowWeights() - assertEquals(45.5f, weights.side, 0.0001f) - assertEquals(134f, weights.space, 0.0001f) - // The three fill the reference's 225-unit centre column exactly, and - // the space bar keeps better than twice a side key's width. - assertEquals(225f, weights.side * 2 + weights.space, 0.0001f) + assertEquals(0.7f, weights.side, 0.0001f) + assertEquals(1.6f, weights.space, 0.0001f) assertTrue(weights.space > weights.side * 2) } @@ -68,5 +55,4 @@ class ProductionKeyPolicyTest { assertEquals(balanced.leftOuter, balanced.rightOuter, 0.0001f) assertEquals(0.925f, balanced.leftOuter, 0.0001f) } - } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 549d4cef..78b4b203 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -166,7 +166,11 @@ Add new entries at the end. The page opens with 全部 / 单字 as one option group over the list (`input_more_cand.xml:9-13`; `cls_filter_button`, `style.xml:773-780`), the list sits in one rounded card (radius 11, 2 edge, `cls_morecand_bk`, `style.xml:1162-1166`), and the backspace floats in that card's bottom-right corner at 25.2% by 20.2% of the list, rounding only that corner with the card's own radius (`bkround="0,0,11,0"`, `input_more_cand.xml:18-26`, key face `cls_button_backspace` over `cls_bk_gray_circle`, `style.xml:828-835`). Every cell is the same flat label with a hairline edge (`cls_morecands_button`, `style.xml:1141-1149`), and no cell carries the accent tile, because the skin gives the expanded list no first-item class. The corner key deletes one letter of the open composition and the page stays open on the shorter list. *Reason:* the page used to be the key grid itself — one bordered tile per candidate, the first one filled in the accent — which is not the surface the reference ships. 单字 keeps single characters (`is_single="true"` on that option), and 全部 is the page's own default (`selected="true"`), so the two chips do what their labels say. The page's left syllable list (`FilterListLayout name="more_cand_filter_list"`, `input_more_cand.xml:17-18`, commented 音节过滤) is not in this change: the 26-key path has no syllable-fixing state yet, and the list would have nothing to filter by. -- **The 26-key bottom row is the reference's seven-key row.** +- **Keep the original key layout (supersedes the layout ports below).** + Restore the layout from `cb3bc2a`, before the reference-alignment batch: equal letter cells on the indented second row, 1.25-wide Shift/backspace, the centred five-key 26-key bottom row, the separate nine-key zero, and the original stroke and numeric grids. All key caps keep the original six-unit gap. Toolbar icons have no resting key fill. Input engines, candidate improvements, settings, letter hints and gesture fixes remain. + *Reason:* the owner confirmed that the original keys were correct and asked to restore them. Skin declarations alone do not establish which keys the reference shows at runtime; they do not justify replacing our working layout. + +- **Historical reference port: the 26-key bottom row was changed to seven keys.** The row is 123 | ,。 | , | 空格 | 。 | 中/英 | 确定, with the merged key's corner mark and the two symbol keys as the reference ships them. The merged key is `ButtonPeriodComma`: Chinese draws `text=","` with the always-visible corner `ficon_text="。"` (`skin-full/assets/skin/default/layout/input_kbd_pinyin26.xml:145-152`) and English draws `text="."` with the corner `","` and the bubble `", ."` (`input_kbd_english26.xml:154-161`) — the wide key leads with the period in English, which our version had reversed. The two symbol keys are the `ButtonSymbol` pair the reference marks `visible="true"` either side of the space bar (`input_kbd_pinyin26.xml:153,156`, again in `skin-reference/input_kbd_pinyin26.xml:144,153`; English `input_kbd_english26.xml:162-164,174-175`): they carry the class's own text — full-width `,` and `。` in Chinese (`cls_keyboard_button_comma` / `cls_keyboard_button_period`, `style.xml:335-356`), `,` and `.` in English — and their `uri_adapter_symbol` replaces the pair with `"/"` and `"."` in an address or e-mail field, the same fields whose rail carries the URL list. Weights are the reference's own: 70.6 / 37.6 / 35.5 / 133.6 / 35.5 / 38.6 / 74.6 (`style.xml:957-978`), which in landscape only widens the space bar to 182.2 (`skin-reference/land/style.xml:115`). *Reason:* the reference's row has seven keys, not five: the earlier row folded the two symbol keys away and gave the space bar the room, so the 123 and 中/英 keys sat wider than the reference draws them and no key offered `/` or a second `,`/`。`. The two keys' label is inferred from the element's absent `text` plus `uri_adapter_symbol` on the class text: under that reading every attribute in the layout has a job. A device capture can overrule it — the skin alone cannot say whether the app hides these two keys at runtime, since the same file ships both the merged key and them. From 3304d2996e2291dcea6e7cc57f4398d85c2d5ed9 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:16:18 +0800 Subject: [PATCH 91/94] Match the settings accent, centre the launcher pages and accept the official model packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard and the app pages now share one accent, the launcher grids stay centred at any window width, and the toolbar keeps our own key order. The voice-model page lists the official download addresses, and its own import takes the tar.bz2 packages those addresses serve. - Unify the accent (light #1668D0, dark #2D6FD6) across the keyboard palette and setup_primary, with white on the accent, and give the toolbar icons the weight they need to read on the strip. A test pins the two together. - Centre the tools and keyboard-switcher grids: the reference's 88.9% width was computed but never applied, so the rows sat left of the window centre. The rows also follow the window when it changes width. - Keep our own toolbar order (切换键盘, 剪贴板, 表情, 文本编辑, 撤销, 更多), in the reference's scrolling strip. docs/DECISIONS.md records that this is deliberate against the reference's own row. - Refresh the SMS code chip from a ContentObserver on Telephony.Sms.CONTENT_URI with a 200 ms debounce, so a code that arrives while the keyboard is open shows up; unregister it when the chip is off, the field is a password or the view detaches. SmsCodeExtractor also reads the layouts real messages use. - Import the official sherpa-onnx tar.bz2 model packages as they arrive, with commons-compress for bzip2 (java.util.zip has zip and gzip only), the application/x-bzip2 MIME type in the picker, and the archive's own traversal and size checks kept. - List the official model addresses on the voice-model page with copy buttons, and centre that page's content and cards. - Hide the handwriting entry (HandwritingFeaturePolicy.ENTRY_OFFERED): the pad is still rough. The provider, the pad and the strokes table stay in the build. - Name the release assets openIME-llm-v.apk (the offline voice models) and openIME-v.apk (the smaller package), and compare the draft's asset names against those two instead of counting them, so a tag run with both APKs attached can publish. Co-Authored-By: Claude Code --- .github/workflows/release.yml | 15 +- CHANGELOG.md | 8 + README.md | 5 +- README.zh-CN.md | 4 +- THIRD_PARTY_NOTICES.md | 3 +- app/build.gradle.kts | 4 + .../FloatingGameKeyboardInstrumentedTest.kt | 20 +- .../openime/LauncherTileInstrumentedTest.kt | 10 +- .../openime/PanelAlignmentInstrumentedTest.kt | 145 ++++++++++++ .../VoiceModelActivityInstrumentedTest.kt | 104 +++++++++ .../licenses/commons-compress-Apache-2.0.txt | 207 ++++++++++++++++++ .../llc/slacker/openime/VoiceModelActivity.kt | 139 +++++++++--- .../handwriting/HandwritingProvider.kt | 13 ++ .../openime/keyboard/ImeKeyboardView.kt | 38 ++++ .../slacker/openime/keyboard/ImeTopZone.kt | 16 +- .../openime/keyboard/SmsCodeExtractor.kt | 101 +++++++-- .../slacker/openime/panel/ImePanelRenderer.kt | 43 +++- .../slacker/openime/theme/ImeDesignTokens.kt | 15 +- .../openime/voice/VoiceModelArchive.kt | 32 ++- .../openime/voice/VoiceModelDownloads.kt | 41 ++++ .../java/llc/slacker/openime/ContrastTest.kt | 4 +- .../openime/HandwritingFeaturePolicyTest.kt | 10 + .../slacker/openime/SmsCodeExtractorTest.kt | 146 ++++++++++-- .../llc/slacker/openime/TokenDriftTest.kt | 12 + .../slacker/openime/VoiceModelArchiveTest.kt | 54 ++++- docs/DECISIONS.md | 6 +- docs/LICENSING.md | 4 +- docs/LOCAL_VOICE_MODEL.md | 3 +- docs/RELEASE.md | 12 +- scripts/release_build.sh | 9 +- 30 files changed, 1087 insertions(+), 136 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt create mode 100644 app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt create mode 100644 app/src/main/assets/licenses/commons-compress-Apache-2.0.txt create mode 100644 app/src/main/java/llc/slacker/openime/voice/VoiceModelDownloads.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c825a73..79b33aed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,11 +257,11 @@ jobs: channel_flags=(--prerelease) latest_flag=--latest=false fi - # The full and lite APKs are the assets; their SHA-256 and the signing certificate + # The -llm and plain APKs are the assets; their SHA-256 and the signing certificate # are in the release notes, and the licenses ship inside the APK. gh release create "$GITHUB_REF_NAME" \ - release-files/openIME-*-arm64-release.apk \ - release-files/openIME-*-arm64-lite-release.apk \ + release-files/openIME-llm-v*.apk \ + release-files/openIME-v*.apk \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ @@ -269,9 +269,12 @@ jobs: --title "openIME $GITHUB_REF_NAME" \ --notes-file release-files/RELEASE_NOTES.md - count="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" - if [[ "$count" -ne 1 ]]; then - echo "::error::Expected exactly the APK as release asset, found $count; the draft was left unpublished." + names="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | sort)" + wanted="$(printf 'openIME-%s.apk\nopenIME-llm-%s.apk\n' "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" | sort)" + if [[ "$names" != "$wanted" ]]; then + echo "::error::Expected the -llm and the plain APK as release assets; the draft was left unpublished." + echo "found:" + echo "$names" exit 1 fi gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false "$latest_flag" diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c8ad92..03940dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 键盘与设置页统一蓝色强调色,加深工具栏图标并恢复原工具栏顺序;修复切换键盘与工具页网格偏左,窗口变宽或变窄时保持居中。语音模型页统一内容与卡片边距,增加可复制的官方下载地址及导入步骤。 + +- 短信验证码候选现在会自己刷新。此前顶栏那条「验证码 123456,点击输入」只在键盘弹出或设置变化时算一次,键盘开着时新收到的短信不会出现;现在注册短信内容变化的监听(`Telephony.Sms.CONTENT_URI`),200 毫秒防抖后重算一次候选,权限被收回或键盘收起时自动注销。取出验证码的规则也放宽到实际收到的各种写法:标签与验证码之间可以换行、中文冒号与全角数字都能认,数字可以带空格或连字符分组(`876 523`),英文标签(`code`、`passcode`、`OTP`、`verification code` 等)同样识别;同时收紧误报,电话、日期、订单号和「共/请于/有效期」这类状态句子里的数字不再当验证码。 + +- 官方语音模型包可以直接导入。sherpa-onnx 的模型发布是 `tar.bz2`,此前导入器只认 zip/tar/tar.gz,用户必须自己解压改名;现在按魔数(`BZh` 加 1–9)与扩展名(`.tar.bz2`、`.tbz2`、`.tbz`)都认,选择器的类型过滤补上 `application/x-bzip2`,解压走 Apache Commons Compress 的 `BZip2CompressorInputStream`(平台自带的 `java.util.zip` 只有 zip 和 gzip,没有 bzip2),路径穿越与解压体积上限两道检查对其他格式一样生效。 + +- 手写输入的入口暂时隐藏。笔画匹配的手写板还在实验阶段,笔画表和识别代码都留在包里,只是工具页与切换键盘页不再列出入口;需要时改一个开关就能放回来。 + - 恢复豆包对齐前的按键布局:26 键第二行等宽、Shift/退格原宽度和五键底行;九键独立 0 键与原底行比例;笔画、数字页恢复原排列。键帽恢复统一 6 单位间距,工具栏恢复无常驻底色图标。保留词库、候选、设置、字母角标与手势修复。 - 九键的拼音组合补到参考实现的规模。参考每按一次键都对整条开放数字尾做全量格枚举,三处一起给出「组合」:左栏构造器(libshell.so `0x5ba1e0`)把音节、**首键字母**(每个字母连同 `add w8, w8, #0x20` 的大小写孪生各一条,`0x5ba608`–`0x5ba704`)和首位数字项 push 进同一个 vector,全程没有 `take`,只有 `reserve(新标记数 + 8)`(`0x5ba430`)——逐条解码 `syllable.dat` 的数:`64` 十项、`9426` 十八项、`5426` 十五项;**模糊音在数字层就扩充读法**,data3 每条记录带「达到这条读法所需的最小模糊掩码」,pass2(`0x5b962c`)按 `mask ⊆ 用户fuzzy` 放行,于是节点 `64` 在 n↔l 打开时多一条 `li`(data3[275],mask 0x0008)、节点 `426` 多出 `fang`(0x0010)、`hang`(0x0040),全开时 `9426` 的读法从 19 涨到 32、`5426` 从 18 涨到 30;候选侧同样是全量枚举,只有一条「单调最远前沿」剪枝,没有计数上限。我们这边左栏只列音节、字母仅在下一次按键时给、`readingOptions` 截 12 条(喂给 librime 的读数只取 6 条),而模糊音只传给了词表查询、解码一条都不用,所以 `li`、`xiang` 这类读法永远出不来,`64` 的左栏只有 3 条。现在照参考补齐:左栏对任意长度的开放尾都列出首键能起音节的字母(`i`/`u`/`v` 起不了音节,仍是死路不列;参考另推的大写孪生是九键英文用的,按已定范围不做),上限 12→48(参考不截断,这里是滚动列表的上界);解码器按参考 data3 的结构给每个音节建「模糊变体」条目——挂在原音节的数字节点下、带着所需规则集,查询时只有规则全开才放行——`64` 开 n↔l 后给出 `li` 与「里」,`9426` 给出 `xiang`/`zhang`/`zang`/`zan`/`zao`;路径上限 12→24,送 librime 的读数 6→12(参考无上限,我们每条读数一次 JNI 查询,保留上限但放宽)。左栏仍只收明文拼写(参考的左栏构造器只取 mask 为 0 的记录,`0x5ba378`),所以模糊读法落在候选与拆分里,不进左栏。另记一笔:`ENGINE-SPEC.md:222` 把「独立模糊展开路径 0x6c0508 族」记成九键的是错的,那一族属于五笔引擎(`oime_engine::wubi::Engine`,调用点 `0x6b394c`),按「他们错了,我们保持一致」处理,不照抄。 diff --git a/README.md b/README.md index 67afebc1..d3224ca3 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,12 @@ The app has no `INTERNET` permission, so your text cannot leave the phone. ## Install 1. Open [Releases](https://github.com/Slacker-LLC/openIME/releases). - Download the latest `openIME-v*-arm64-release.apk`. + Download `openIME-llm-v*.apk` for the build that carries the offline voice + models, or `openIME-v*.apk` for the smaller build that has none. 2. Check the SHA-256 checksum that the release notes show: ```bash - sha256sum openIME-v*-arm64-release.apk + sha256sum openIME-llm-v*.apk ``` 3. Install the APK. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5a0391bb..310793dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,12 +59,12 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 ## 下载与安装 -1. 在 [Releases](https://github.com/Slacker-LLC/openIME/releases) 下载最新的 `openIME-v*-arm64-release.apk`。 +1. 在 [Releases](https://github.com/Slacker-LLC/openIME/releases) 下载:带离线语音模型的 `openIME-llm-v*.apk`,或是不含模型的小体积版本 `openIME-v*.apk`。 发布包仅支持 `arm64-v8a` 设备(绝大多数近年的 Android 手机),系统要求 Android 8.0(API 26)及以上。 2. 对照发布说明里的 SHA-256 校验后再安装: ```bash - sha256sum openIME-v*-arm64-release.apk + sha256sum openIME-llm-v*.apk ``` 3. 打开 openIME,按引导启用输入法并切换到 openIME。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 853e7f9b..8275b7c1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,13 +14,14 @@ This file covers only the third-party components. | sherpa-onnx Android runtime | `app/libs/sherpa-onnx-1.13.6.aar` | https://github.com/k2-fsa/sherpa-onnx | Apache-2.0 | The APK contains `assets/licenses/sherpa-onnx-Apache-2.0.txt`. | | Streaming Paraformer bilingual (Chinese and English) INT8 model | `app/src/main/assets/models/voice/bilingual-paraformer/` | https://huggingface.co/csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en | Apache-2.0 | The APK contains only `encoder.int8.onnx`, `decoder.int8.onnx` and `tokens.txt`. `assets/licenses/paraformer-model-Apache-2.0.txt` is included. `manifest.json` pins the model ID, the version and the file hashes. | | CT-Transformer punctuation INT8 model | `app/src/main/assets/models/voice/punctuation/` | https://modelscope.cn/models/iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch and https://github.com/k2-fsa/sherpa-onnx/releases/tag/punctuation-models | Apache-2.0 | We pin the `2024-04-12-int8` export. The manifest lists the file hashes. The APK contains `ct-transformer-Apache-2.0.txt`. | +| Apache Commons Compress | `org.apache.commons:commons-compress:1.27.1` (Maven) | https://commons.apache.org/proper/commons-compress/ | Apache-2.0 | Imported to read the bzip2 archives the official sherpa-onnx voice-model releases ship. The APK contains `assets/licenses/commons-compress-Apache-2.0.txt` (the artifact's own `META-INF/NOTICE.txt` and `META-INF/LICENSE.txt`). | | Microsoft Fluent Emoji | `app/src/main/assets/emoji/fluent/` | https://github.com/microsoft/fluentui-emoji | MIT | The APK has only the emoji images that the app uses. It contains `assets/licenses/fluent-emoji-MIT.txt`. | ## Checks before a release - Do not delete `LICENSE`, `COPYING`, `AUTHORS` or `NOTICE` files from vendored source directories. - Keep the license texts in `app/src/main/assets/licenses/` in the APK. - This includes Rime Ice, librime, OpenCC, Snappy, sherpa-onnx, the Paraformer model and Fluent Emoji. + This includes Rime Ice, librime, OpenCC, Snappy, sherpa-onnx, Apache Commons Compress, the Paraformer model and Fluent Emoji. - When you update the voice runtime or a model, check the license of the new version. Do not rely on the earlier result. - When the dictionary source or its pinned commit changes, update this file, [docs/LICENSING.md](docs/LICENSING.md) and the license files in the APK. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12ded606..80b8c015 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,6 +214,10 @@ androidComponents { dependencies { implementation(files("libs/sherpa-onnx-1.13.6.aar")) + // The official sherpa-onnx voice-model archives ship as tar.bz2, and the + // platform ships no bzip2 decoder (`java.util.zip` has zip/gzip only), so + // the import needs one from here. + implementation("org.apache.commons:commons-compress:1.27.1") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20260814") androidTestImplementation("androidx.test.ext:junit:1.3.0") diff --git a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt index 28b3985d..fac3c89a 100644 --- a/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/FloatingGameKeyboardInstrumentedTest.kt @@ -103,9 +103,14 @@ class FloatingGameKeyboardInstrumentedTest { return find(row) } - /** The idle row is the reference's own order, in the reference's own scrolling strip. */ + /** + * The idle row keeps our own order in the reference's scrolling strip. The + * reference's own row leads with the clipboard, then emoji, then the board + * selector (`input_toolbar_idle.xml:21-25`); the owner asked for our order + * back on 2026-10-16, so the order here is deliberate, not a drift. + */ @Test - fun theIdleIconsFollowTheReferencesOrderInsideAScrollingStrip() = withKeyboard { harness, _, keyboard -> + fun theIdleIconsKeepOurOrderInsideAScrollingStrip() = withKeyboard { harness, _, keyboard -> harness.awaitMain { val strip = keyboard.findViewWithTag("toolbar-icon-strip") ?: return@awaitMain null val scroll = keyboard.findViewWithTag("toolbar-icon-scroll") @@ -116,13 +121,10 @@ class FloatingGameKeyboardInstrumentedTest { val labels = (0 until strip.childCount).map(strip::getChildAt) .filter { it.visibility == View.VISIBLE && it.contentDescription != null } .map { it.contentDescription.toString() } - listOf("剪贴板", "表情", "切换键盘").forEach { - assertTrue("$it must keep its key in the idle row, got $labels", it in labels) - } - assertTrue( - "The reference puts the clipboard first, then emoji, then the board selector: $labels", - labels.indexOf("剪贴板") < labels.indexOf("表情") && - labels.indexOf("表情") < labels.indexOf("切换键盘"), + assertEquals( + "The idle row keeps our own order, see docs/DECISIONS.md", + listOf("切换键盘", "剪贴板", "表情", "文本编辑", "撤销", "更多"), + labels, ) true } diff --git a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt index 745348e2..c52e3b41 100644 --- a/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/LauncherTileInstrumentedTest.kt @@ -8,6 +8,7 @@ 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.handwriting.HandwritingFeaturePolicy import llc.slacker.openime.handwriting.StrokeMatchHandwritingProvider import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.panel.TileGridPolicy @@ -190,11 +191,14 @@ class LauncherTileInstrumentedTest { } } assertTrue("The switcher keeps the pinyin keyboards", labels.contains("拼音 26 键")) - // The handwriting tile follows the strokes table, which only local - // builds ship (`StrokeMatchHandwritingProvider.assetPresent`). + // The handwriting entry appears only when the pad is offered and the + // strokes table is there; local builds ship the table but the pad is + // hidden for now (`HandwritingFeaturePolicy.ENTRY_OFFERED`). assertEquals( "The switcher carries the handwriting entry with its strokes table", - StrokeMatchHandwritingProvider.assetPresent(keyboard.context), + HandwritingFeaturePolicy.entryShown( + StrokeMatchHandwritingProvider.assetPresent(keyboard.context), + ), labels.contains("手写输入"), ) keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) diff --git a/app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt new file mode 100644 index 00000000..3dd498ce --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PanelAlignmentInstrumentedTest.kt @@ -0,0 +1,145 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.core.Panel +import llc.slacker.openime.keyboard.ImeKeyboardView +import llc.slacker.openime.keyboard.ImeTopZone +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.theme.ImeAppearance +import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Proxy + +@RunWith(AndroidJUnit4::class) +class PanelAlignmentInstrumentedTest { + private fun withKeyboard(test: (DirectActivityHarness, ImeKeyboardView) -> Unit) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + val listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> if (method.returnType == java.lang.Boolean.TYPE) false else null } as ImeKeyboardView.Listener + ImeKeyboardView(activity, listener).also { + activity.findViewById(android.R.id.content).addView(it) + } + } + try { + harness.awaitMain { if (keyboard.width > 0) true else null } + test(harness, keyboard) + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + private fun center(view: View): Float { + val location = IntArray(2).also(view::getLocationOnScreen) + return location[0] + view.width / 2f + } + + @Test + fun launcherRowsStayCenteredWhenTheWindowChangesWidth() = withKeyboard { harness, keyboard -> + val fullWidth = keyboard.width + for (panel in listOf(Panel.KEYBOARD_SELECT, Panel.TOOLS)) { + harness.awaitMain { keyboard.showPanel(panel); true } + for (width in listOf(fullWidth, fullWidth * 3 / 4, fullWidth)) { + harness.awaitMain { + keyboard.layoutParams = keyboard.layoutParams.apply { this.width = width } + true + } + harness.awaitMain { + val grid = keyboard.findViewWithTag("tile-grid") + // The page slides in on its own translation; measure it at rest, + // or the row reads a few px off the window's centre. + val overlay = keyboard.findViewWithTag("panel-overlay") + if (keyboard.width != width || grid == null || grid.width <= 0 || grid.isLayoutRequested || + overlay?.translationX != 0f + ) { + null + } else { + val offset = keyRowOffset(keyboard, grid) + for (rowIndex in 0 until grid.childCount) { + val row = grid.getChildAt(rowIndex) as ViewGroup + val first = (row.getChildAt(0) as ViewGroup).getChildAt(0) + val last = (row.getChildAt(row.childCount - 1) as ViewGroup).getChildAt(0) + val left = IntArray(2).also(first::getLocationOnScreen)[0] + val right = IntArray(2).also(last::getLocationOnScreen)[0] + last.width + assertEquals( + "$panel row $rowIndex at width $width ($offset)", + center(keyboard), + (left + right) / 2f, + 2f, + ) + assertTrue("Tiles fit inside their grid", first.width > 0 && last.right <= last.parent.let { it as View }.width) + } + true + } + } + } + } + } + + /** A one-line read of where the grid sits, for an assertion failure. */ + private fun keyRowOffset(keyboard: ImeKeyboardView, grid: View): String { + fun box(view: View?): String = view?.let { + val at = IntArray(2).also(it::getLocationOnScreen)[0] + "$at+${it.width}p${it.paddingLeft}/${it.paddingRight}" + } ?: "gone" + return "keyboard=${box(keyboard)}" + + " overlay=${box(keyboard.findViewWithTag("panel-overlay"))}" + + " grid=${box(grid)}" + } + + @Test + fun toolbarKeepsTheOriginalOrderInNormalAndCompactModes() = withKeyboard { harness, keyboard -> + harness.awaitMain { + val top = keyboard.findViewWithTag("ime_toolbar") + ?: (0 until keyboard.childCount).map { keyboard.getChildAt(it) }.filterIsInstance().first() + val strip = keyboard.findViewWithTag("toolbar-icon-strip") + fun labels() = (0 until strip.childCount).map { strip.getChildAt(it) } + .filter { it.visibility == View.VISIBLE && it.contentDescription != null } + .map { it.contentDescription.toString() } + val normal = listOf("切换键盘", "剪贴板", "表情", "文本编辑", "撤销", "更多") + assertEquals(normal, labels()) + top.setCompactToolbar(true) + assertEquals(listOf("切换键盘", "常用语", "表情", "更多"), labels()) + top.setCompactToolbar(false) + assertEquals(normal, labels()) + true + } + } + + @Test + fun keyboardAndAppUseTheSameAccentAndToolbarIconsHaveFullerWeight() = withKeyboard { harness, keyboard -> + for (appearance in listOf(ImeAppearance.LIGHT, ImeAppearance.DARK)) { + harness.awaitMain { + keyboard.applyPersistedSettings(ImeTheme.IOS, appearance, false, false, false, false) + val tokens = ImeTheme.IOS.tokens(appearance) + val configuration = android.content.res.Configuration(keyboard.resources.configuration).apply { + uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (appearance == ImeAppearance.DARK) android.content.res.Configuration.UI_MODE_NIGHT_YES + else android.content.res.Configuration.UI_MODE_NIGHT_NO + } + val page = keyboard.context.createConfigurationContext(configuration) + assertEquals(SetupUi.accent(page), tokens.primary) + val icon = keyboard.findViewWithTag("keyboard-selector") + assertEquals(ImeSurfacePolicy.toolbarIcon(tokens), icon.imageTintList!!.defaultColor) + assertEquals(217, android.graphics.Color.alpha(icon.imageTintList!!.defaultColor)) + true + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt new file mode 100644 index 00000000..b874df3c --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/VoiceModelActivityInstrumentedTest.kt @@ -0,0 +1,104 @@ +package llc.slacker.openime + +import android.content.ClipboardManager +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.setup.SetupUi +import llc.slacker.openime.voice.VoiceModelDownloads +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.abs + +@RunWith(AndroidJUnit4::class) +class VoiceModelActivityInstrumentedTest { + @Test + fun contentIsCenteredAndEveryCardSharesOneTextAndActionColumn() { + DirectActivityHarness(VoiceModelActivity::class.java).use { harness -> + harness.launch() + val scroll = harness.awaitMain { activity -> + val content = activity.window.decorView.findViewWithTag("voice-model-content") + if (content == null || content.width == 0 || content.isLayoutRequested) null + else content.parent as ScrollView + } + val fullWidth = harness.awaitMain { scroll.width } + // Exercise relayout as well as the device's current width. Run this on a + // tablet/landscape emulator too so the 600dp cap is exercised. + for (width in listOf(fullWidth, fullWidth * 3 / 4, fullWidth)) { + harness.awaitMain { + scroll.layoutParams = scroll.layoutParams.apply { this.width = width } + true + } + harness.awaitMain { activity -> + val content = scroll.findViewWithTag("voice-model-content") + if (scroll.width != width || content.isLayoutRequested || scroll.isLayoutRequested) { + return@awaitMain null + } + val availableWidth = scroll.width - scroll.paddingLeft - scroll.paddingRight + assertEquals(minOf(availableWidth, SetupUi.dp(activity, 600)), content.width) + assertTrue("Content has symmetric margins", abs( + (content.left - scroll.paddingLeft) - + (scroll.width - scroll.paddingRight - content.right), + ) <= 1) + val cards = (0 until content.childCount).map(content::getChildAt) + .filterIsInstance() + .filter { it.background != null } + assertTrue("Current model, hotwords, import and download cards exist", cards.size >= 6) + val pad = SetupUi.dp(activity, 16) + cards.forEach { card -> + assertEquals(pad, card.paddingLeft) + assertEquals(pad, card.paddingRight) + assertEquals(content.paddingLeft, card.left) + assertEquals(content.width - content.paddingRight, card.right) + for (index in 0 until card.childCount) { + val child = card.getChildAt(index) + assertEquals("Card text and actions start together", pad, child.left) + assertEquals("Card text and actions end together", card.width - pad, child.right) + } + } + true + } + } + } + } + + @Test + fun eachDownloadButtonCopiesItsDisplayedOfficialAddress() { + DirectActivityHarness(VoiceModelActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { activity -> if (activity.hasWindowFocus()) true else null } + val originalClip = harness.awaitMain { activity -> + activity.getSystemService(ClipboardManager::class.java) + }.primaryClip + try { + for (model in VoiceModelDownloads.models) { + harness.awaitMain { activity -> + val card = activity.window.decorView.findViewWithTag("voice-model-download-${model.id}") + ?: return@awaitMain null + val url = card.findViewWithTag("voice-model-url-${model.id}") + assertEquals(model.url, url.text.toString()) + assertTrue("URL can also be selected", url.isTextSelectable) + val button = (0 until card.childCount).map(card::getChildAt).filterIsInstance