From 6637d9fdcd407f7b63ab6f4bc05291506dbf43cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:27:41 +0000 Subject: [PATCH 01/12] build: bump the gradle-minor-and-patch group across 2 directories with 5 updates Bumps the gradle-minor-and-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | com.android.application | `8.7.3` | `8.13.2` | | [org.jetbrains.kotlin.android](https://github.com/JetBrains/kotlin) | `2.1.0` | `2.4.20` | | androidx.test.ext:junit | `1.2.1` | `1.3.0` | | androidx.test:runner | `1.6.2` | `1.7.0` | | [gradle-wrapper](https://github.com/gradle/gradle) | `8.11.1` | `8.14.5` | Bumps the gradle-minor-and-patch group with 2 updates in the /app directory: androidx.test.ext:junit and androidx.test:runner. Updates `com.android.application` from 8.7.3 to 8.13.2 Updates `org.jetbrains.kotlin.android` from 2.1.0 to 2.4.20 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.0...v2.4.20) Updates `androidx.test.ext:junit` from 1.2.1 to 1.3.0 Updates `androidx.test:runner` from 1.6.2 to 1.7.0 Updates `gradle-wrapper` from 8.11.1 to 8.14.5 - [Release notes](https://github.com/gradle/gradle/releases) - [Commits](https://github.com/gradle/gradle/compare/v8.11.1...v8.14.5) Updates `androidx.test.ext:junit` from 1.2.1 to 1.3.0 Updates `androidx.test:runner` from 1.6.2 to 1.7.0 --- updated-dependencies: - dependency-name: com.android.application dependency-version: 8.13.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch - dependency-name: org.jetbrains.kotlin.android dependency-version: 2.4.20 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch - dependency-name: androidx.test.ext:junit dependency-version: 1.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch - dependency-name: androidx.test:runner dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch - dependency-name: gradle-wrapper dependency-version: 8.14.5 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch - dependency-name: androidx.test.ext:junit dependency-version: 1.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch - dependency-name: androidx.test:runner dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-minor-and-patch ... Signed-off-by: dependabot[bot] --- app/build.gradle.kts | 4 +- build.gradle.kts | 4 +- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 146 ++++++++++++++++-- gradlew.bat | 188 +++++++++++------------ 6 files changed, 229 insertions(+), 115 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6648169..f8468d71 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -125,8 +125,8 @@ dependencies { implementation(files("libs/sherpa-onnx-1.13.6.aar")) testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20260814") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") } // Kotlin 2.1 writes JVM unit-test classes to tmp/kotlin-classes while this diff --git a/build.gradle.kts b/build.gradle.kts index 1020b258..1c692a4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.4.20" apply false } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 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

Gn4F+B~IivciU9^t0j-Mgao3 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 DDw4oP diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f190f30a..34dabfe0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=600000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0f448472..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -15,21 +15,66 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link app_path=$0 + +# Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do - ls=$( ls -ld -- "$app_path" ) + ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( @@ -41,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -65,11 +110,11 @@ nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NonStop* ) nonstop=true ;; + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -105,7 +150,6 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" - ;; esac case $MAX_FD in #( '' | soft) :;; #( @@ -114,24 +158,94 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" - ;; esac fi -# Collect all arguments for the java command, stracks://am the program://ent://s by:// -# temporary://file://to://workaround long://command://line://limits://in://some://shells. -# shellcheck disable=SC2006 +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" -# Stop when "xeli" is in effect, otherwise the shift won't work below. -# Also, the curved brackets here are required by the csh due to the {}. -# shellcheck disable=SC3011 -if [ "${-#*e}" != "$-" ] ; then - set +e +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 9d21a218..5eed7ee8 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,94 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From 10811f731ca529989d2ef54dd00a57ebcde6bf54 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:18:29 +0800 Subject: [PATCH 02/12] Nine-key pre-edit is always letters; a tapped initial in the rail is a choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-edit fell back to the raw digits whenever no word read every digit (669, or a long string whose top candidate covers only its start). The local decoder now spells any digits with letters (whole syllables, the start of an unfinished one), and alignment to Rime's top candidate takes its characters as far as they fit, by full reading, cut reading or initial (669 -> 模型 as mo'x), and spells the rest. Tapping a bare initial (m, n ...) in the left rail only showed it: Rime still read the digit and the pre-edit snapped back (6 -> o). It is now fixed like a syllable, so the candidates start with it (m -> 没). Also: the new AGP's GestureBackNavigation lint is suppressed on the three activities that already register OnBackInvokedCallback on 33+. Co-Authored-By: Claude Opus 5.5 --- .../slacker/openime/DebugKeyboardActivity.kt | 3 + .../slacker/openime/ImeSettingsActivity.kt | 4 + .../slacker/openime/LocalVoiceImeService.kt | 3 + .../openime/QuickPhraseEditActivity.kt | 4 + .../slacker/openime/SymbolManagerActivity.kt | 4 + .../openime/candidate/CandidatePipeline.kt | 10 + .../openime/candidate/NineKeyLocalDecoder.kt | 130 +++++++++++- .../openime/keyboard/ImeKeyboardView.kt | 12 +- .../llc/slacker/openime/NineKeyChineseTest.kt | 13 +- .../openime/NineKeyPreviewLettersTest.kt | 73 +++++++ gradlew.bat | 188 +++++++++--------- 11 files changed, 336 insertions(+), 108 deletions(-) create mode 100644 app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt diff --git a/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt b/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt index a9492058..6667a97f 100644 --- a/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt +++ b/app/src/debug/java/llc/slacker/openime/DebugKeyboardActivity.kt @@ -109,6 +109,9 @@ class DebugKeyboardActivity : Activity(), ImeKeyboardView.Listener, CandidateRes override fun nineKeyReadingFor(digits: String, candidate: String): List? = candidatePipeline.nineKeyReadingFor(digits, candidate) + override fun nineKeyPreviewFor(digits: String, candidate: String): List? = + candidatePipeline.nineKeyPreviewFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = candidatePipeline.nineKeyPathsFor(code) diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 66fe0133..fefabdfd 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -93,7 +93,11 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { super.onSaveInstanceState(outState) } + // Android 13+ gets the back gesture through the OnBackInvokedCallback + // registered in onCreate; this override only serves Android 12 and below, + // where back still arrives here. The app does not use AndroidX. @Deprecated("Deprecated in Java") + @android.annotation.SuppressLint("GestureBackNavigation") override fun onBackPressed() { handleBack() } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index af694044..32f71757 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -258,6 +258,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun nineKeyReadingFor(digits: String, candidate: String): List? = candidatePipeline.nineKeyReadingFor(digits, candidate) + override fun nineKeyPreviewFor(digits: String, candidate: String): List? = + candidatePipeline.nineKeyPreviewFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = candidatePipeline.nineKeyPathsFor(code) diff --git a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt index 9594729f..dc32a1b2 100644 --- a/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt +++ b/app/src/main/java/llc/slacker/openime/QuickPhraseEditActivity.kt @@ -76,7 +76,11 @@ class QuickPhraseEditActivity : Activity() { } } + // Android 13+ gets the back gesture through the OnBackInvokedCallback + // registered in onCreate; this override only serves Android 12 and below, + // where back still arrives here. The app does not use AndroidX. @Deprecated("Deprecated in Java") + @android.annotation.SuppressLint("GestureBackNavigation") override fun onBackPressed() { requestClose() } diff --git a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt index 727c6798..49ac9c8f 100644 --- a/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt +++ b/app/src/main/java/llc/slacker/openime/SymbolManagerActivity.kt @@ -66,7 +66,11 @@ class SymbolManagerActivity : Activity() { } } + // Android 13+ gets the back gesture through the OnBackInvokedCallback + // registered in onCreate; this override only serves Android 12 and below, + // where back still arrives here. The app does not use AndroidX. @Deprecated("Deprecated in Java") + @android.annotation.SuppressLint("GestureBackNavigation") override fun onBackPressed() { requestClose() } 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 c6184bf7..7f2a6f63 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -36,6 +36,13 @@ interface CandidateResolver { /** Syllables of [candidate] if it spells exactly all of [digits] (a lone key may be its initial); null otherwise. */ fun nineKeyReadingFor(digits: String, candidate: String): List? = null + /** + * Pre-edit syllables for [digits] given the top [candidate]: its reading + * when it spells them all, else the part it spells (its last syllable cut + * where the digits end) followed by a spelling of the rest. Never digits. + */ + fun nineKeyPreviewFor(digits: String, candidate: String): List? = null + /** Readings of [digits] for the left rail, best first (see [NineKeyReading]). */ fun nineKeyReadingsFor(digits: String, preferred: String?): List = emptyList() @@ -270,6 +277,9 @@ class CandidatePipeline internal constructor( override fun nineKeyReadingFor(digits: String, candidate: String): List? = nineKeyDecoder.readingFor(digits, candidate) + override fun nineKeyPreviewFor(digits: String, candidate: String): List? = + nineKeyDecoder.readingForPrefix(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = nineKeyUiState.pathsFor(code) 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 4ba03819..761c468e 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -65,6 +65,9 @@ internal class NineKeyLocalDecoder( private val syllablePrefixes = HashSet() private val completeSyllables = HashSet() + /** Digit prefix of a syllable -> the letters of the likeliest syllable it starts, and that syllable's score. */ + private val prefixSpellings = HashMap>() + /** Han character -> syllables that can read it, most likely first. */ private val readingsByChar = HashMap>() private var previousDigits = "" @@ -81,7 +84,13 @@ internal class NineKeyLocalDecoder( } if (isSyllableEntry(entry)) { completeSyllables += entry.pinyin - for (length in 1..entry.digits.length) syllablePrefixes += entry.digits.substring(0, length) + val score = entryScore(entry) + for (length in 1..entry.digits.length) { + val prefix = entry.digits.substring(0, length) + syllablePrefixes += prefix + val known = prefixSpellings[prefix] + if (known == null || score > known.second) prefixSpellings[prefix] = entry.pinyin.substring(0, length) to score + } } } } @@ -186,15 +195,19 @@ internal class NineKeyLocalDecoder( ?.pinyin ?.take(bounded.length) ?.takeIf { digitsForPinyin(it) == bounded } + // The pre-edit is always letters: when no word or path decides them, + // spellDigits reads whole syllables and the start of an unfinished one + // (6442646 -> ni hao go). Showing the digits instead left "669" or a + // whole digit string in the pre-edit until (or after) Rime answered. val preview = when { stable != null -> stable continuous != null -> continuous - bounded.length == 1 -> bounded - paths.isEmpty() -> bounded + bounded.length == 1 -> spellDigits(bounded).joinToString(" ") + paths.isEmpty() -> prefixPreview ?: spellDigits(bounded).joinToString(" ") continuationBase != null && validPreset.isEmpty() && prefixPreview != null -> prefixPreview paths.isNotEmpty() -> paths.first() prefixPreview != null -> prefixPreview - else -> bounded + else -> spellDigits(bounded).joinToString(" ") } previousDigits = bounded @@ -271,6 +284,107 @@ internal class NineKeyLocalDecoder( return null } + /** + * The pre-edit for a candidate that spells only the start of the digits, + * the usual case in long input: its readings, the last one possibly cut + * where the digits end (你好工 for 6442646 -> ni hao go), then + * [spellDigits] for anything after the word. Null when the candidate does + * not fit the digits at all. + */ + @Synchronized + fun readingForPrefix(digits: String, text: String): List? { + val bounded = digits.filter { it in '2'..'9' } + if (bounded.isEmpty() || text.isEmpty()) return null + readingFor(bounded, text)?.let { return it } + val chars = ArrayList() + var index = 0 + while (index < text.length) { + val cp = text.codePointAt(index) + chars += String(Character.toChars(cp)) + index += Character.charCount(cp) + } + // Each character by its full reading, else by the start of one: Rime's + // nine-key matches initials (669 -> 模型 as mo'x) and its key + // corrections accept cut syllables (go for gong). Take the characters, + // in order, as far as they fit the digits; the rest is spelled. + data class Walk(val parts: List, val full: Int) + var states = mapOf(0 to Walk(emptyList(), 0)) + var bestEnd = 0 + var best = Walk(emptyList(), 0) + for (char in chars) { + val next = HashMap() + for ((digitIndex, walk) in states) { + if (digitIndex == bounded.length) continue + for (reading in readingsByChar[char].orEmpty()) { + val code = digitsForPinyin(reading) ?: continue + for (length in code.length downTo 1) { + if (digitIndex + length > bounded.length) continue + if (!bounded.startsWith(code.substring(0, length), digitIndex)) continue + val end = digitIndex + length + val step = Walk(walk.parts + reading.take(length), walk.full + if (length == code.length) 1 else 0) + val known = next[end] + if (known == null || step.full > known.full) next[end] = step + } + } + } + if (next.isEmpty()) break + next.forEach { (end, walk) -> + if (end > bestEnd || (end == bestEnd && walk.full > best.full)) { + bestEnd = end + best = walk + } + } + states = next + } + if (best.parts.isEmpty()) return null + return if (bestEnd == bounded.length) best.parts else best.parts + spellDigits(bounded.substring(bestEnd)) + } + + /** + * Letters for [digits], one per digit, when no word decides them: the + * fewest whole syllables, the likeliest start of one for an unfinished + * end, and a key's first letter only where nothing else reads. Never the + * digits themselves. + */ + @Synchronized + fun spellDigits(digits: String): List { + val bounded = digits.filter { it in '2'..'9' } + val n = bounded.length + if (n == 0) return emptyList() + // A part is a whole syllable, or the start of one: natural at the end + // (the syllable is still being typed), a last resort in the middle. Fewer + // and whole parts win (a lone a / o / e between syllables counts double, + // a grid artefact as in ni'ha'o); then likelier syllables and words. + data class Spelling(val parts: List, val cost: Int, val score: Int, val partialEnd: Boolean = false) + val better = compareBy { it.cost }.thenBy { it.partialEnd }.thenByDescending { it.score } + val states = Array(n + 1) { ArrayList() } + states[0] += Spelling(emptyList(), 0, 0) + for (start in 0 until n) { + val from = states[start].sortedWith(better).take(SPELL_BEAM) + if (from.isEmpty()) continue + var node = root + 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) + for (before in from) { + for (entry in whole) { + val parts = before.parts + entry.pinyin + val cost = if (entry.pinyin.length == 1 && n > 1) 2 else 1 + states[end + 1] += Spelling(parts, before.cost + cost, before.score + entryScore(entry) + phraseBonus(parts)) + } + val (letters, score) = prefixSpellings[chunk] ?: continue + if (whole.any { it.pinyin == letters }) continue + val last = end + 1 == n + states[end + 1] += Spelling(before.parts + letters, before.cost + if (last) 1 else 3, before.score + score / 2, partialEnd = last) + } + } + } + states[n].minWithOrNull(better)?.let { return it.parts } + // Unreachable while every key starts some syllable; keep the letters promise anyway. + return listOf(bounded.map { KEY_LETTERS.getValue(it).first() }.joinToString("")) + } + /** * The readings offered in the left rail, best first: one syllable per item, * for the next character only (`ni`, `mi`, ...). Fixing one moves the list on @@ -613,11 +727,19 @@ internal class NineKeyLocalDecoder( private const val MAX_SYLLABLE_LENGTH = 6 private const val MAX_PATHS = 12 private const val MAX_BEAM = 12 + private const val SPELL_BEAM = 8 + private const val SPELL_BRANCHES = 8 private const val MAX_BRANCHES = 10 private const val MAX_CANDIDATES = 96 private const val PER_PATH_CANDIDATES = 24 private const val PART_PENALTY = 55 + /** The letters printed on each nine-key digit. */ + private val KEY_LETTERS = mapOf( + '2' to "abc", '3' to "def", '4' to "ghi", '5' to "jkl", + '6' to "mno", '7' to "pqrs", '8' to "tuv", '9' to "wxyz", + ) + fun digitsForPinyin(pinyin: String): String? { if (pinyin.isBlank()) return null val digits = StringBuilder(pinyin.length) 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 914281eb..e0361f46 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -2579,7 +2579,7 @@ open class ImeKeyboardView( topCandidate.offsetByCodePoints(0, fixedSyllables) } val syllables = candidateProvider - ?.nineKeyReadingFor(digits, topCandidate.substring(skipped)) + ?.nineKeyPreviewFor(digits, topCandidate.substring(skipped)) ?.takeIf { it.isNotEmpty() } ?: return val aligned = prefix + syllables.joinToString("'") @@ -2656,12 +2656,10 @@ open class ImeKeyboardView( syllables.forEach { spelled.append(NineKeyLocalDecoder.digitsForPinyin(it) ?: return) } if (digits.isEmpty() || !digits.startsWith(spelled)) return - if (!reading.complete) { - // A bare initial is orientation, not a decision: show it, fix nothing. - lastNineSegmentPrefix = prefix - publishNineKeyDigits(digits, preferredSuffix = syllables.single()) - return - } + // A bare initial (m, w, x ...) is a choice like any syllable: tapping it + // fixes that letter, so the candidates start with it (m -> 么 吗 们). + // Treated as a mere hint it changed nothing visible: Rime still read the + // digit and the pre-edit snapped back to its top candidate (6 -> o). val rest = digits.substring(spelled.length) if (rest.isEmpty()) { val last = syllables.last() diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index 8853fd7c..9b04bec4 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -39,9 +39,16 @@ class NineKeyChineseTest { } @Test - fun incompleteDigitOnlyPrefixesStayAsDigitsInsteadOfFakeLetters() { - assertEquals("2", resolve("2").preview) - assertEquals("6", resolve("6").preview) + fun aLoneKeyShowsALetterNotItsDigit() { + // The pre-edit is letters from the first key on; Rime's answer then + // aligns it to the top candidate (4 -> 和 -> h). + assertEquals("a", resolve("2").preview) + assertEquals("o", resolve("6").preview) + listOf("3", "4", "5", "7", "8", "9").forEach { digit -> + val preview = resolve(digit).preview + assertEquals("one letter for $digit", 1, preview.length) + assertEquals(digit, NineKeyLocalDecoder.digitsForPinyin(preview)) + } } @Test diff --git a/app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt new file mode 100644 index 00000000..a4e1870a --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/NineKeyPreviewLettersTest.kt @@ -0,0 +1,73 @@ +package llc.slacker.openime + +import llc.slacker.openime.candidate.CandidateEngine +import llc.slacker.openime.candidate.CandidatePipeline +import llc.slacker.openime.candidate.NineKeyLocalDecoder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +/** + * The nine-key pre-edit shows letters, never digits: beta6 left "669" or a + * whole digit string in it whenever no word read every digit. + */ +class NineKeyPreviewLettersTest { + private val engine = CandidateEngine() + private val pipeline = CandidatePipeline(engine) + private val decoder = NineKeyLocalDecoder(engine) + + private fun assertLettersFor(digits: String, preview: String) { + assertTrue("digits in the pre-edit for $digits: '$preview'", preview.none { it.isDigit() }) + val letters = preview.filter { it in 'a'..'z' } + assertEquals("one letter per digit for $digits: '$preview'", digits.length, letters.length) + assertEquals("letters must type the same digits: '$preview'", digits, NineKeyLocalDecoder.digitsForPinyin(letters)) + } + + @Test + fun everyDigitStringIsSpelledWithLettersOnly() { + val random = Random(20261005) + repeat(3000) { + val length = random.nextInt(1, 33) + val digits = (1..length).map { "23456789"[random.nextInt(8)] }.joinToString("") + assertLettersFor(digits, decoder.spellDigits(digits).joinToString("")) + } + } + + @Test + fun thePreviewNeverFallsBackToDigits() { + val random = Random(7) + val samples = listOf("644264658846649669", "669", "4", "5", "7", "8", "9", "6442646", "999999", "7777", "88888888") + + (1..400).map { (1..random.nextInt(1, 25)).map { "23456789"[random.nextInt(8)] }.joinToString("") } + samples.forEach { digits -> + val preview = pipeline.resolveNineKey(digits, "", null, fuzzy = false).preview + assertLettersFor(digits, preview) + } + } + + @Test + fun anUnfinishedLastSyllableShowsItsStart() { + // 46 starts gong / gou but is no syllable: its letters, not "46". + assertEquals(listOf("go"), decoder.spellDigits("46")) + // Whole syllables where they fit. + assertEquals(listOf("ni", "hao"), decoder.spellDigits("64426")) + } + + @Test + fun aWordSpellingOnlyTheStartAlignsItsPartAndSpellsTheRest() { + // 你好工 for 6442646: the last character typed only up to "go". + assertEquals(listOf("ni", "hao", "go"), decoder.readingForPrefix("6442646", "你好工")) + // 你好 covers 64426; the remaining 46 are spelled, not left as digits. + val rest = decoder.readingForPrefix("6442646", "你好")!! + assertEquals(listOf("ni", "hao"), rest.take(2)) + assertLettersFor("6442646", rest.joinToString("")) + // A word that does not fit the digits at all is no guide. + assertEquals(null, decoder.readingForPrefix("6442646", "我们")) + } + + @Test + fun aWordSpellingEveryDigitStillUsesItsReading() { + assertEquals(listOf("ni", "hao"), decoder.readingForPrefix("64426", "你好")) + assertEquals(listOf("zhong", "guo"), decoder.readingForPrefix("94664486", "中国")) + } +} diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee8..db3a6ac2 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,94 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH= - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From 23f96e4e76f43b3e611f8f2faa0ab69e6606e5c1 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:28:46 +0800 Subject: [PATCH 03/12] Typing stress: no digits in the nine-key pre-edit, every rail reading responds Co-Authored-By: Claude Opus 5.5 --- scripts/typing_stress_e2e.py | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py index 4efbddd9..91dba9bf 100644 --- a/scripts/typing_stress_e2e.py +++ b/scripts/typing_stress_e2e.py @@ -117,6 +117,18 @@ def field_bounds(self) -> tuple[int, int, int, int] | None: match = re.search(rf'resource-id="[^"]*/{FIELD}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) return tuple(map(int, match.groups())) if match else None + def preedit(self) -> str | None: + """The keyboard's own pre-edit line (the IME window's text field), or None.""" + self.shell("uiautomator", "dump", "--windows", "/sdcard/stress-ime.xml") + xml = self.shell("cat", "/sdcard/stress-ime.xml") + values = re.findall( + rf']*text="([^"]*)"[^>]*resource-id="[^"]*"[^>]*class="android.widget.EditText"[^>]*package="{re.escape(PKG)}"', + xml, + ) + hint = re.search(r'resource-id="[^"]*/' + FIELD, xml) + # The IME window comes first in the dump; the lab fields follow it. + return values[0] if values and not (hint and xml.find(values[0]) > hint.start()) else None + def shot(self, name: str) -> None: (self.out / f"{name}.png").write_bytes(self.run("exec-out", "screencap", "-p", binary=True)) @@ -162,13 +174,65 @@ def type_pinyin26(dev: Device, words: list[str]) -> None: time.sleep(0.15) -def type_nine_key(dev: Device, words: list[str]) -> None: +def type_nine_key(dev: Device, words: list[str], check_preedit: bool = False) -> list[str]: + """Type each word's digits and commit; with [check_preedit], report any pre-edit holding digits.""" + problems = [] for word in words: for letter in word.replace("v", "u"): dev.tap_tag(f"key-9:{T9[letter]}") time.sleep(0.4) + if check_preedit: + shown = dev.preedit() + if shown is None or any(ch.isdigit() for ch in shown): + problems.append(f"{word}: pre-edit {shown!r}") dev.tap_tag("key-space") time.sleep(0.15) + return problems + + +RAIL_TAGS = ("nine-pinyin-path-filter", "nine-pinyin-path-selected") + + +def check_nine_key_rail(dev: Device, sequences: list[str]) -> list[str]: + """Tap every reading the left rail offers: each must change the pre-edit or the candidates.""" + problems = [] + for digits in sequences: + index = 0 + while True: + dev.tap_tag("key-retype") + for digit in digits: + dev.tap_tag(f"key-9:{digit}") + time.sleep(0.9) + dev.refresh_bounds() + rail = [b for b in dev.bounds if b[0] in RAIL_TAGS] + if index >= len(rail): + break + name = rail[index][1] + symbols_y = next(b[3] for b in dev.bounds if b[0] == "key-symbols") + for _ in range(4): # scroll the rail until the reading is above the 符号 key + hit = next((b for b in dev.bounds if b[0] in RAIL_TAGS and b[1] == name), None) + if hit is None or hit[3] < symbols_y - 90: + break + dev.shell("input", "swipe", str(int(hit[2])), str(int(symbols_y - 120)), + str(int(hit[2])), str(int(symbols_y - 420)), "250") + time.sleep(0.5) + dev.refresh_bounds() + if hit is None or hit[3] >= symbols_y - 90: + problems.append(f"{digits}: {name} unreachable") + index += 1 + continue + before = (dev.preedit(), [b[1] for b in dev.bounds if b[0] == "candidate-first-row"]) + dev.shell("input", "tap", str(int(hit[2])), str(int(hit[3]))) + time.sleep(0.9) + dev.refresh_bounds() + after = (dev.preedit(), [b[1] for b in dev.bounds if b[0] == "candidate-first-row"]) + if after[0] is None or any(ch.isdigit() for ch in after[0]): + problems.append(f"{digits}: after {name} the pre-edit is {after[0]!r}") + elif after == before and "已选择" not in name: + problems.append(f"{digits}: tapping {name} changed nothing ({before})") + index += 1 + dev.tap_tag("key-retype") + return problems def stroke_codes(count: int) -> list[tuple[str, str]]: @@ -240,8 +304,13 @@ def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: elif name == "pinyin9": set_mode(dev, "PINYIN_9") probe = probe_preedit_stays_on_keyboard(dev, [f"key-9:{d}" for d in "669"]) - type_nine_key(dev, PINYIN_WORDS) + digit_problems = type_nine_key(dev, PINYIN_WORDS, check_preedit=True) + probe = probe or ("; ".join(digit_problems) if digit_problems else None) chinese = True + elif name == "nine-rail": + set_mode(dev, "PINYIN_9") + problems = check_nine_key_rail(dev, ["2", "4", "6", "9", "64", "74", "646", "6464", "94664", "6442646", "644264658846649669"]) + return (not problems), ("; ".join(problems) if problems else "every rail reading responds, pre-edit always letters") elif name == "stroke": set_mode(dev, "STROKE") probe = probe_preedit_stays_on_keyboard(dev, [f"key-stroke:{c}" for c in "phz"]) @@ -297,7 +366,7 @@ def main() -> int: parser.add_argument("--out", type=Path, default=ROOT / "build" / "typing-stress") parser.add_argument("--min", type=int, default=50) parser.add_argument("--package", default=PKG, help="applicationId of the debug build (e.g. with a .dev suffix)") - parser.add_argument("cases", nargs="*", default=["pinyin26", "pinyin9", "stroke", "english", "digits", "mixed"]) + parser.add_argument("cases", nargs="*", default=["pinyin26", "pinyin9", "nine-rail", "stroke", "english", "digits", "mixed"]) args = parser.parse_args() PKG = args.package IME = f"{PKG}/{CLASSES}.LocalVoiceImeService" From 8f578a3ede2e17d2364a43bde596e9b17c750afa Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:40:07 +0800 Subject: [PATCH 04/12] =?UTF-8?q?Rail=20reads=20as=20a=20list=20with=20a?= =?UTF-8?q?=20circled=20=EF=BC=8B;=20nine-key=200=20key;=20wide=20kaomoji;?= =?UTF-8?q?=20full=20emoji;=20more=20symbols?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Symbol rails are one panel with hairlines and flat items, cells 3.5 to the rail so the next one peeks out, edges fade; the editor cell is a circled plus icon. - Nine-key action column: 删除 · 重输 · 0 · 确定. - Symbol categories with long items (kaomoji) use three wide cells and a smaller size; five narrow cells cut them off. - Emoji panel: curated order plus the rest of Unicode 18.0's base emoji (scripts/generate_emoji_catalog.py -> assets/emoji/unicode_emoji.tsv), filtered by what the font can draw, loaded in the background. - SymbolCatalog: more Chinese marks, special signs and ~100 kaomoji, and new 拼音 / 日文 / 注音 / 制表 categories. Co-Authored-By: Claude Opus 5.5 --- app/src/main/assets/emoji/unicode_emoji.tsv | 1913 +++++++++++++++++ .../slacker/openime/LocalVoiceImeService.kt | 3 + .../llc/slacker/openime/core/EmojiCatalog.kt | 58 + .../llc/slacker/openime/core/SymbolCatalog.kt | 90 + .../openime/keyboard/ImeThemeApplier.kt | 23 +- .../keyboard/NineKeySymbolRailController.kt | 7 +- .../keyboard/NumericKeyboardRenderer.kt | 4 +- .../keyboard/Pinyin9KeyboardRenderer.kt | 16 +- .../openime/keyboard/SymbolRailRenderer.kt | 57 +- .../slacker/openime/panel/ImePanelRenderer.kt | 43 +- app/src/main/res/drawable/ic_rail_add.xml | 1 + .../openime/EmojiAndSymbolCatalogTest.kt | 62 + .../slacker/openime/NoDecorativeGlyphTest.kt | 3 +- scripts/generate_emoji_catalog.py | 66 + 14 files changed, 2304 insertions(+), 42 deletions(-) create mode 100644 app/src/main/assets/emoji/unicode_emoji.tsv create mode 100644 app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt create mode 100644 app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt create mode 100644 app/src/main/res/drawable/ic_rail_add.xml create mode 100644 app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt create mode 100644 scripts/generate_emoji_catalog.py diff --git a/app/src/main/assets/emoji/unicode_emoji.tsv b/app/src/main/assets/emoji/unicode_emoji.tsv new file mode 100644 index 00000000..030e7b12 --- /dev/null +++ b/app/src/main/assets/emoji/unicode_emoji.tsv @@ -0,0 +1,1913 @@ +# Unicode emoji 18.0, fully-qualified, no skin-tone or hair variants. +# Generated by scripts/generate_emoji_catalog.py from emoji-test.txt; do not edit. +笑脸 😀 +笑脸 😃 +笑脸 😄 +笑脸 😁 +笑脸 😆 +笑脸 😅 +笑脸 🤣 +笑脸 😂 +笑脸 🙂 +笑脸 🙃 +笑脸 🫠 +笑脸 🫫 +笑脸 😉 +笑脸 😊 +笑脸 😇 +笑脸 🥰 +笑脸 😍 +笑脸 🤩 +笑脸 😘 +笑脸 😗 +笑脸 ☺️ +笑脸 😚 +笑脸 😙 +笑脸 🥲 +笑脸 😋 +笑脸 😛 +笑脸 😜 +笑脸 🤪 +笑脸 😝 +笑脸 🤑 +笑脸 🤗 +笑脸 🤭 +笑脸 🫢 +笑脸 🫣 +笑脸 🤫 +笑脸 🤔 +笑脸 🫡 +笑脸 🤐 +笑脸 🤨 +笑脸 😐 +笑脸 😑 +笑脸 😶 +笑脸 🫥 +笑脸 😶‍🌫️ +笑脸 😏 +笑脸 😒 +笑脸 🙄 +笑脸 😬 +笑脸 😮‍💨 +笑脸 🤥 +笑脸 🫨 +笑脸 🙂‍↔️ +笑脸 🙂‍↕️ +笑脸 😌 +笑脸 😔 +笑脸 😪 +笑脸 🤤 +笑脸 😴 +笑脸 🫩 +笑脸 😷 +笑脸 🤒 +笑脸 🤕 +笑脸 🤢 +笑脸 🤮 +笑脸 🤧 +笑脸 🥵 +笑脸 🥶 +笑脸 🥴 +笑脸 😵 +笑脸 😵‍💫 +笑脸 🤯 +笑脸 🤠 +笑脸 🥳 +笑脸 🥸 +笑脸 😎 +笑脸 🤓 +笑脸 🧐 +笑脸 😕 +笑脸 🫤 +笑脸 😟 +笑脸 🙁 +笑脸 ☹️ +笑脸 😮 +笑脸 😯 +笑脸 😲 +笑脸 😳 +笑脸 🫪 +笑脸 🥺 +笑脸 🥹 +笑脸 😦 +笑脸 😧 +笑脸 😨 +笑脸 😰 +笑脸 😥 +笑脸 😢 +笑脸 😭 +笑脸 😱 +笑脸 😖 +笑脸 😣 +笑脸 😞 +笑脸 😓 +笑脸 😩 +笑脸 😫 +笑脸 🥱 +笑脸 😤 +笑脸 😡 +笑脸 😠 +笑脸 🤬 +笑脸 😈 +笑脸 👿 +笑脸 💀 +笑脸 ☠️ +笑脸 💩 +笑脸 🤡 +笑脸 👹 +笑脸 👺 +笑脸 👻 +笑脸 👽 +笑脸 👾 +笑脸 🤖 +笑脸 😺 +笑脸 😸 +笑脸 😹 +笑脸 😻 +笑脸 😼 +笑脸 😽 +笑脸 🙀 +笑脸 😿 +笑脸 😾 +笑脸 🙈 +笑脸 🙉 +笑脸 🙊 +笑脸 💌 +笑脸 💘 +笑脸 💝 +笑脸 💖 +笑脸 💗 +笑脸 💓 +笑脸 💞 +笑脸 💕 +笑脸 💟 +笑脸 ❣️ +笑脸 💔 +笑脸 ❤️‍🔥 +笑脸 ❤️‍🩹 +笑脸 ❤️ +笑脸 🩷 +笑脸 🧡 +笑脸 💛 +笑脸 💚 +笑脸 💙 +笑脸 🩵 +笑脸 💜 +笑脸 🤎 +笑脸 🖤 +笑脸 🩶 +笑脸 🤍 +笑脸 💋 +笑脸 💯 +笑脸 💢 +笑脸 🫯 +笑脸 💥 +笑脸 💫 +笑脸 💦 +笑脸 💨 +笑脸 🕳️ +笑脸 💬 +笑脸 👁️‍🗨️ +笑脸 🗨️ +笑脸 🗯️ +笑脸 💭 +笑脸 💤 +人物/手势 👋 +人物/手势 🤚 +人物/手势 🖐️ +人物/手势 ✋ +人物/手势 🖖 +人物/手势 🫱 +人物/手势 🫲 +人物/手势 🫳 +人物/手势 🫴 +人物/手势 🫷 +人物/手势 🫸 +人物/手势 👌 +人物/手势 🤌 +人物/手势 🤏 +人物/手势 ✌️ +人物/手势 🤞 +人物/手势 🫰 +人物/手势 🤟 +人物/手势 🤘 +人物/手势 🤙 +人物/手势 👈 +人物/手势 👉 +人物/手势 👆 +人物/手势 🖕 +人物/手势 👇 +人物/手势 ☝️ +人物/手势 🫵 +人物/手势 👍 +人物/手势 👎 +人物/手势 🫹 +人物/手势 🫺 +人物/手势 ✊ +人物/手势 👊 +人物/手势 🤛 +人物/手势 🤜 +人物/手势 👏 +人物/手势 🙌 +人物/手势 🫶 +人物/手势 👐 +人物/手势 🤲 +人物/手势 🤝 +人物/手势 🙏 +人物/手势 ✍️ +人物/手势 💅 +人物/手势 🤳 +人物/手势 💪 +人物/手势 🦾 +人物/手势 🦿 +人物/手势 🦵 +人物/手势 🦶 +人物/手势 👂 +人物/手势 🦻 +人物/手势 👃 +人物/手势 🧠 +人物/手势 🫀 +人物/手势 🫁 +人物/手势 🦷 +人物/手势 🦴 +人物/手势 👀 +人物/手势 👁️ +人物/手势 👅 +人物/手势 👄 +人物/手势 🫦 +人物/手势 👶 +人物/手势 🧒 +人物/手势 👦 +人物/手势 👧 +人物/手势 🧑 +人物/手势 👱 +人物/手势 👨 +人物/手势 🧔 +人物/手势 🧔‍♂️ +人物/手势 🧔‍♀️ +人物/手势 👩 +人物/手势 👱‍♀️ +人物/手势 👱‍♂️ +人物/手势 🧓 +人物/手势 👴 +人物/手势 👵 +人物/手势 🙍 +人物/手势 🙍‍♂️ +人物/手势 🙍‍♀️ +人物/手势 🙎 +人物/手势 🙎‍♂️ +人物/手势 🙎‍♀️ +人物/手势 🙅 +人物/手势 🙅‍♂️ +人物/手势 🙅‍♀️ +人物/手势 🙆 +人物/手势 🙆‍♂️ +人物/手势 🙆‍♀️ +人物/手势 💁 +人物/手势 💁‍♂️ +人物/手势 💁‍♀️ +人物/手势 🙋 +人物/手势 🙋‍♂️ +人物/手势 🙋‍♀️ +人物/手势 🧏 +人物/手势 🧏‍♂️ +人物/手势 🧏‍♀️ +人物/手势 🙇 +人物/手势 🙇‍♂️ +人物/手势 🙇‍♀️ +人物/手势 🤦 +人物/手势 🤦‍♂️ +人物/手势 🤦‍♀️ +人物/手势 🤷 +人物/手势 🤷‍♂️ +人物/手势 🤷‍♀️ +人物/手势 🧑‍⚕️ +人物/手势 👨‍⚕️ +人物/手势 👩‍⚕️ +人物/手势 🧑‍🎓 +人物/手势 👨‍🎓 +人物/手势 👩‍🎓 +人物/手势 🧑‍🏫 +人物/手势 👨‍🏫 +人物/手势 👩‍🏫 +人物/手势 🧑‍⚖️ +人物/手势 👨‍⚖️ +人物/手势 👩‍⚖️ +人物/手势 🧑‍🌾 +人物/手势 👨‍🌾 +人物/手势 👩‍🌾 +人物/手势 🧑‍🍳 +人物/手势 👨‍🍳 +人物/手势 👩‍🍳 +人物/手势 🧑‍🔧 +人物/手势 👨‍🔧 +人物/手势 👩‍🔧 +人物/手势 🧑‍🏭 +人物/手势 👨‍🏭 +人物/手势 👩‍🏭 +人物/手势 🧑‍💼 +人物/手势 👨‍💼 +人物/手势 👩‍💼 +人物/手势 🧑‍🔬 +人物/手势 👨‍🔬 +人物/手势 👩‍🔬 +人物/手势 🧑‍💻 +人物/手势 👨‍💻 +人物/手势 👩‍💻 +人物/手势 🧑‍🎤 +人物/手势 👨‍🎤 +人物/手势 👩‍🎤 +人物/手势 🧑‍🎨 +人物/手势 👨‍🎨 +人物/手势 👩‍🎨 +人物/手势 🧑‍✈️ +人物/手势 👨‍✈️ +人物/手势 👩‍✈️ +人物/手势 🧑‍🚀 +人物/手势 👨‍🚀 +人物/手势 👩‍🚀 +人物/手势 🧑‍🚒 +人物/手势 👨‍🚒 +人物/手势 👩‍🚒 +人物/手势 👮 +人物/手势 👮‍♂️ +人物/手势 👮‍♀️ +人物/手势 🕵️ +人物/手势 🕵️‍♂️ +人物/手势 🕵️‍♀️ +人物/手势 💂 +人物/手势 💂‍♂️ +人物/手势 💂‍♀️ +人物/手势 🥷 +人物/手势 👷 +人物/手势 👷‍♂️ +人物/手势 👷‍♀️ +人物/手势 🫅 +人物/手势 🤴 +人物/手势 👸 +人物/手势 👳 +人物/手势 👳‍♂️ +人物/手势 👳‍♀️ +人物/手势 👲 +人物/手势 🧕 +人物/手势 🤵 +人物/手势 🤵‍♂️ +人物/手势 🤵‍♀️ +人物/手势 👰 +人物/手势 👰‍♂️ +人物/手势 👰‍♀️ +人物/手势 🤰 +人物/手势 🫃 +人物/手势 🫄 +人物/手势 🤱 +人物/手势 👩‍🍼 +人物/手势 👨‍🍼 +人物/手势 🧑‍🍼 +人物/手势 👼 +人物/手势 🎅 +人物/手势 🤶 +人物/手势 🧑‍🎄 +人物/手势 🦸 +人物/手势 🦸‍♂️ +人物/手势 🦸‍♀️ +人物/手势 🦹 +人物/手势 🦹‍♂️ +人物/手势 🦹‍♀️ +人物/手势 🧙 +人物/手势 🧙‍♂️ +人物/手势 🧙‍♀️ +人物/手势 🧚 +人物/手势 🧚‍♂️ +人物/手势 🧚‍♀️ +人物/手势 🧛 +人物/手势 🧛‍♂️ +人物/手势 🧛‍♀️ +人物/手势 🧜 +人物/手势 🧜‍♂️ +人物/手势 🧜‍♀️ +人物/手势 🧝 +人物/手势 🧝‍♂️ +人物/手势 🧝‍♀️ +人物/手势 🧞 +人物/手势 🧞‍♂️ +人物/手势 🧞‍♀️ +人物/手势 🧟 +人物/手势 🧟‍♂️ +人物/手势 🧟‍♀️ +人物/手势 🧌 +人物/手势 🫈 +人物/手势 💆 +人物/手势 💆‍♂️ +人物/手势 💆‍♀️ +人物/手势 💇 +人物/手势 💇‍♂️ +人物/手势 💇‍♀️ +人物/手势 🚶 +人物/手势 🚶‍♂️ +人物/手势 🚶‍♀️ +人物/手势 🚶‍➡️ +人物/手势 🚶‍♀️‍➡️ +人物/手势 🚶‍♂️‍➡️ +人物/手势 🧍 +人物/手势 🧍‍♂️ +人物/手势 🧍‍♀️ +人物/手势 🧎 +人物/手势 🧎‍♂️ +人物/手势 🧎‍♀️ +人物/手势 🧎‍➡️ +人物/手势 🧎‍♀️‍➡️ +人物/手势 🧎‍♂️‍➡️ +人物/手势 🧑‍🦯 +人物/手势 🧑‍🦯‍➡️ +人物/手势 👨‍🦯 +人物/手势 👨‍🦯‍➡️ +人物/手势 👩‍🦯 +人物/手势 👩‍🦯‍➡️ +人物/手势 🧑‍🦼 +人物/手势 🧑‍🦼‍➡️ +人物/手势 👨‍🦼 +人物/手势 👨‍🦼‍➡️ +人物/手势 👩‍🦼 +人物/手势 👩‍🦼‍➡️ +人物/手势 🧑‍🦽 +人物/手势 🧑‍🦽‍➡️ +人物/手势 👨‍🦽 +人物/手势 👨‍🦽‍➡️ +人物/手势 👩‍🦽 +人物/手势 👩‍🦽‍➡️ +人物/手势 🏃 +人物/手势 🏃‍♂️ +人物/手势 🏃‍♀️ +人物/手势 🏃‍➡️ +人物/手势 🏃‍♀️‍➡️ +人物/手势 🏃‍♂️‍➡️ +人物/手势 🧑‍🩰 +人物/手势 💃 +人物/手势 🕺 +人物/手势 🕴️ +人物/手势 👯 +人物/手势 👯‍♂️ +人物/手势 👯‍♀️ +人物/手势 🧖 +人物/手势 🧖‍♂️ +人物/手势 🧖‍♀️ +人物/手势 🧗 +人物/手势 🧗‍♂️ +人物/手势 🧗‍♀️ +人物/手势 🤺 +人物/手势 🏇 +人物/手势 ⛷️ +人物/手势 🏂 +人物/手势 🏌️ +人物/手势 🏌️‍♂️ +人物/手势 🏌️‍♀️ +人物/手势 🏄 +人物/手势 🏄‍♂️ +人物/手势 🏄‍♀️ +人物/手势 🚣 +人物/手势 🚣‍♂️ +人物/手势 🚣‍♀️ +人物/手势 🏊 +人物/手势 🏊‍♂️ +人物/手势 🏊‍♀️ +人物/手势 ⛹️ +人物/手势 ⛹️‍♂️ +人物/手势 ⛹️‍♀️ +人物/手势 🏋️ +人物/手势 🏋️‍♂️ +人物/手势 🏋️‍♀️ +人物/手势 🚴 +人物/手势 🚴‍♂️ +人物/手势 🚴‍♀️ +人物/手势 🚵 +人物/手势 🚵‍♂️ +人物/手势 🚵‍♀️ +人物/手势 🤸 +人物/手势 🤸‍♂️ +人物/手势 🤸‍♀️ +人物/手势 🤼 +人物/手势 🤼‍♂️ +人物/手势 🤼‍♀️ +人物/手势 🤽 +人物/手势 🤽‍♂️ +人物/手势 🤽‍♀️ +人物/手势 🤾 +人物/手势 🤾‍♂️ +人物/手势 🤾‍♀️ +人物/手势 🤹 +人物/手势 🤹‍♂️ +人物/手势 🤹‍♀️ +人物/手势 🧘 +人物/手势 🧘‍♂️ +人物/手势 🧘‍♀️ +人物/手势 🛀 +人物/手势 🛌 +人物/手势 🧑‍🤝‍🧑 +人物/手势 👭 +人物/手势 👫 +人物/手势 👬 +人物/手势 💏 +人物/手势 👩‍❤️‍💋‍👨 +人物/手势 👨‍❤️‍💋‍👨 +人物/手势 👩‍❤️‍💋‍👩 +人物/手势 💑 +人物/手势 👩‍❤️‍👨 +人物/手势 👨‍❤️‍👨 +人物/手势 👩‍❤️‍👩 +人物/手势 👨‍👩‍👦 +人物/手势 👨‍👩‍👧 +人物/手势 👨‍👩‍👧‍👦 +人物/手势 👨‍👩‍👦‍👦 +人物/手势 👨‍👩‍👧‍👧 +人物/手势 👨‍👨‍👦 +人物/手势 👨‍👨‍👧 +人物/手势 👨‍👨‍👧‍👦 +人物/手势 👨‍👨‍👦‍👦 +人物/手势 👨‍👨‍👧‍👧 +人物/手势 👩‍👩‍👦 +人物/手势 👩‍👩‍👧 +人物/手势 👩‍👩‍👧‍👦 +人物/手势 👩‍👩‍👦‍👦 +人物/手势 👩‍👩‍👧‍👧 +人物/手势 👨‍👦 +人物/手势 👨‍👦‍👦 +人物/手势 👨‍👧 +人物/手势 👨‍👧‍👦 +人物/手势 👨‍👧‍👧 +人物/手势 👩‍👦 +人物/手势 👩‍👦‍👦 +人物/手势 👩‍👧 +人物/手势 👩‍👧‍👦 +人物/手势 👩‍👧‍👧 +人物/手势 🗣️ +人物/手势 👤 +人物/手势 👥 +人物/手势 🫂 +人物/手势 👪 +人物/手势 🧑‍🧑‍🧒 +人物/手势 🧑‍🧑‍🧒‍🧒 +人物/手势 🧑‍🧒 +人物/手势 🧑‍🧒‍🧒 +人物/手势 👣 +人物/手势 🫆 +动物/自然 🐵 +动物/自然 🐒 +动物/自然 🦍 +动物/自然 🦧 +动物/自然 🐶 +动物/自然 🐕 +动物/自然 🦮 +动物/自然 🐕‍🦺 +动物/自然 🐩 +动物/自然 🐺 +动物/自然 🦊 +动物/自然 🦝 +动物/自然 🐱 +动物/自然 🐈 +动物/自然 🐈‍⬛ +动物/自然 🦁 +动物/自然 🐯 +动物/自然 🐅 +动物/自然 🐆 +动物/自然 🐴 +动物/自然 🫎 +动物/自然 🫏 +动物/自然 🐎 +动物/自然 🦄 +动物/自然 🦓 +动物/自然 🦌 +动物/自然 🦬 +动物/自然 🐮 +动物/自然 🐂 +动物/自然 🐃 +动物/自然 🐄 +动物/自然 🐷 +动物/自然 🐖 +动物/自然 🐗 +动物/自然 🐽 +动物/自然 🐏 +动物/自然 🐑 +动物/自然 🐐 +动物/自然 🐪 +动物/自然 🐫 +动物/自然 🦙 +动物/自然 🦒 +动物/自然 🐘 +动物/自然 🦣 +动物/自然 🦏 +动物/自然 🦛 +动物/自然 🐭 +动物/自然 🐁 +动物/自然 🐀 +动物/自然 🐹 +动物/自然 🐰 +动物/自然 🐇 +动物/自然 🐿️ +动物/自然 🦫 +动物/自然 🦔 +动物/自然 🦇 +动物/自然 🐻 +动物/自然 🐻‍❄️ +动物/自然 🐨 +动物/自然 🐼 +动物/自然 🦥 +动物/自然 🦦 +动物/自然 🦨 +动物/自然 🦘 +动物/自然 🦡 +动物/自然 🐾 +动物/自然 🦃 +动物/自然 🐔 +动物/自然 🐓 +动物/自然 🐣 +动物/自然 🐤 +动物/自然 🐥 +动物/自然 🐦 +动物/自然 🐧 +动物/自然 🕊️ +动物/自然 🦅 +动物/自然 🦆 +动物/自然 🦢 +动物/自然 🦉 +动物/自然 🦤 +动物/自然 🪶 +动物/自然 🦩 +动物/自然 🦚 +动物/自然 🦜 +动物/自然 🪽 +动物/自然 🐦‍⬛ +动物/自然 🪿 +动物/自然 🐦‍🔥 +动物/自然 🐸 +动物/自然 🐊 +动物/自然 🐢 +动物/自然 🦎 +动物/自然 🐍 +动物/自然 🐲 +动物/自然 🐉 +动物/自然 🦕 +动物/自然 🦖 +动物/自然 🐳 +动物/自然 🐋 +动物/自然 🐬 +动物/自然 🫍 +动物/自然 🦭 +动物/自然 🐟 +动物/自然 🐠 +动物/自然 🐡 +动物/自然 🦈 +动物/自然 🐙 +动物/自然 🐚 +动物/自然 🪸 +动物/自然 🪼 +动物/自然 🦀 +动物/自然 🦞 +动物/自然 🦐 +动物/自然 🦑 +动物/自然 🦪 +动物/自然 🐌 +动物/自然 🦋 +动物/自然 🫌 +动物/自然 🐛 +动物/自然 🐜 +动物/自然 🐝 +动物/自然 🪲 +动物/自然 🐞 +动物/自然 🦗 +动物/自然 🪳 +动物/自然 🕷️ +动物/自然 🕸️ +动物/自然 🦂 +动物/自然 🦟 +动物/自然 🪰 +动物/自然 🪱 +动物/自然 🦠 +动物/自然 💐 +动物/自然 🌸 +动物/自然 💮 +动物/自然 🪷 +动物/自然 🏵️ +动物/自然 🌹 +动物/自然 🥀 +动物/自然 🌺 +动物/自然 🌻 +动物/自然 🌼 +动物/自然 🌷 +动物/自然 🪻 +动物/自然 🌱 +动物/自然 🪴 +动物/自然 🌲 +动物/自然 🌳 +动物/自然 🌴 +动物/自然 🌵 +动物/自然 🌾 +动物/自然 🌿 +动物/自然 ☘️ +动物/自然 🍀 +动物/自然 🍁 +动物/自然 🍂 +动物/自然 🍃 +动物/自然 🪹 +动物/自然 🪺 +动物/自然 🍄 +动物/自然 🪾 +食物/饮品 🍇 +食物/饮品 🍈 +食物/饮品 🍉 +食物/饮品 🍊 +食物/饮品 🍋 +食物/饮品 🍋‍🟩 +食物/饮品 🍌 +食物/饮品 🍍 +食物/饮品 🥭 +食物/饮品 🍎 +食物/饮品 🍏 +食物/饮品 🍐 +食物/饮品 🍑 +食物/饮品 🍒 +食物/饮品 🍓 +食物/饮品 🫐 +食物/饮品 🥝 +食物/饮品 🍅 +食物/饮品 🫒 +食物/饮品 🥥 +食物/饮品 🥑 +食物/饮品 🍆 +食物/饮品 🥔 +食物/饮品 🥕 +食物/饮品 🌽 +食物/饮品 🌶️ +食物/饮品 🫑 +食物/饮品 🥒 +食物/饮品 🫝 +食物/饮品 🥬 +食物/饮品 🥦 +食物/饮品 🧄 +食物/饮品 🧅 +食物/饮品 🥜 +食物/饮品 🫘 +食物/饮品 🌰 +食物/饮品 🫚 +食物/饮品 🫛 +食物/饮品 🍄‍🟫 +食物/饮品 🫜 +食物/饮品 🍞 +食物/饮品 🥐 +食物/饮品 🥖 +食物/饮品 🫓 +食物/饮品 🥨 +食物/饮品 🥯 +食物/饮品 🥞 +食物/饮品 🧇 +食物/饮品 🧀 +食物/饮品 🍖 +食物/饮品 🍗 +食物/饮品 🥩 +食物/饮品 🥓 +食物/饮品 🍔 +食物/饮品 🍟 +食物/饮品 🍕 +食物/饮品 🌭 +食物/饮品 🥪 +食物/饮品 🌮 +食物/饮品 🌯 +食物/饮品 🫔 +食物/饮品 🥙 +食物/饮品 🧆 +食物/饮品 🥚 +食物/饮品 🍳 +食物/饮品 🥘 +食物/饮品 🍲 +食物/饮品 🫕 +食物/饮品 🥣 +食物/饮品 🥗 +食物/饮品 🍿 +食物/饮品 🧈 +食物/饮品 🧂 +食物/饮品 🥫 +食物/饮品 🍱 +食物/饮品 🍘 +食物/饮品 🍙 +食物/饮品 🍚 +食物/饮品 🍛 +食物/饮品 🍜 +食物/饮品 🍝 +食物/饮品 🍠 +食物/饮品 🍢 +食物/饮品 🍣 +食物/饮品 🍤 +食物/饮品 🍥 +食物/饮品 🥮 +食物/饮品 🍡 +食物/饮品 🥟 +食物/饮品 🥠 +食物/饮品 🥡 +食物/饮品 🍦 +食物/饮品 🍧 +食物/饮品 🍨 +食物/饮品 🍩 +食物/饮品 🍪 +食物/饮品 🎂 +食物/饮品 🍰 +食物/饮品 🧁 +食物/饮品 🥧 +食物/饮品 🍫 +食物/饮品 🍬 +食物/饮品 🍭 +食物/饮品 🍮 +食物/饮品 🍯 +食物/饮品 🍼 +食物/饮品 🥛 +食物/饮品 ☕ +食物/饮品 🫖 +食物/饮品 🍵 +食物/饮品 🍶 +食物/饮品 🍾 +食物/饮品 🍷 +食物/饮品 🍸 +食物/饮品 🍹 +食物/饮品 🍺 +食物/饮品 🍻 +食物/饮品 🥂 +食物/饮品 🥃 +食物/饮品 🫗 +食物/饮品 🥤 +食物/饮品 🧋 +食物/饮品 🧃 +食物/饮品 🧉 +食物/饮品 🧊 +食物/饮品 🥢 +食物/饮品 🍽️ +食物/饮品 🍴 +食物/饮品 🥄 +食物/饮品 🔪 +食物/饮品 🫙 +食物/饮品 🏺 +旅行/地点 🌍 +旅行/地点 🌎 +旅行/地点 🌏 +旅行/地点 🌐 +旅行/地点 🗺️ +旅行/地点 🗾 +旅行/地点 🧭 +旅行/地点 🏔️ +旅行/地点 ⛰️ +旅行/地点 🛘 +旅行/地点 🌋 +旅行/地点 🗻 +旅行/地点 🏕️ +旅行/地点 🏖️ +旅行/地点 🏜️ +旅行/地点 🏝️ +旅行/地点 🏞️ +旅行/地点 🏟️ +旅行/地点 🏛️ +旅行/地点 🏗️ +旅行/地点 🧱 +旅行/地点 🪨 +旅行/地点 🪵 +旅行/地点 🛖 +旅行/地点 🏘️ +旅行/地点 🏚️ +旅行/地点 🏠 +旅行/地点 🏡 +旅行/地点 🏢 +旅行/地点 🏣 +旅行/地点 🏤 +旅行/地点 🏥 +旅行/地点 🏦 +旅行/地点 🏨 +旅行/地点 🏩 +旅行/地点 🏪 +旅行/地点 🏫 +旅行/地点 🏬 +旅行/地点 🏭 +旅行/地点 🏯 +旅行/地点 🏰 +旅行/地点 💒 +旅行/地点 🗼 +旅行/地点 🗽 +旅行/地点 ⛪ +旅行/地点 🕌 +旅行/地点 🛕 +旅行/地点 🕍 +旅行/地点 ⛩️ +旅行/地点 🕋 +旅行/地点 ⛲ +旅行/地点 ⛺ +旅行/地点 🌁 +旅行/地点 🌃 +旅行/地点 🏙️ +旅行/地点 🌄 +旅行/地点 🌅 +旅行/地点 🌆 +旅行/地点 🌇 +旅行/地点 🌉 +旅行/地点 ♨️ +旅行/地点 🎠 +旅行/地点 🛝 +旅行/地点 🎡 +旅行/地点 🎢 +旅行/地点 💈 +旅行/地点 🎪 +旅行/地点 🚂 +旅行/地点 🚃 +旅行/地点 🚄 +旅行/地点 🚅 +旅行/地点 🚆 +旅行/地点 🚇 +旅行/地点 🚈 +旅行/地点 🚉 +旅行/地点 🚊 +旅行/地点 🚝 +旅行/地点 🚞 +旅行/地点 🚋 +旅行/地点 🚌 +旅行/地点 🚍 +旅行/地点 🚎 +旅行/地点 🚐 +旅行/地点 🚑 +旅行/地点 🚒 +旅行/地点 🚓 +旅行/地点 🚔 +旅行/地点 🚕 +旅行/地点 🚖 +旅行/地点 🚗 +旅行/地点 🚘 +旅行/地点 🚙 +旅行/地点 🛻 +旅行/地点 🚚 +旅行/地点 🚛 +旅行/地点 🚜 +旅行/地点 🏎️ +旅行/地点 🏍️ +旅行/地点 🛵 +旅行/地点 🦽 +旅行/地点 🦼 +旅行/地点 🛺 +旅行/地点 🚲 +旅行/地点 🛴 +旅行/地点 🛹 +旅行/地点 🛼 +旅行/地点 🚏 +旅行/地点 🛣️ +旅行/地点 🛤️ +旅行/地点 🛢️ +旅行/地点 ⛽ +旅行/地点 🛞 +旅行/地点 🚨 +旅行/地点 🚥 +旅行/地点 🚦 +旅行/地点 🛑 +旅行/地点 🚧 +旅行/地点 🛙 +旅行/地点 ⚓ +旅行/地点 🛟 +旅行/地点 ⛵ +旅行/地点 🛶 +旅行/地点 🚤 +旅行/地点 🛳️ +旅行/地点 ⛴️ +旅行/地点 🛥️ +旅行/地点 🚢 +旅行/地点 ✈️ +旅行/地点 🛩️ +旅行/地点 🛫 +旅行/地点 🛬 +旅行/地点 🪂 +旅行/地点 💺 +旅行/地点 🚁 +旅行/地点 🚟 +旅行/地点 🚠 +旅行/地点 🚡 +旅行/地点 🛰️ +旅行/地点 🚀 +旅行/地点 🛸 +旅行/地点 🛎️ +旅行/地点 🧳 +旅行/地点 ⌛ +旅行/地点 ⏳ +旅行/地点 ⌚ +旅行/地点 ⏰ +旅行/地点 ⏱️ +旅行/地点 ⏲️ +旅行/地点 🕰️ +旅行/地点 🕛 +旅行/地点 🕧 +旅行/地点 🕐 +旅行/地点 🕜 +旅行/地点 🕑 +旅行/地点 🕝 +旅行/地点 🕒 +旅行/地点 🕞 +旅行/地点 🕓 +旅行/地点 🕟 +旅行/地点 🕔 +旅行/地点 🕠 +旅行/地点 🕕 +旅行/地点 🕡 +旅行/地点 🕖 +旅行/地点 🕢 +旅行/地点 🕗 +旅行/地点 🕣 +旅行/地点 🕘 +旅行/地点 🕤 +旅行/地点 🕙 +旅行/地点 🕥 +旅行/地点 🕚 +旅行/地点 🕦 +旅行/地点 🌑 +旅行/地点 🌒 +旅行/地点 🌓 +旅行/地点 🌔 +旅行/地点 🌕 +旅行/地点 🌖 +旅行/地点 🌗 +旅行/地点 🌘 +旅行/地点 🌙 +旅行/地点 🌚 +旅行/地点 🌛 +旅行/地点 🌜 +旅行/地点 🌡️ +旅行/地点 ☀️ +旅行/地点 🌝 +旅行/地点 🌞 +旅行/地点 🪐 +旅行/地点 ⭐ +旅行/地点 🌟 +旅行/地点 🌠 +旅行/地点 🌌 +旅行/地点 ☁️ +旅行/地点 ⛅ +旅行/地点 ⛈️ +旅行/地点 🌤️ +旅行/地点 🌥️ +旅行/地点 🌦️ +旅行/地点 🌧️ +旅行/地点 🌨️ +旅行/地点 🌩️ +旅行/地点 🌪️ +旅行/地点 🌫️ +旅行/地点 🌬️ +旅行/地点 🌀 +旅行/地点 🌈 +旅行/地点 🌂 +旅行/地点 ☂️ +旅行/地点 ☔ +旅行/地点 ⛱️ +旅行/地点 ⚡ +旅行/地点 ❄️ +旅行/地点 ☃️ +旅行/地点 ⛄ +旅行/地点 ☄️ +旅行/地点 🪋 +旅行/地点 🔥 +旅行/地点 💧 +旅行/地点 🌊 +活动 🎃 +活动 🎄 +活动 🎆 +活动 🎇 +活动 🧨 +活动 ✨ +活动 🎈 +活动 🎉 +活动 🎊 +活动 🎋 +活动 🎍 +活动 🎎 +活动 🎏 +活动 🎐 +活动 🎑 +活动 🧧 +活动 🎀 +活动 🎁 +活动 🎗️ +活动 🎟️ +活动 🎫 +活动 🎖️ +活动 🏆 +活动 🏅 +活动 🥇 +活动 🥈 +活动 🥉 +活动 ⚽ +活动 ⚾ +活动 🥎 +活动 🏀 +活动 🏐 +活动 🏈 +活动 🏉 +活动 🎾 +活动 🥏 +活动 🎳 +活动 🏏 +活动 🏑 +活动 🏒 +活动 🥍 +活动 🏓 +活动 🏸 +活动 🥊 +活动 🥋 +活动 🥅 +活动 ⛳ +活动 ⛸️ +活动 🎣 +活动 🤿 +活动 🎽 +活动 🎿 +活动 🛷 +活动 🥌 +活动 🎯 +活动 🪀 +活动 🪁 +活动 🔫 +活动 🎱 +活动 🔮 +活动 🪄 +活动 🎮 +活动 🕹️ +活动 🎰 +活动 🎲 +活动 🧩 +活动 🧸 +活动 🪅 +活动 🪩 +活动 🪆 +活动 ♠️ +活动 ♥️ +活动 ♦️ +活动 ♣️ +活动 ♟️ +活动 🃏 +活动 🀄 +活动 🎴 +活动 🎭 +活动 🖼️ +活动 🎨 +活动 🧵 +活动 🪡 +活动 🧶 +活动 🪢 +物品 👓 +物品 🕶️ +物品 🥽 +物品 🥼 +物品 🦺 +物品 👔 +物品 👕 +物品 👖 +物品 🧣 +物品 🧤 +物品 🧥 +物品 🧦 +物品 👗 +物品 👘 +物品 🥻 +物品 🩱 +物品 🩲 +物品 🩳 +物品 👙 +物品 👚 +物品 🪭 +物品 👛 +物品 👜 +物品 👝 +物品 🛍️ +物品 🎒 +物品 🩴 +物品 👞 +物品 👟 +物品 🥾 +物品 🥿 +物品 👠 +物品 👡 +物品 🩰 +物品 👢 +物品 🪮 +物品 👑 +物品 👒 +物品 🎩 +物品 🎓 +物品 🧢 +物品 🪖 +物品 ⛑️ +物品 📿 +物品 💄 +物品 💍 +物品 💎 +物品 🔇 +物品 🔈 +物品 🔉 +物品 🔊 +物品 📢 +物品 📣 +物品 📯 +物品 🔔 +物品 🔕 +物品 🎼 +物品 🎵 +物品 🎶 +物品 🎙️ +物品 🎚️ +物品 🎛️ +物品 🎤 +物品 🎧 +物品 📻 +物品 🎷 +物品 🎺 +物品 🪊 +物品 🪗 +物品 🎸 +物品 🎹 +物品 🎻 +物品 🪕 +物品 🥁 +物品 🪘 +物品 🪇 +物品 🪈 +物品 🪉 +物品 📱 +物品 📲 +物品 ☎️ +物品 📞 +物品 📟 +物品 📠 +物品 🔋 +物品 🪫 +物品 🔌 +物品 💻 +物品 🖥️ +物品 🖨️ +物品 ⌨️ +物品 🖱️ +物品 🖲️ +物品 💽 +物品 💾 +物品 💿 +物品 📀 +物品 🧮 +物品 🎥 +物品 🎞️ +物品 📽️ +物品 🎬 +物品 📺 +物品 📷 +物品 📸 +物品 📹 +物品 📼 +物品 🔍 +物品 🔎 +物品 🕯️ +物品 💡 +物品 🔦 +物品 🏮 +物品 🪔 +物品 📔 +物品 📕 +物品 📖 +物品 📗 +物品 📘 +物品 📙 +物品 📚 +物品 📓 +物品 📒 +物品 📃 +物品 📜 +物品 📄 +物品 📰 +物品 🗞️ +物品 📑 +物品 🔖 +物品 🏷️ +物品 🪙 +物品 💰 +物品 🪎 +物品 💴 +物品 💵 +物品 💶 +物品 💷 +物品 💸 +物品 💳 +物品 🧾 +物品 💹 +物品 ✉️ +物品 📧 +物品 📨 +物品 📩 +物品 📤 +物品 📥 +物品 📦 +物品 📫 +物品 📪 +物品 📬 +物品 📭 +物品 📮 +物品 🗳️ +物品 ✏️ +物品 ✒️ +物品 🖋️ +物品 🖊️ +物品 🖌️ +物品 🖍️ +物品 📝 +物品 🪌 +物品 💼 +物品 📁 +物品 📂 +物品 🗂️ +物品 📅 +物品 📆 +物品 🗒️ +物品 🗓️ +物品 📇 +物品 📈 +物品 📉 +物品 📊 +物品 📋 +物品 📌 +物品 📍 +物品 📎 +物品 🖇️ +物品 📏 +物品 📐 +物品 ✂️ +物品 🗃️ +物品 🗄️ +物品 🗑️ +物品 🔒 +物品 🔓 +物品 🔏 +物品 🔐 +物品 🔑 +物品 🗝️ +物品 🪍 +物品 🔨 +物品 🪓 +物品 ⛏️ +物品 ⚒️ +物品 🛠️ +物品 🗡️ +物品 ⚔️ +物品 💣 +物品 🪃 +物品 🏹 +物品 🛡️ +物品 🪚 +物品 🔧 +物品 🪛 +物品 🔩 +物品 ⚙️ +物品 🗜️ +物品 ⚖️ +物品 🦯 +物品 🔗 +物品 ⛓️‍💥 +物品 ⛓️ +物品 🪝 +物品 🧰 +物品 🧲 +物品 🪜 +物品 🪏 +物品 ⚗️ +物品 🧪 +物品 🧫 +物品 🧬 +物品 🔬 +物品 🔭 +物品 📡 +物品 💉 +物品 🩸 +物品 💊 +物品 🩹 +物品 🩼 +物品 🩺 +物品 🩻 +物品 🚪 +物品 🛗 +物品 🪞 +物品 🪟 +物品 🛏️ +物品 🛋️ +物品 🪑 +物品 🚽 +物品 🪠 +物品 🚿 +物品 🛁 +物品 🪤 +物品 🪒 +物品 🧴 +物品 🧷 +物品 🧹 +物品 🧺 +物品 🧻 +物品 🪣 +物品 🧼 +物品 🫧 +物品 🪥 +物品 🧽 +物品 🧯 +物品 🛒 +物品 🚬 +物品 ⚰️ +物品 🪦 +物品 ⚱️ +物品 🧿 +物品 🪬 +物品 🗿 +物品 🪧 +物品 🪪 +符号 🏧 +符号 🚮 +符号 🚰 +符号 ♿ +符号 🚹 +符号 🚺 +符号 🚻 +符号 🚼 +符号 🚾 +符号 🛂 +符号 🛃 +符号 🛄 +符号 🛅 +符号 ⚠️ +符号 🚸 +符号 ⛔ +符号 🚫 +符号 🚳 +符号 🚭 +符号 🚯 +符号 🚱 +符号 🚷 +符号 📵 +符号 🔞 +符号 ☢️ +符号 ☣️ +符号 ⬆️ +符号 ↗️ +符号 ➡️ +符号 ↘️ +符号 ⬇️ +符号 ↙️ +符号 ⬅️ +符号 ↖️ +符号 ↕️ +符号 ↔️ +符号 ↩️ +符号 ↪️ +符号 ⤴️ +符号 ⤵️ +符号 🔃 +符号 🔄 +符号 🔙 +符号 🔚 +符号 🔛 +符号 🔜 +符号 🔝 +符号 🛐 +符号 ⚛️ +符号 🕉️ +符号 ✡️ +符号 ☸️ +符号 ☯️ +符号 ✝️ +符号 ☦️ +符号 ☪️ +符号 ☮️ +符号 🕎 +符号 🔯 +符号 🪯 +符号 ♈ +符号 ♉ +符号 ♊ +符号 ♋ +符号 ♌ +符号 ♍ +符号 ♎ +符号 ♏ +符号 ♐ +符号 ♑ +符号 ♒ +符号 ♓ +符号 ⛎ +符号 🔀 +符号 🔁 +符号 🔂 +符号 ▶️ +符号 ⏩ +符号 ⏭️ +符号 ⏯️ +符号 ◀️ +符号 ⏪ +符号 ⏮️ +符号 🔼 +符号 ⏫ +符号 🔽 +符号 ⏬ +符号 ⏸️ +符号 ⏹️ +符号 ⏺️ +符号 ⏏️ +符号 🎦 +符号 🔅 +符号 🔆 +符号 📶 +符号 🛜 +符号 📳 +符号 📴 +符号 ♀️ +符号 ♂️ +符号 ⚧️ +符号 ✖️ +符号 ➕ +符号 ➖ +符号 ➗ +符号 🟰 +符号 ♾️ +符号 ‼️ +符号 ⁉️ +符号 ❓ +符号 ❔ +符号 ❕ +符号 ❗ +符号 〰️ +符号 💱 +符号 💲 +符号 ⚕️ +符号 ♻️ +符号 ⚜️ +符号 🔱 +符号 📛 +符号 🔰 +符号 ⭕ +符号 ✅ +符号 ☑️ +符号 ✔️ +符号 ❌ +符号 ❎ +符号 ➰ +符号 ➿ +符号 〽️ +符号 ✳️ +符号 ✴️ +符号 ❇️ +符号 ©️ +符号 ®️ +符号 ™️ +符号 🫟 +符号 #️⃣ +符号 *️⃣ +符号 0️⃣ +符号 1️⃣ +符号 2️⃣ +符号 3️⃣ +符号 4️⃣ +符号 5️⃣ +符号 6️⃣ +符号 7️⃣ +符号 8️⃣ +符号 9️⃣ +符号 🔟 +符号 🔠 +符号 🔡 +符号 🔢 +符号 🔣 +符号 🔤 +符号 🅰️ +符号 🆎 +符号 🅱️ +符号 🆑 +符号 🆒 +符号 🆓 +符号 ℹ️ +符号 🆔 +符号 Ⓜ️ +符号 🆕 +符号 🆖 +符号 🅾️ +符号 🆗 +符号 🅿️ +符号 🆘 +符号 🆙 +符号 🆚 +符号 🈁 +符号 🈂️ +符号 🈷️ +符号 🈶 +符号 🈯 +符号 🉐 +符号 🈹 +符号 🈚 +符号 🈲 +符号 🉑 +符号 🈸 +符号 🈴 +符号 🈳 +符号 ㊗️ +符号 ㊙️ +符号 🈺 +符号 🈵 +符号 🔴 +符号 🟠 +符号 🟡 +符号 🟢 +符号 🔵 +符号 🟣 +符号 🟤 +符号 ⚫ +符号 ⚪ +符号 🟥 +符号 🟧 +符号 🟨 +符号 🟩 +符号 🟦 +符号 🟪 +符号 🟫 +符号 ⬛ +符号 ⬜ +符号 ◼️ +符号 ◻️ +符号 ◾ +符号 ◽ +符号 ▪️ +符号 ▫️ +符号 🔶 +符号 🔷 +符号 🔸 +符号 🔹 +符号 🔺 +符号 🔻 +符号 💠 +符号 🔘 +符号 🔳 +符号 🔲 +旗帜 🏁 +旗帜 🚩 +旗帜 🎌 +旗帜 🏴 +旗帜 🏳️ +旗帜 🏳️‍🌈 +旗帜 🏳️‍⚧️ +旗帜 🏴‍☠️ +旗帜 🇦🇨 +旗帜 🇦🇩 +旗帜 🇦🇪 +旗帜 🇦🇫 +旗帜 🇦🇬 +旗帜 🇦🇮 +旗帜 🇦🇱 +旗帜 🇦🇲 +旗帜 🇦🇴 +旗帜 🇦🇶 +旗帜 🇦🇷 +旗帜 🇦🇸 +旗帜 🇦🇹 +旗帜 🇦🇺 +旗帜 🇦🇼 +旗帜 🇦🇽 +旗帜 🇦🇿 +旗帜 🇧🇦 +旗帜 🇧🇧 +旗帜 🇧🇩 +旗帜 🇧🇪 +旗帜 🇧🇫 +旗帜 🇧🇬 +旗帜 🇧🇭 +旗帜 🇧🇮 +旗帜 🇧🇯 +旗帜 🇧🇱 +旗帜 🇧🇲 +旗帜 🇧🇳 +旗帜 🇧🇴 +旗帜 🇧🇶 +旗帜 🇧🇷 +旗帜 🇧🇸 +旗帜 🇧🇹 +旗帜 🇧🇻 +旗帜 🇧🇼 +旗帜 🇧🇾 +旗帜 🇧🇿 +旗帜 🇨🇦 +旗帜 🇨🇨 +旗帜 🇨🇩 +旗帜 🇨🇫 +旗帜 🇨🇬 +旗帜 🇨🇭 +旗帜 🇨🇮 +旗帜 🇨🇰 +旗帜 🇨🇱 +旗帜 🇨🇲 +旗帜 🇨🇳 +旗帜 🇨🇴 +旗帜 🇨🇵 +旗帜 🇨🇶 +旗帜 🇨🇷 +旗帜 🇨🇺 +旗帜 🇨🇻 +旗帜 🇨🇼 +旗帜 🇨🇽 +旗帜 🇨🇾 +旗帜 🇨🇿 +旗帜 🇩🇪 +旗帜 🇩🇬 +旗帜 🇩🇯 +旗帜 🇩🇰 +旗帜 🇩🇲 +旗帜 🇩🇴 +旗帜 🇩🇿 +旗帜 🇪🇦 +旗帜 🇪🇨 +旗帜 🇪🇪 +旗帜 🇪🇬 +旗帜 🇪🇭 +旗帜 🇪🇷 +旗帜 🇪🇸 +旗帜 🇪🇹 +旗帜 🇪🇺 +旗帜 🇫🇮 +旗帜 🇫🇯 +旗帜 🇫🇰 +旗帜 🇫🇲 +旗帜 🇫🇴 +旗帜 🇫🇷 +旗帜 🇬🇦 +旗帜 🇬🇧 +旗帜 🇬🇩 +旗帜 🇬🇪 +旗帜 🇬🇫 +旗帜 🇬🇬 +旗帜 🇬🇭 +旗帜 🇬🇮 +旗帜 🇬🇱 +旗帜 🇬🇲 +旗帜 🇬🇳 +旗帜 🇬🇵 +旗帜 🇬🇶 +旗帜 🇬🇷 +旗帜 🇬🇸 +旗帜 🇬🇹 +旗帜 🇬🇺 +旗帜 🇬🇼 +旗帜 🇬🇾 +旗帜 🇭🇰 +旗帜 🇭🇲 +旗帜 🇭🇳 +旗帜 🇭🇷 +旗帜 🇭🇹 +旗帜 🇭🇺 +旗帜 🇮🇨 +旗帜 🇮🇩 +旗帜 🇮🇪 +旗帜 🇮🇱 +旗帜 🇮🇲 +旗帜 🇮🇳 +旗帜 🇮🇴 +旗帜 🇮🇶 +旗帜 🇮🇷 +旗帜 🇮🇸 +旗帜 🇮🇹 +旗帜 🇯🇪 +旗帜 🇯🇲 +旗帜 🇯🇴 +旗帜 🇯🇵 +旗帜 🇰🇪 +旗帜 🇰🇬 +旗帜 🇰🇭 +旗帜 🇰🇮 +旗帜 🇰🇲 +旗帜 🇰🇳 +旗帜 🇰🇵 +旗帜 🇰🇷 +旗帜 🇰🇼 +旗帜 🇰🇾 +旗帜 🇰🇿 +旗帜 🇱🇦 +旗帜 🇱🇧 +旗帜 🇱🇨 +旗帜 🇱🇮 +旗帜 🇱🇰 +旗帜 🇱🇷 +旗帜 🇱🇸 +旗帜 🇱🇹 +旗帜 🇱🇺 +旗帜 🇱🇻 +旗帜 🇱🇾 +旗帜 🇲🇦 +旗帜 🇲🇨 +旗帜 🇲🇩 +旗帜 🇲🇪 +旗帜 🇲🇫 +旗帜 🇲🇬 +旗帜 🇲🇭 +旗帜 🇲🇰 +旗帜 🇲🇱 +旗帜 🇲🇲 +旗帜 🇲🇳 +旗帜 🇲🇴 +旗帜 🇲🇵 +旗帜 🇲🇶 +旗帜 🇲🇷 +旗帜 🇲🇸 +旗帜 🇲🇹 +旗帜 🇲🇺 +旗帜 🇲🇻 +旗帜 🇲🇼 +旗帜 🇲🇽 +旗帜 🇲🇾 +旗帜 🇲🇿 +旗帜 🇳🇦 +旗帜 🇳🇨 +旗帜 🇳🇪 +旗帜 🇳🇫 +旗帜 🇳🇬 +旗帜 🇳🇮 +旗帜 🇳🇱 +旗帜 🇳🇴 +旗帜 🇳🇵 +旗帜 🇳🇷 +旗帜 🇳🇺 +旗帜 🇳🇿 +旗帜 🇴🇲 +旗帜 🇵🇦 +旗帜 🇵🇪 +旗帜 🇵🇫 +旗帜 🇵🇬 +旗帜 🇵🇭 +旗帜 🇵🇰 +旗帜 🇵🇱 +旗帜 🇵🇲 +旗帜 🇵🇳 +旗帜 🇵🇷 +旗帜 🇵🇸 +旗帜 🇵🇹 +旗帜 🇵🇼 +旗帜 🇵🇾 +旗帜 🇶🇦 +旗帜 🇷🇪 +旗帜 🇷🇴 +旗帜 🇷🇸 +旗帜 🇷🇺 +旗帜 🇷🇼 +旗帜 🇸🇦 +旗帜 🇸🇧 +旗帜 🇸🇨 +旗帜 🇸🇩 +旗帜 🇸🇪 +旗帜 🇸🇬 +旗帜 🇸🇭 +旗帜 🇸🇮 +旗帜 🇸🇯 +旗帜 🇸🇰 +旗帜 🇸🇱 +旗帜 🇸🇲 +旗帜 🇸🇳 +旗帜 🇸🇴 +旗帜 🇸🇷 +旗帜 🇸🇸 +旗帜 🇸🇹 +旗帜 🇸🇻 +旗帜 🇸🇽 +旗帜 🇸🇾 +旗帜 🇸🇿 +旗帜 🇹🇦 +旗帜 🇹🇨 +旗帜 🇹🇩 +旗帜 🇹🇫 +旗帜 🇹🇬 +旗帜 🇹🇭 +旗帜 🇹🇯 +旗帜 🇹🇰 +旗帜 🇹🇱 +旗帜 🇹🇲 +旗帜 🇹🇳 +旗帜 🇹🇴 +旗帜 🇹🇷 +旗帜 🇹🇹 +旗帜 🇹🇻 +旗帜 🇹🇼 +旗帜 🇹🇿 +旗帜 🇺🇦 +旗帜 🇺🇬 +旗帜 🇺🇲 +旗帜 🇺🇳 +旗帜 🇺🇸 +旗帜 🇺🇾 +旗帜 🇺🇿 +旗帜 🇻🇦 +旗帜 🇻🇨 +旗帜 🇻🇪 +旗帜 🇻🇬 +旗帜 🇻🇮 +旗帜 🇻🇳 +旗帜 🇻🇺 +旗帜 🇼🇫 +旗帜 🇼🇸 +旗帜 🇽🇰 +旗帜 🇾🇪 +旗帜 🇾🇹 +旗帜 🇿🇦 +旗帜 🇿🇲 +旗帜 🇿🇼 +旗帜 🏴󠁧󠁢󠁥󠁮󠁧󠁿 +旗帜 🏴󠁧󠁢󠁳󠁣󠁴󠁿 +旗帜 🏴󠁧󠁢󠁷󠁬󠁳󠁿 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 32f71757..d4496d30 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -30,6 +30,7 @@ import llc.slacker.openime.candidate.Stroke import llc.slacker.openime.candidate.StrokeLexicon import llc.slacker.openime.candidate.personalizedLearningAllowed import llc.slacker.openime.core.CrashGuard +import llc.slacker.openime.core.EmojiCatalog import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -200,6 +201,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) } .onSuccess { candidatePipeline = it } .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) } + // The emoji panel's full list checks every emoji against the font: do it here, not on first open. + runCatching { EmojiCatalog.byCategory(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) { diff --git a/app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt b/app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt new file mode 100644 index 00000000..2d6b7b1c --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/EmojiCatalog.kt @@ -0,0 +1,58 @@ +package llc.slacker.openime.core + +import android.content.Context +import android.graphics.Paint + +/** + * The emoji panel's categories: [ImeData.emojiByCategory]'s curated order + * (its smileys have bundled Fluent images), then every other Unicode emoji + * of that category from assets/emoji/unicode_emoji.tsv (generated by + * scripts/generate_emoji_catalog.py). Emoji the phone's font cannot draw are + * left out, so an older phone shows no empty boxes for newer emoji. + */ +object EmojiCatalog { + private const val ASSET = "emoji/unicode_emoji.tsv" + + @Volatile + private var cached: LinkedHashMap>? = null + + fun byCategory(context: Context): LinkedHashMap> { + cached?.let { return it } + return synchronized(this) { + cached ?: build(context).also { cached = it } + } + } + + private fun build(context: Context): LinkedHashMap> { + val extra = LinkedHashMap>() + runCatching { + context.assets.open(ASSET).bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.forEach { line -> + if (line.isBlank() || line.startsWith('#')) return@forEach + val tab = line.indexOf('\t') + if (tab > 0) extra.getOrPut(line.substring(0, tab)) { ArrayList() } += line.substring(tab + 1) + } + } + } + val paint = Paint() + return merge(ImeData.emojiByCategory, extra) { emoji -> paint.hasGlyph(emoji) } + } + + /** Curated first, then the catalog's additions; [drawable] filters both. */ + internal fun merge( + curated: Map>, + extra: Map>, + drawable: (String) -> Boolean, + ): LinkedHashMap> { + val result = LinkedHashMap>() + curated.forEach { (category, emoji) -> + // Compare without the variation selector: ❤ and ❤️ are one emoji. + val seen = HashSet() + val list = (emoji + extra[category].orEmpty()) + .filter { seen.add(it.replace("️", "")) } + .filter(drawable) + result[category] = list + } + return result + } +} diff --git a/app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt b/app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt new file mode 100644 index 00000000..dc34bd0a --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/SymbolCatalog.kt @@ -0,0 +1,90 @@ +package llc.slacker.openime.core + +/** + * The symbol panel's categories, in order, and what each lists. The older + * [ImeData.symbols] groups are kept as they were and extended here, plus + * whole new categories (拼音声调, 日文, 注音, 制表) that Chinese users reach + * for and had no home. + */ +object SymbolCatalog { + /** Panel order. "自定义" is the user's own list (CustomSymbolRepository). */ + val CATEGORIES: List = listOf( + "常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", + "拼音", "日文", "注音", "制表", "单位", "编程", "自定义", + ) + + /** Full-width marks and signs beyond the basic punctuation. */ + val CHINESE_EXTRA: List = listOf( + "〈", "〉", "〔", "〕", "〖", "〗", "〘", "〙", "[", "]", "{", "}", + "〃", "々", "〆", "〇", "※", "§", "№", "‰", "℃", "°", "¥", "&", "@", + "#", "%", "*", "+", "-", "=", "/", "\", "|", "_", "﹏", "﹋", "﹌", + "︿", "﹀", "︵", "︶", "︷", "︸", "︹", "︺", "︻", "︼", "︽", "︾", + ) + + /** Stars, hearts, marks, music, weather, cards, gender and religion signs. */ + val SPECIAL_EXTRA: List = listOf( + "★", "☆", "✦", "✧", "✩", "✪", "✫", "✬", "✭", "✮", "✯", "✰", "✶", "✷", "✸", "✹", "✺", + "✻", "✼", "❄", "❅", "❆", "❇", "❈", "❉", "❊", "❋", "♥", "♡", "❤", "❥", "❦", "❧", + "✓", "✔", "✗", "✘", "☑", "☒", "☐", "♩", "♪", "♫", "♬", "♭", "♮", "♯", + "☀", "☁", "☂", "☃", "☄", "☾", "☽", "♠", "♣", "♦", "♤", "♧", "♢", + "♀", "♂", "⚥", "☯", "☮", "☸", "✿", "❀", "❁", "✾", "☎", "☏", "✆", "✉", "✎", "✏", "✐", + "⌘", "⌥", "⇧", "⏎", "⌫", "⌦", "♨", "☕", "⚠", "☢", "☣", "♻", "⚡", "☘", "⚓", "⚔", + "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", + ) + + /** Popular kaomoji. Long items get wide cells in the panel. */ + val KAOMOJI_EXTRA: List = listOf( + "( ̄▽ ̄)", "(^▽^)", "(≧▽≦)", "(*´▽`*)", "(。・ω・。)", "(・ω・)", "(`・ω・´)", "(´・ω・`)", + "( ´ ▽ ` )ノ", "ヾ(^∇^)", "ヽ(•‿•)ノ", "(☆▽☆)", "(✿◠‿◠)", "(◕‿◕)", "(◍•ᴗ•◍)", "(๑•̀ㅂ•́)و✧", + "(。♥‿♥。)", "(♡˙︶˙♡)", "(づ ̄ ³ ̄)づ", "(っ˘з(˘⌣˘ )", "(*/ω\*)", "(⁄ ⁄•⁄ω⁄•⁄ ⁄)", "(〃▽〃)", "(*^ω^*)", + "(╥﹏╥)", "(T_T)", "(ಥ﹏ಥ)", "(;´༎ຶД༎ຶ`)", "(っ °Д °;)っ", "Σ(っ°Д°;)っ", "(⊙_⊙)", "(°ロ°)!", + "(╯°□°)╯︵ ┻━┻", "┬─┬ノ( º _ ºノ)", "(ノ`Д´)ノ", "(╬ Ò﹏Ó)", "( ̄へ ̄)", "(¬_¬)", "(눈_눈)", "(`へ´)", + "¯\\_(ツ)_/¯", "┐( ̄ヘ ̄)┌", "(´-ω-`)", "( ̄ω ̄;)", "(-_-;)", "(^_^;)", "(ーー;)", "(* ̄m ̄)", + "( ̄o ̄) zzZZ", "(_ _)。゜zzZ", "(~﹃~)~zZ", "(>_<)", "(>﹏<)", "(。•́︿•̀。)", "(´;ω;`)", "(ノ_<。)", + "ψ(`∇´)ψ", "( ̄ε(# ̄)☆╰╮", "(ง •_•)ง", "ᕦ(ò_óˇ)ᕤ", "(•̀ᴗ•́)و", "(^^)b", "d( ̄▽ ̄)b", "(👍≖‿≖)👍", + "(=^・ω・^=)", "(=①ω①=)", "ʕ•ᴥ•ʔ", "U・ᴥ・U", "(・⊝・)", "(°∀°)ノ", "(*•̀ᴗ•́*)", "( ̄︶ ̄)", + "(o^^)o", "o(^^o)", "\(^o^)/", "ヽ(✿゚▽゚)ノ", "(^_−)☆", "(。•̀ᴗ-)✧", "( ̄ー ̄)b", "(•̀⌄•́)", + "( •̀ ω •́ )✧", "(◔◡◔)", "(˘•ω•˘)", "(。>﹏<。)", "(っ﹏-) .。o", "(*≧ω≦)", "( ˘•ω•˘ )", "(ノ◕ヮ◕)ノ*:・゚✧", + ) + + /** Pinyin with tone marks (and ü, ê, ḿ/ń for the rarer syllables). */ + val PINYIN_TONES: List = listOf( + "ā", "á", "ǎ", "à", "ō", "ó", "ǒ", "ò", "ē", "é", "ě", "è", "ī", "í", "ǐ", "ì", + "ū", "ú", "ǔ", "ù", "ǖ", "ǘ", "ǚ", "ǜ", "ü", "ê", "ê̄", "ế", "ê̌", "ề", "ḿ", "ń", "ň", "ǹ", + "Ā", "Á", "Ǎ", "À", "Ō", "Ó", "Ǒ", "Ò", "Ē", "É", "Ě", "È", "Ī", "Í", "Ǐ", "Ì", + "Ū", "Ú", "Ǔ", "Ù", "Ǖ", "Ǘ", "Ǚ", "Ǜ", "Ü", "ˉ", "ˊ", "ˇ", "ˋ", "˙", + ) + + /** Hiragana, then katakana, gojūon order with small kana and marks. */ + val JAPANESE: List = listOf( + "あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し", "す", "せ", "そ", + "た", "ち", "つ", "て", "と", "な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ", + "ま", "み", "む", "め", "も", "や", "ゆ", "よ", "ら", "り", "る", "れ", "ろ", "わ", "を", "ん", + "が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ", "だ", "ぢ", "づ", "で", "ど", + "ば", "び", "ぶ", "べ", "ぼ", "ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ぁ", "ぃ", "ぅ", "ぇ", "ぉ", "ゃ", "ゅ", "ょ", "っ", + "ア", "イ", "ウ", "エ", "オ", "カ", "キ", "ク", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", + "タ", "チ", "ツ", "テ", "ト", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "ヒ", "フ", "ヘ", "ホ", + "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ワ", "ヲ", "ン", + "ガ", "ギ", "グ", "ゲ", "ゴ", "ザ", "ジ", "ズ", "ゼ", "ゾ", "ダ", "ヂ", "ヅ", "デ", "ド", + "バ", "ビ", "ブ", "ベ", "ボ", "パ", "ピ", "プ", "ペ", "ポ", "ァ", "ィ", "ゥ", "ェ", "ォ", "ャ", "ュ", "ョ", "ッ", + "ヴ", "ー", "・", "゛", "゜", "ゝ", "ゞ", "ヽ", "ヾ", + ) + + /** Zhuyin (bopomofo) with its tone marks. */ + val ZHUYIN: List = listOf( + "ㄅ", "ㄆ", "ㄇ", "ㄈ", "ㄉ", "ㄊ", "ㄋ", "ㄌ", "ㄍ", "ㄎ", "ㄏ", "ㄐ", "ㄑ", "ㄒ", + "ㄓ", "ㄔ", "ㄕ", "ㄖ", "ㄗ", "ㄘ", "ㄙ", "ㄚ", "ㄛ", "ㄜ", "ㄝ", "ㄞ", "ㄟ", "ㄠ", "ㄡ", + "ㄢ", "ㄣ", "ㄤ", "ㄥ", "ㄦ", "ㄧ", "ㄨ", "ㄩ", "ˉ", "ˊ", "ˇ", "ˋ", "˙", + ) + + /** Box drawing and block elements, for tables and bars in plain text. */ + val BOX_DRAWING: List = listOf( + "─", "━", "│", "┃", "┄", "┅", "┆", "┇", "┈", "┉", "┊", "┋", + "┌", "┍", "┎", "┏", "┐", "┑", "┒", "┓", "└", "┕", "┖", "┗", "┘", "┙", "┚", "┛", + "├", "┝", "┠", "┣", "┤", "┥", "┨", "┫", "┬", "┯", "┰", "┳", "┴", "┷", "┸", "┻", "┼", "┿", "╂", "╋", + "═", "║", "╒", "╓", "╔", "╕", "╖", "╗", "╘", "╙", "╚", "╛", "╜", "╝", + "╞", "╟", "╠", "╡", "╢", "╣", "╤", "╥", "╦", "╧", "╨", "╩", "╪", "╫", "╬", + "╭", "╮", "╯", "╰", "╱", "╲", "╳", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", + "▉", "▊", "▋", "▌", "▍", "▎", "▏", "░", "▒", "▓", "▔", "▕", + ) +} 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 a860a561..158640ce 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -227,8 +227,12 @@ internal class ImeThemeApplier( } private fun applyScrollView(view: ScrollView, t: ImeTheme.Tokens) { - if (view.tag == "nine-punct-stack" && view.getChildAt(0)?.tag == "nine-pinyin-panel") { - // Reading list: the whole rail is one panel and its items are flat. + val content = view.getChildAt(0)?.tag + if ( + (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. view.background = InsetDrawable( ImeDrawableFactory.rounded(t.sideKeyBackground, toPx(ImeGeometryTokens.KEY_RADIUS_DP)), @@ -269,6 +273,15 @@ internal class ImeThemeApplier( } when { + view.tag == "punct:add" || view.tag == "digit-symbol:add" -> { + // The rail's circled plus: drawn like the symbols around it. + view.imageTintList = ColorStateList.valueOf(t.sideKeyText) + view.background = statefulRounded( + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), + toPx(ImeGeometryTokens.KEY_RADIUS_DP), + ) + } (view.tag as? String)?.startsWith("clip-pin:") == true || (view.tag as? String)?.startsWith("phrase-") == true -> { view.imageTintList = ColorStateList.valueOf(if (view.isSelected) t.primary else t.keySecondaryText) } @@ -501,9 +514,10 @@ internal class ImeThemeApplier( } tag?.startsWith("punct:") == true || tag?.startsWith("digit-symbol:") == true -> { + // Flat on the rail's panel; only a press shows a surface. view.setTextColor(t.keyText) view.background = statefulRounded( - t.sideKeyBackground, + Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) @@ -622,6 +636,9 @@ internal class ImeThemeApplier( private fun applyTaggedView(view: View, t: ImeTheme.Tokens) { when (view.tag) { + SymbolRailRenderer.DIVIDER_TAG -> view.setBackgroundColor( + ImeDrawableFactory.blend(t.keySecondaryText, t.sideKeyBackground, 0.22f), + ) "handwriting-canvas" -> { view.background = ImeDrawableFactory.rounded( t.canvasBackground, 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 a2d2fd18..53f50f20 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -73,7 +73,7 @@ internal class NineKeySymbolRailController( contentTag = NINE_CONTENT_TAG, contentDescription = "九键拼音筛选或常用符号,上下滑动查看更多", symbols = commonSymbols(), - cellHeightDp = cellHeightDp(), + cellHeightDp = symbolCellHeightDp(), toPx = toPx, tagPrefix = "punct:", onCommit = onCommit, @@ -161,7 +161,7 @@ internal class NineKeySymbolRailController( SymbolRailRenderer.populate( scroll = scroll, symbols = commonSymbols(), - cellHeightDp = cellHeightDp(), + cellHeightDp = symbolCellHeightDp(), toPx = toPx, tagPrefix = "punct:", onCommit = onCommit, @@ -266,6 +266,9 @@ internal class NineKeySymbolRailController( private fun speakablePath(path: String): String = path.replace(" ", "、").replace("|", "、").replace("'", "、") + /** 3.5 symbols to the rail's three key rows: the fourth peeks out, so the list reads as scrollable. */ + private fun symbolCellHeightDp(): Int = cellHeightDp() * 6 / 7 + /** The user's rail (ten common marks until edited), then + to edit it. */ private fun commonSymbols(): List = RailSymbolRepository.load(context) 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..86ef8546 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -61,7 +61,7 @@ internal class NumericKeyboardRenderer( contentTag = DIGITS_CONTENT_TAG, contentDescription = "数字键盘符号,上下滑动查看更多", symbols = digitSymbols(), - cellHeightDp = keyRowHeightDp(), + cellHeightDp = keyRowHeightDp() * 6 / 7, // the fourth symbol peeks out: the rail scrolls toPx = toPx, tagPrefix = "digit-symbol:", onCommit = onCommitCharacter, @@ -209,7 +209,7 @@ internal class NumericKeyboardRenderer( SymbolRailRenderer.populate( scroll = rail, symbols = digitSymbols(), - cellHeightDp = keyRowHeightDp(), + cellHeightDp = keyRowHeightDp() * 6 / 7, // the fourth symbol peeks out: the rail scrolls toPx = toPx, tagPrefix = "digit-symbol:", onCommit = onCommitCharacter, 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 c01beed5..686f8fab 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -116,23 +116,33 @@ internal class Pinyin9KeyboardRenderer( orientation = LinearLayout.VERTICAL tag = "pinyin9-actions" } + // 删除 · 重输 · 0 · 确定, one key row each: 0 has its own key, as on a + // phone keypad, instead of only a swipe-up on 1. side.addView( createBackspaceKey().apply { markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 3), + sideKeyParams(nineBodyHeightDp() / 4), ) side.addView( createKey("重输", true, null, ImeTypographyTokens.BODY_SP, onRetranslate).apply { tag = "key-retype" markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 3), + 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), ) side.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, onEnter).apply { tag = "key-enter" markSideKey(this) }, - sideKeyParams(nineBodyHeightDp() / 3), + sideKeyParams(nineBodyHeightDp() / 4), ) container.addView(side, adaptiveColumnParams(1f)) 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 1c8227e6..c08cc4eb 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -6,9 +6,11 @@ import android.graphics.Rect import android.view.Gravity import android.view.View import android.view.ViewGroup +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.theme.ImeGeometryTokens /** @@ -18,6 +20,7 @@ import llc.slacker.openime.theme.ImeGeometryTokens */ internal object SymbolRailRenderer { private const val CELL_HEIGHT_DP = 54 + private const val FADING_EDGE_DP = 18 fun build( context: Context, @@ -39,6 +42,9 @@ internal object SymbolRailRenderer { val scroll = ScrollView(context).apply { tag = railTag isVerticalScrollBarEnabled = false + // The list fades out at its edges: more lies beyond. + isVerticalFadingEdgeEnabled = true + setFadingEdgeLength(toPx(FADING_EDGE_DP)) overScrollMode = View.OVER_SCROLL_NEVER clipToPadding = false this.contentDescription = contentDescription @@ -90,7 +96,24 @@ internal object SymbolRailRenderer { cellParams(content.context, withGap = true, heightDp = cellHeightDp, toPx = toPx), ) } + // One flat list on one panel (the theme draws the panel), hairlines + // between the items: it reads as something to scroll, not a column of + // separate keys. Cells are a little shorter than a key row, so the next + // one peeks out at the bottom. + 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) + }, + ) + } symbols.forEachIndexed { index, symbol -> + if (index > 0) divider() content.addView( symbolCell( context = content.context, @@ -100,34 +123,34 @@ internal object SymbolRailRenderer { onCommit = onCommit, onFeedback = onFeedback, ), - cellParams( - content.context, - withGap = index < symbols.lastIndex || onAdd != null, - heightDp = cellHeightDp, - toPx = toPx, - ), + flatCellParams(heightDp = cellHeightDp, toPx = toPx), ) } if (onAdd != null) { + if (symbols.isNotEmpty()) divider() content.addView( - symbolCell( - context = content.context, - symbol = ADD_LABEL, - inheritedTextColor = inheritedTextColor, - tagPrefix = tagPrefix, - onCommit = { onAdd() }, - onFeedback = onFeedback, - ).apply { + ImageView(content.context).apply { + setImageResource(R.drawable.ic_rail_add) + scaleType = ImageView.ScaleType.CENTER tag = "${tagPrefix}add" contentDescription = "添加和排序常用符号" + isClickable = true + isFocusable = true + setOnClickListener { + onFeedback() + onAdd() + } }, - cellParams(content.context, withGap = false, heightDp = cellHeightDp, toPx = toPx), + flatCellParams(heightDp = cellHeightDp, toPx = toPx), ) } } - /** The rail editor's cell: a plain plus, drawn like the symbols around it. */ - private const val ADD_LABEL = "+" + const val DIVIDER_TAG = "rail-divider" + private const val DIVIDER_INSET_DP = 10 + + private 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( LinearLayout.LayoutParams.MATCH_PARENT, 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 8e1c427e..100d7156 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -18,7 +18,9 @@ import android.widget.ScrollView import android.widget.TextView import llc.slacker.openime.R import llc.slacker.openime.SymbolManagerActivity +import llc.slacker.openime.core.EmojiCatalog import llc.slacker.openime.core.ImeData +import llc.slacker.openime.core.SymbolCatalog import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel import llc.slacker.openime.data.CustomSymbolRepository @@ -429,9 +431,7 @@ internal class ImePanelRenderer( notifyRebuilt: Boolean, ) { body.removeAllViews() - val categories = listOf( - "常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", "单位", "编程", "自定义", - ) + 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 } @@ -482,20 +482,25 @@ internal class ImePanelRenderer( ), ) } - items.chunked(5).forEach { chunk -> + // Kaomoji and other long items get three wide cells a row and a smaller + // size; five narrow cells cut (づ。◕‿‿◕。)づ down to "(づ。". + 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 + items.chunked(columns).forEach { chunk -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { symbol -> row.addView( createKey( symbol, false, - ImeTypographyTokens.KEY_LETTER_SP, + textSize, ) { onSymbolSelected(symbol) }, - gridCellParams(54, 5, 0), + gridCellParams(54, columns, 0), ) } - repeat(5 - chunk.size) { - row.addView(View(context), gridCellParams(54, 5, 0)) + repeat(columns - chunk.size) { + row.addView(View(context), gridCellParams(54, columns, 0)) } grid.addView( row, @@ -525,7 +530,7 @@ internal class ImePanelRenderer( notifyRebuilt: Boolean, ) { body.removeAllViews() - val categories = listOf("全部") + ImeData.emojiByCategory.keys.toList() + val categories = listOf("全部") + EmojiCatalog.byCategory(context).keys.toList() fun displayCategory(category: String): String = when (category) { "人物/手势" -> "手势" "动物/自然" -> "动物" @@ -552,9 +557,9 @@ internal class ImePanelRenderer( setPadding(toPx(4), 0, toPx(4), 0) } val items = if (emojiCategory == "全部") { - (EmojiRecentRepository.load(context) + ImeData.emojiByCategory.values.flatten()).distinct() + (EmojiRecentRepository.load(context) + EmojiCatalog.byCategory(context).values.flatten()).distinct() } else { - val catalog = ImeData.emojiByCategory[emojiCategory].orEmpty() + val catalog = EmojiCatalog.byCategory(context)[emojiCategory].orEmpty() if (emojiCategory == "笑脸") (ImeData.referenceSmileys + catalog).distinct() else catalog } if (items.isEmpty() && emojiCategory == "最近") { @@ -707,7 +712,10 @@ internal class ImePanelRenderer( } private fun symbolItems(category: String): List = when (category) { - "中文" -> listOf(",", "。", "、", ";", ":", "?", "!", "…", "—", "~", "·", "「", "」", "『", "』", "(", ")", "《", "》", "【", "】", "“", "”", "‘", "’") + "中文" -> ( + listOf(",", "。", "、", ";", ":", "?", "!", "…", "—", "~", "·", "「", "」", "『", "』", "(", ")", "《", "》", "【", "】", "“", "”", "‘", "’") + + SymbolCatalog.CHINESE_EXTRA + ).distinct() "英文" -> ImeData.symbols["英文标点"].orEmpty() "数学" -> listOf( ImeData.symbols["数学运算"].orEmpty(), @@ -726,11 +734,16 @@ internal class ImePanelRenderer( "编程" -> ImeData.symbols["技术编程"].orEmpty() "特殊" -> listOf( ImeData.symbols["特殊图形"].orEmpty(), + SymbolCatalog.SPECIAL_EXTRA, ImeData.symbols["几何图形"].orEmpty(), ImeData.symbols["箭头线条"].orEmpty(), ImeData.symbols["括号边框"].orEmpty(), - ).flatten() - "网络颜文字" -> ImeData.symbols["网络颜文字"].orEmpty() + ).flatten().distinct() + "网络颜文字" -> (ImeData.symbols["网络颜文字"].orEmpty() + SymbolCatalog.KAOMOJI_EXTRA).distinct() + "拼音" -> SymbolCatalog.PINYIN_TONES + "日文" -> SymbolCatalog.JAPANESE + "注音" -> SymbolCatalog.ZHUYIN + "制表" -> SymbolCatalog.BOX_DRAWING "自定义" -> CustomSymbolRepository.load(context).map { it.symbol } else -> listOf( ImeData.symbols["常用"].orEmpty(), @@ -755,5 +768,7 @@ 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 } } diff --git a/app/src/main/res/drawable/ic_rail_add.xml b/app/src/main/res/drawable/ic_rail_add.xml new file mode 100644 index 00000000..e2d3b115 --- /dev/null +++ b/app/src/main/res/drawable/ic_rail_add.xml @@ -0,0 +1 @@ + diff --git a/app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt b/app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt new file mode 100644 index 00000000..634ab2db --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/EmojiAndSymbolCatalogTest.kt @@ -0,0 +1,62 @@ +package llc.slacker.openime + +import llc.slacker.openime.core.EmojiCatalog +import llc.slacker.openime.core.ImeData +import llc.slacker.openime.core.SymbolCatalog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class EmojiAndSymbolCatalogTest { + private fun asset(relative: String): File = sequenceOf( + File("src/main/assets/$relative"), + File("app/src/main/assets/$relative"), + ).first { it.isFile } + + @Test + fun curatedEmojiComeFirstThenTheCatalogAddsTheRest() { + val merged = EmojiCatalog.merge( + curated = linkedMapOf("笑脸" to listOf("😀", "❤️")), + extra = mapOf("笑脸" to listOf("😀", "❤", "🫠"), "旗帜" to listOf("🏁")), + drawable = { true }, + ) + // ❤ and ❤️ are one emoji; categories the panel has no tab for are ignored. + assertEquals(listOf("😀", "❤️", "🫠"), merged["笑脸"]) + assertEquals(setOf("笑脸"), merged.keys) + } + + @Test + fun emojiTheFontCannotDrawAreLeftOut() { + val merged = EmojiCatalog.merge( + curated = linkedMapOf("笑脸" to listOf("😀")), + extra = mapOf("笑脸" to listOf("🫩")), + drawable = { it != "🫩" }, + ) + assertEquals(listOf("😀"), merged["笑脸"]) + } + + @Test + fun theGeneratedCatalogCoversEveryPanelCategoryWithoutSkinTones() { + val rows = asset("emoji/unicode_emoji.tsv").readLines().filter { it.isNotBlank() && !it.startsWith("#") } + val categories = rows.map { it.substringBefore('\t') }.toSet() + assertEquals(ImeData.emojiByCategory.keys, categories) + assertTrue("about 1900 base emoji, got ${rows.size}", rows.size in 1800..2400) + val tones = (0x1F3FB..0x1F3FF).map { String(Character.toChars(it)) } + assertTrue(rows.none { row -> tones.any { it in row } }) + } + + @Test + fun everySymbolListIsFilledAndHasNoRepeats() { + listOf( + SymbolCatalog.CHINESE_EXTRA, SymbolCatalog.SPECIAL_EXTRA, SymbolCatalog.KAOMOJI_EXTRA, + SymbolCatalog.PINYIN_TONES, SymbolCatalog.JAPANESE, SymbolCatalog.ZHUYIN, SymbolCatalog.BOX_DRAWING, + ).forEach { list -> + assertTrue(list.isNotEmpty()) + assertEquals("repeats in ${list.take(3)}", list.size, list.toSet().size) + assertTrue(list.none { it.isBlank() }) + } + assertEquals(SymbolCatalog.CATEGORIES.size, SymbolCatalog.CATEGORIES.toSet().size) + assertTrue(SymbolCatalog.KAOMOJI_EXTRA.size + ImeData.symbols["网络颜文字"].orEmpty().size >= 100) + } +} diff --git a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt index 9b230ba3..1c762b68 100644 --- a/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt +++ b/app/src/test/java/llc/slacker/openime/NoDecorativeGlyphTest.kt @@ -9,7 +9,8 @@ class NoDecorativeGlyphTest { fun interactiveUiDoesNotUseUnicodeAsIcons() { val root = repoRoot() val forbidden = listOf("⌄", "⌃", "✓", "⏹", "🎤", "☺", "›", "▲", "◀", "▶", "▼") - val excluded = setOf("ImeData.kt") + // Symbol and emoji data the user types, not glyphs used as icons. + val excluded = setOf("ImeData.kt", "SymbolCatalog.kt") val violations = File(root, "app/src/main/java") .walkTopDown() .filter { it.isFile && it.extension == "kt" && it.name !in excluded } diff --git a/scripts/generate_emoji_catalog.py b/scripts/generate_emoji_catalog.py new file mode 100644 index 00000000..71c184e5 --- /dev/null +++ b/scripts/generate_emoji_catalog.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Generate openIME's full emoji list from Unicode's emoji-test.txt. + + curl -sSfLO https://unicode.org/Public/emoji/latest/emoji-test.txt + python3 scripts/generate_emoji_catalog.py emoji-test.txt + +Writes app/src/main/assets/emoji/unicode_emoji.tsv: one "categoryemoji" +line per fully-qualified emoji, in Unicode's order, without skin-tone or +hair variants (the panel shows each emoji once). The emoji panel keeps its +curated order and appends what this list adds; emoji the phone's font cannot +draw are dropped at runtime. Unicode data terms: +https://www.unicode.org/terms_of_use.html +""" + +import re +import sys +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "app" / "src" / "main" / "assets" / "emoji" / "unicode_emoji.tsv" + +# Unicode emoji groups -> the panel's categories (ImeData.emojiByCategory keys). +GROUPS = { + "Smileys & Emotion": "笑脸", + "People & Body": "人物/手势", + "Animals & Nature": "动物/自然", + "Food & Drink": "食物/饮品", + "Travel & Places": "旅行/地点", + "Activities": "活动", + "Objects": "物品", + "Symbols": "符号", + "Flags": "旗帜", +} +# Skin tones and hair styles: the panel lists the base emoji only. +VARIANTS = set(range(0x1F3FB, 0x1F400)) | {0x1F9B0, 0x1F9B1, 0x1F9B2, 0x1F9B3} + + +def main(): + source = Path(sys.argv[1]) + text = source.read_text(encoding="utf-8") + version = re.search(r"^# Version: (\S+)", text, re.M).group(1) + group = None + rows = [] + for line in text.splitlines(): + if line.startswith("# group: "): + group = GROUPS.get(line[len("# group: "):].strip()) + continue + if group is None or not line or line.startswith("#"): + continue + codes, _, rest = line.partition(";") + if not rest.strip().startswith("fully-qualified"): + continue + points = [int(code, 16) for code in codes.split()] + if any(point in VARIANTS for point in points): + continue + rows.append((group, "".join(chr(point) for point in points))) + OUT.write_text( + f"# Unicode emoji {version}, fully-qualified, no skin-tone or hair variants.\n" + "# Generated by scripts/generate_emoji_catalog.py from emoji-test.txt; do not edit.\n" + + "".join(f"{category}\t{emoji}\n" for category, emoji in rows), + encoding="utf-8", + ) + print(f"{len(rows)} emoji (Unicode {version}) -> {OUT.relative_to(OUT.parents[5])}") + + +if __name__ == "__main__": + main() From f9f474670b6443d6352069e017b0f2a534b96e0c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:43:39 +0800 Subject: [PATCH 05/12] One icon family for the keyboard switcher; tile icons 28dp on both tile pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every keyboard icon is the toolbar keyboard's frame with what that keyboard shows inside (rows, 3x3 keys, 一丨丿, Aa, 123). Co-Authored-By: Claude Opus 5.5 --- .../main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt | 2 ++ app/src/main/res/drawable/ic_kb_digits.xml | 2 +- app/src/main/res/drawable/ic_kb_english.xml | 2 +- app/src/main/res/drawable/ic_kb_pinyin26.xml | 2 +- app/src/main/res/drawable/ic_kb_pinyin9.xml | 2 +- app/src/main/res/drawable/ic_kb_stroke.xml | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) 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 158640ce..7f6cf4b2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -248,6 +248,8 @@ internal class ImeThemeApplier( 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 + // Tile icons (工具 and 切换键盘 share the tile): one size for both pages. + view.tag == "tool-icon" || view.tag == "tool-icon-selected" -> 28 hasAncestorTag(view, "tools-panel") -> 24 (view.parent as? View)?.tag == "toolbar-row" -> 22 else -> 0 diff --git a/app/src/main/res/drawable/ic_kb_digits.xml b/app/src/main/res/drawable/ic_kb_digits.xml index b4633a04..a0a5681b 100644 --- a/app/src/main/res/drawable/ic_kb_digits.xml +++ b/app/src/main/res/drawable/ic_kb_digits.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_english.xml b/app/src/main/res/drawable/ic_kb_english.xml index 2f42c176..7e928f90 100644 --- a/app/src/main/res/drawable/ic_kb_english.xml +++ b/app/src/main/res/drawable/ic_kb_english.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_pinyin26.xml b/app/src/main/res/drawable/ic_kb_pinyin26.xml index 9c06f78a..ad275b51 100644 --- a/app/src/main/res/drawable/ic_kb_pinyin26.xml +++ b/app/src/main/res/drawable/ic_kb_pinyin26.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_pinyin9.xml b/app/src/main/res/drawable/ic_kb_pinyin9.xml index 04717d82..84c038b7 100644 --- a/app/src/main/res/drawable/ic_kb_pinyin9.xml +++ b/app/src/main/res/drawable/ic_kb_pinyin9.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_kb_stroke.xml b/app/src/main/res/drawable/ic_kb_stroke.xml index 76e71d5f..0128c800 100644 --- a/app/src/main/res/drawable/ic_kb_stroke.xml +++ b/app/src/main/res/drawable/ic_kb_stroke.xml @@ -1 +1 @@ - + From e41f191a4a2eb7779acb05c49335463a010740e4 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:49:46 +0800 Subject: [PATCH 06/12] Release 0.0.7-beta.1; typing stress covers panels, rail, expand, clear, landscape, dark Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 ++++ VERSION | 2 +- docs/NINE_KEY_REFERENCE.md | 4 ++ scripts/typing_stress_e2e.py | 131 ++++++++++++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b314ba1..3d8a6bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.7-beta.1] - 2026-10-05 + +第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。 + +- 修复九键预编辑行有时显示数字(如“669”或一整串数字):没有词能读完全部数字、或首个候选只覆盖前半段时,预编辑退回了原始数字。现在任何时候都显示拼音字母:先按首个候选的读音对齐(含简拼,如 669 打出“模型”显示 mo'x),剩下的按完整音节和未打完音节的开头拼出。 +- 修复九键左侧拼音栏点只有声母的选项(如单按 6 后的 m、n)没反应:现在点了就按这个字母筛选候选(m → 没、么、们)。 +- 九键左侧符号栏改成一整块可滑动的列表:符号之间用细线分隔,底部露出半个下一项,一看就知道能上下滑;最下面的“+”加了圆圈。数字键盘的符号栏同样调整。 +- 九键右侧一列加“0”键:删除、重输、0、确定。 +- 表情补全为 Unicode 18.0 的全部基础表情(约 1900 个,原有顺序不变,新增的排在各分类后面);手机字体显示不了的新表情自动隐藏,不会出现方框。 +- 符号大幅扩充:中文标点、特殊符号(星、心、勾叉、音乐、天气、星座等)、颜文字增加到 100 多个,新增“拼音”(带声调字母)、“日文”(平假名、片假名)、“注音”、“制表”(制表线和方块)分类。 +- 修复颜文字在符号面板里显示不全:较长的符号改用每行 3 个的宽格子。 +- 切换键盘的五个图标统一成同一种样式(与工具栏的键盘图标同一个外框),工具页和切换键盘页的图标大小一致。 +- 构建工具升级(Gradle 8.14、Android Gradle 插件 8.13、Kotlin 2.4)。 + ## [0.0.6-beta.1] - 2026-10-05 第六个测试版:Rime 词库在打包时预先编译好,安装或升级后不用再等一分钟。从 0.0.5-beta.1 覆盖安装即可,用户数据保留。 diff --git a/VERSION b/VERSION index 12e375e9..a9b673b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.6-beta.1 +0.0.7-beta.1 diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md index 73864d15..8486bdae 100644 --- a/docs/NINE_KEY_REFERENCE.md +++ b/docs/NINE_KEY_REFERENCE.md @@ -22,6 +22,7 @@ (百度输入法、rime-t9-shiyin 的做法,也是九键「先选拼音再选字」的通行流程)。用户不用为整句选拼音, 整词的读法在预编辑里看,要换字就从候选里选。不提供会让剩余数字无法拼读的选项;孤立的 `a/o/e`、无元音的 `ng/m` 不当读法。早先按设计稿列整条读法(`ni'hao`),与「一个字一个拼音」冲突,已改掉。 + 只有声母的项(单按 6 时的 `m`、`n`)点了同样锁定:候选按这个字母筛选(`m` → 没、么、们)。 2. **锁定的音节发给 Rime 时保持字母**(`xiong'486`)。luna_pinyin 方案同时接受字母和 2–9 数字, 所以 zhong / xiong 这类同数字的读法不会被重新混在一起,候选与所选读法一致。 3. **锁定的音节在继续打字时保持锁定**(用边界封住),退格先解锁最近锁定的音节 / 分词边界。 @@ -30,6 +31,9 @@ 5. **数字刚好拼得出的词排在预测之前。** 输入 `9426`(xian)时 Rime 可能先给 `自从`(zi'cong 的预测); 我们按读法是否恰好耗尽已输入数字分组,组内保持 Rime 顺序,不丢任何候选。 6. **预编辑跟随首选词的读法**(`我想吃饭` → `wo'xiang'chi'fan`),而不是本地解码器独立的猜测。 + 首选词只覆盖前半段时,按它的字取到覆盖不了为止(每个字依次试全拼、截断的读音、声母:`669` → 模型 → `mo'x`), + 剩下的数字再拼成字母。**预编辑任何时候都只有字母,不出现数字**:没有词可依时,本地按「最少的完整音节、 + 末尾允许未打完的音节开头」拼出(`46` → `go`)。 7. **选词只覆盖一部分输入时,只上屏该词,剩余输入继续作为预编辑**(九键 / 26 键一致)。 本地不对部分选词做原生学习(Rime 的整句提交会把没选过的短语写进用户词库)。 8. **空格提交首选词;回车(确定)提交已输入的拼音原文**(Rime / fcitx / Gboard 拼音的约定)。 diff --git a/scripts/typing_stress_e2e.py b/scripts/typing_stress_e2e.py index 91dba9bf..27c5faee 100644 --- a/scripts/typing_stress_e2e.py +++ b/scripts/typing_stress_e2e.py @@ -35,6 +35,7 @@ RECEIVER = f"{PKG}/{CLASSES}.E2ETestReceiver" ACTION = f"{CLASSES}.TEST_COMMAND" FIELD = "lab_multiline" +FIELD_HINT = "多行文本" ROOT = Path(__file__).resolve().parent.parent STROKE_TABLE = ROOT / "app" / "build" / "generated" / "assets" / "prebuildRimeData" / "stroke_table.tsv" @@ -108,7 +109,9 @@ def field_text(self) -> str: continue for el in root.iter("node"): if el.get("resource-id", "").endswith(f"/{FIELD}"): - return el.get("text", "") + # uiautomator reports an empty field's hint as its text. + text = el.get("text", "") + return "" if text == el.get("hint", FIELD_HINT) or text == FIELD_HINT else text return "" def field_bounds(self) -> tuple[int, int, int, int] | None: @@ -286,6 +289,52 @@ def probe_preedit_stays_on_keyboard(dev: Device, keys: list[str]) -> str | None: return None +def visible_tags(dev: Device, predicate) -> list[tuple[str, str, float, float]]: + """Bounds entries matching [predicate] whose centre is on screen above the navigation bar.""" + height = int(re.search(r"(\d+)x(\d+)", dev.shell("wm", "size")).group(2)) + return [b for b in dev.bounds if predicate(b) and 0 < b[3] < height - 120] + + +def type_from_symbol_panel(dev: Device, categories: list[str], per_category: int = 2) -> str: + """Open 符号, tap the first symbols of each category; returns what should have been typed.""" + expected = "" + dev.tap_tag("key-symbols") + time.sleep(1.0) + for category in categories: + dev.command(f"tap:{category}") + time.sleep(0.8) + dev.refresh_bounds() + keys = visible_tags(dev, lambda b: b[0].startswith("key:"))[:per_category] + for tag, desc, x, y in keys: + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.25) + expected += tag[len("key:"):] + dev.shell("input", "keyevent", "BACK") + time.sleep(0.6) + return expected + + +def type_emoji(dev: Device, count: int = 6) -> str: + dev.command("tap:表情") + time.sleep(1.2) + dev.refresh_bounds() + cells = visible_tags(dev, lambda b: b[0] == "emoji-cell")[:count] + expected = "" + for _, desc, x, y in cells: + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.25) + expected += desc + dev.shell("input", "keyevent", "BACK") + time.sleep(0.6) + return expected + + +def set_rotation(dev: Device, landscape: bool) -> None: + dev.shell("settings", "put", "system", "accelerometer_rotation", "0") + dev.shell("settings", "put", "system", "user_rotation", "1" if landscape else "0") + time.sleep(2) + + def is_cjk(text: str) -> bool: return all("㐀" <= ch <= "鿿" or "\U00020000" <= ch <= "\U0003134f" for ch in text) @@ -342,6 +391,81 @@ def run_case(dev: Device, name: str, minimum: int) -> tuple[bool, str]: type_digits(dev, "2026100512") set_mode(dev, "PINYIN_26") type_pinyin26(dev, PINYIN_WORDS[16:24]) + elif name == "symbols": + set_mode(dev, "PINYIN_9") + expected = type_from_symbol_panel( + dev, ["常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", "拼音", "日文", "注音", "制表", "单位", "编程"], + ) + minimum = min(minimum, len(expected)) + elif name == "emoji": + set_mode(dev, "PINYIN_26") + expected = type_emoji(dev, 8) + minimum = min(minimum, len(expected)) + elif name == "rail": + set_mode(dev, "PINYIN_9") + cells = visible_tags(dev, lambda b: b[0].startswith("punct:") and b[0] != "punct:add")[:3] + expected = "" + for tag, _, x, y in cells: + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.25) + expected += tag[len("punct:"):] + dev.tap_tag("key-9:0") + expected += "0" + minimum = min(minimum, len(expected)) + elif name == "expand": + set_mode(dev, "PINYIN_26") + picked = "" + for word in ["shi", "zhong", "hao", "de", "ren"]: + for letter in word: + dev.tap_tag(f"key:{letter}") + time.sleep(0.5) + dev.refresh_bounds() + dev.tap_tag("candidate-expand") + time.sleep(0.8) + dev.refresh_bounds() + grid = visible_tags(dev, lambda b: b[0] in ("candidate-grid", "candidate-grid-first")) + if len(grid) < 3: + return False, f"expanded candidates for {word}: only {len(grid)}" + tag, desc, x, y = grid[2] + dev.shell("input", "tap", str(int(x)), str(int(y))) + time.sleep(0.5) + dev.refresh_bounds() + picked += desc.removeprefix("候选:") + expected = picked + minimum = min(minimum, len(expected)) + elif name == "clear": + set_mode(dev, "PINYIN_26") + type_pinyin26(dev, PINYIN_WORDS[:6]) + dev.refresh_bounds() + hit = next(b for b in dev.bounds if b[0] == "key-backspace") + dev.shell("input", "swipe", str(int(hit[2])), str(int(hit[3])), str(int(hit[2])), str(int(hit[3] - 330)), "300") + time.sleep(1) + text = dev.field_text() + dev.shot(name) + return (text == ""), f"after swipe-up clear the field holds {text!r}" + elif name in ("landscape", "dark"): + if name == "landscape": + set_rotation(dev, True) + else: + dev.shell("cmd", "uimode", "night", "yes") + time.sleep(1.5) + try: + show_keyboard(dev) + clear_field(dev) + set_mode(dev, "PINYIN_26") + type_pinyin26(dev, PINYIN_WORDS[:14]) + set_mode(dev, "PINYIN_9") + type_nine_key(dev, PINYIN_WORDS[14:28]) + time.sleep(1) + text = dev.field_text() + dev.shot(name) + finally: + if name == "landscape": + set_rotation(dev, False) + else: + dev.shell("cmd", "uimode", "night", "no") + ok = len(text) >= minimum and is_cjk(text) + return ok, f"{len(text)} characters: {text[:40]}…" else: return False, f"unknown case {name}" time.sleep(1) @@ -366,7 +490,10 @@ def main() -> int: parser.add_argument("--out", type=Path, default=ROOT / "build" / "typing-stress") parser.add_argument("--min", type=int, default=50) parser.add_argument("--package", default=PKG, help="applicationId of the debug build (e.g. with a .dev suffix)") - parser.add_argument("cases", nargs="*", default=["pinyin26", "pinyin9", "nine-rail", "stroke", "english", "digits", "mixed"]) + parser.add_argument("cases", nargs="*", default=[ + "pinyin26", "pinyin9", "nine-rail", "stroke", "english", "digits", "mixed", + "symbols", "emoji", "rail", "expand", "clear", "landscape", "dark", + ]) args = parser.parse_args() PKG = args.package IME = f"{PKG}/{CLASSES}.LocalVoiceImeService" From 3e4630dfcf3245ab4b87d2fff05ae10f8f1fefd6 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:46:46 +0800 Subject: [PATCH 07/12] Symbol rails show three items, level with the keys; isolate the emoji recents test Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../llc/slacker/openime/InteractionPolishInstrumentedTest.kt | 2 ++ .../slacker/openime/keyboard/NineKeySymbolRailController.kt | 4 ++-- .../llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt | 4 ++-- .../java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt | 5 ++--- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8a6bf3..14fe51bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 修复九键预编辑行有时显示数字(如“669”或一整串数字):没有词能读完全部数字、或首个候选只覆盖前半段时,预编辑退回了原始数字。现在任何时候都显示拼音字母:先按首个候选的读音对齐(含简拼,如 669 打出“模型”显示 mo'x),剩下的按完整音节和未打完音节的开头拼出。 - 修复九键左侧拼音栏点只有声母的选项(如单按 6 后的 m、n)没反应:现在点了就按这个字母筛选候选(m → 没、么、们)。 -- 九键左侧符号栏改成一整块可滑动的列表:符号之间用细线分隔,底部露出半个下一项,一看就知道能上下滑;最下面的“+”加了圆圈。数字键盘的符号栏同样调整。 +- 九键左侧符号栏改成一整块可滑动的列表:符号之间用细线分隔,上下边缘渐隐,提示可以上下滑;最下面的“+”加了圆圈。数字键盘的符号栏同样调整。 - 九键右侧一列加“0”键:删除、重输、0、确定。 - 表情补全为 Unicode 18.0 的全部基础表情(约 1900 个,原有顺序不变,新增的排在各分类后面);手机字体显示不了的新表情自动隐藏,不会出现方框。 - 符号大幅扩充:中文标点、特殊符号(星、心、勾叉、音乐、天气、星座等)、颜文字增加到 100 多个,新增“拼音”(带声调字母)、“日文”(平假名、片假名)、“注音”、“制表”(制表线和方块)分类。 diff --git a/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt index d0474b51..1a7d3ac0 100644 --- a/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/InteractionPolishInstrumentedTest.kt @@ -19,6 +19,8 @@ class InteractionPolishInstrumentedTest { @Test fun emojiRecentIsMostRecentlyUsedAndDeduplicated() { + // Start from no recents: earlier sessions on the device (or E2E runs) leave some. + EmojiRecentRepository.clear(context) EmojiRecentRepository.record(context, "😀") EmojiRecentRepository.record(context, "😂") EmojiRecentRepository.record(context, "😀") 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 53f50f20..e52c54a5 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeySymbolRailController.kt @@ -266,8 +266,8 @@ internal class NineKeySymbolRailController( private fun speakablePath(path: String): String = path.replace(" ", "、").replace("|", "、").replace("'", "、") - /** 3.5 symbols to the rail's three key rows: the fourth peeks out, so the list reads as scrollable. */ - private fun symbolCellHeightDp(): Int = cellHeightDp() * 6 / 7 + /** Three symbols to the rail's three key rows, level with the keys beside them. */ + private fun symbolCellHeightDp(): Int = cellHeightDp() /** The user's rail (ten common marks until edited), then + to edit it. */ private fun commonSymbols(): List = RailSymbolRepository.load(context) 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 86ef8546..a2e4ddc1 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NumericKeyboardRenderer.kt @@ -61,7 +61,7 @@ internal class NumericKeyboardRenderer( contentTag = DIGITS_CONTENT_TAG, contentDescription = "数字键盘符号,上下滑动查看更多", symbols = digitSymbols(), - cellHeightDp = keyRowHeightDp() * 6 / 7, // the fourth symbol peeks out: the rail scrolls + cellHeightDp = keyRowHeightDp(), toPx = toPx, tagPrefix = "digit-symbol:", onCommit = onCommitCharacter, @@ -209,7 +209,7 @@ internal class NumericKeyboardRenderer( SymbolRailRenderer.populate( scroll = rail, symbols = digitSymbols(), - cellHeightDp = keyRowHeightDp() * 6 / 7, // the fourth symbol peeks out: the rail scrolls + cellHeightDp = keyRowHeightDp(), toPx = toPx, tagPrefix = "digit-symbol:", onCommit = onCommitCharacter, 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 c08cc4eb..abf4bacd 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SymbolRailRenderer.kt @@ -97,9 +97,8 @@ internal object SymbolRailRenderer { ) } // One flat list on one panel (the theme draws the panel), hairlines - // between the items: it reads as something to scroll, not a column of - // separate keys. Cells are a little shorter than a key row, so the next - // one peeks out at the bottom. + // 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 { From 8dcae61034226282ee878ef53a134394b091f902 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:28:53 +0800 Subject: [PATCH 08/12] Switch on iOS geometry with symmetric insets; fuzzy pair tracks show their state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The track drawable replaced the switch's 2dp side padding (InsetDrawable pads its view), so the knob sat 0 from the left when off and 4 from the right when on. The track no longer pads; the knob's travel comes from the laid-out pixels; sizes follow iOS (51x31 track, 27 knob, 2 on every side); the knob has a soft shadow and stretches toward its travel while pressed. The theme looked up 模糊音 pairs by label in a table that answered true for anything unknown, so every pair's track was blue. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../openime/keyboard/ImeKeyboardView.kt | 8 +++- .../openime/keyboard/ImeThemeApplier.kt | 4 ++ .../openime/panel/SettingsPanelController.kt | 40 ++++++++++++++----- .../slacker/openime/theme/ImeDesignTokens.kt | 9 +++-- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fe51bb..cb26c0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 符号大幅扩充:中文标点、特殊符号(星、心、勾叉、音乐、天气、星座等)、颜文字增加到 100 多个,新增“拼音”(带声调字母)、“日文”(平假名、片假名)、“注音”、“制表”(制表线和方块)分类。 - 修复颜文字在符号面板里显示不全:较长的符号改用每行 3 个的宽格子。 - 切换键盘的五个图标统一成同一种样式(与工具栏的键盘图标同一个外框),工具页和切换键盘页的图标大小一致。 +- 开关按 iOS 的比例重做(51×31,圆钮 27):圆钮离四边都是 2dp,开和关两种状态完全对称,带一层淡阴影,按住时朝移动方向轻轻拉长。之前关闭时圆钮贴着左边、打开时离右边 4dp。整个应用的开关都是这一个组件。 +- 修复模糊音页里关着的子开关仍显示蓝色底。 - 构建工具升级(Gradle 8.14、Android Gradle 插件 8.13、Kotlin 2.4)。 ## [0.0.6-beta.1] - 2026-10-05 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 e0361f46..7f1dd10c 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -30,6 +30,7 @@ import llc.slacker.openime.candidate.NineKeyLocalDecoder import llc.slacker.openime.candidate.NineKeyPerformanceTrace import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.StrokeLexicon +import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel @@ -2277,7 +2278,10 @@ open class ImeKeyboardView( tag = "panel-section-title" } - private fun onState(seed: String): Boolean = when (seed) { + private fun onState(seed: String): Boolean = FuzzyRule.fromLabel(seed) + // A 模糊音 pair: its own saved state (the theme paints the track from this). + ?.let { it in ImeSettingsRepository.loadFuzzyRules(context) } + ?: when (seed) { "按键音效" -> soundEnabled "触感震动" -> hapticEnabled "模糊音纠错", "启用模糊音" -> fuzzyEnabled @@ -2288,7 +2292,7 @@ open class ImeKeyboardView( "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) "标点用空格代替" -> ImeSettingsRepository.loadVoicePunctuationAsSpace(context) else -> true - } + } private fun updateSettingToggle(seed: String, enabled: Boolean) { when (seed) { 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 7f6cf4b2..3f471bcc 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -625,12 +625,16 @@ internal class ImeThemeApplier( ?.substringBefore(',') .orEmpty() val enabled = toggleState(seed) + // padsView = false: the track must not replace the switch's own + // 2dp side padding, or the knob sits 0 from the left edge when + // off and 4 from the right when on. view.background = paintedWithinTarget( ImeDrawableFactory.rounded( ImeSurfacePolicy.switchTrack(enabled, t), toPx(ImeGeometryTokens.PILL_RADIUS_DP), ), ImeGeometryTokens.SWITCH_HEIGHT_DP, + padsView = false, ) } } 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 3e9e1f34..7eaae82b 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -7,6 +7,7 @@ import android.content.Context import android.os.Build import android.text.TextUtils import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator @@ -854,10 +855,8 @@ internal class SettingsPanelController( gravity = Gravity.START or Gravity.CENTER_VERTICAL } tag = "toggle-knob" - elevation = toPx(1).toFloat() - translationX = - if (isOn) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() - else 0f + // A soft lift, as on iOS: the knob reads as a part that slides. + elevation = toPx(2).toFloat() } return FrameLayout(context).apply { @@ -883,6 +882,26 @@ internal class SettingsPanelController( updateAccessibilityState(isOn) addView(knob) + // The knob's travel from the laid-out pixels, not a dp constant: + // rounded separately, the constants left the two ends a pixel apart. + fun travel(): Float = (width - paddingLeft - paddingRight - knob.width).coerceAtLeast(0).toFloat() + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (knob.animation == null && !knob.isPressed) { + knob.translationX = if (toggleState(seed)) travel() else 0f + } + } + // Pressed, the knob stretches a little toward where it will go. + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + knob.pivotX = if (toggleState(seed)) knob.width.toFloat() else 0f + knob.animate().scaleX(KNOB_PRESS_STRETCH).setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS).start() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + knob.animate().scaleX(1f).setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS).start() + } + false + } setOnClickListener { onFeedback() val next = !toggleState(seed) @@ -890,12 +909,10 @@ internal class SettingsPanelController( updateAccessibilityState(next) onChanged(next) - val knobView = getChildAt(0) - knobView.animate().cancel() - knobView.animate() - .translationX( - if (next) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() else 0f, - ) + knob.animate().cancel() + knob.animate() + .translationX(if (next) travel() else 0f) + .scaleX(1f) .setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS) .setInterpolator(DecelerateInterpolator(1.5f)) .start() @@ -960,6 +977,9 @@ internal class SettingsPanelController( ) private companion object { + /** How much a pressed switch knob widens (iOS-like). */ + const val KNOB_PRESS_STRETCH = 1.14f + /** A character pair each 模糊音 switch makes interchangeable. */ val FUZZY_EXAMPLES: Map = mapOf( FuzzyRule.Z_ZH to "资 zi · 知 zhi", 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 5b05bdd1..6dd6baa0 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -29,11 +29,12 @@ internal object ImeGeometryTokens { const val SETTING_ROW_HEIGHT_DP = 56 const val TOOL_CARD_HEIGHT_DP = 80 const val VOICE_CONTROL_HEIGHT_DP = TOUCH_TARGET_DP - const val SWITCH_WIDTH_DP = 46 - const val SWITCH_HEIGHT_DP = 28 - const val SWITCH_KNOB_DP = 24 + // iOS switch geometry: a 51x31 track and a 27 knob, so the knob sits 2 + // from every edge, on and off alike. + const val SWITCH_WIDTH_DP = 51 + const val SWITCH_HEIGHT_DP = 31 + const val SWITCH_KNOB_DP = 27 const val SWITCH_PADDING_DP = 2 - const val SWITCH_KNOB_TRAVEL_DP = SWITCH_WIDTH_DP - SWITCH_PADDING_DP * 2 - SWITCH_KNOB_DP const val KEY_GAP_DP = 6 const val KEY_ROW_GAP_DP = KEY_GAP_DP const val KEY_SIDE_MARGIN_DP = 2 From 6abf2c46c67adc94ef27cb08045b5cdf43544f1b Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:36:43 +0800 Subject: [PATCH 09/12] New IME brand mark: white tile, blue text-cursor I, ink ME One mark for the launcher, the in-app header, the splash screen and the README images, plus a monochrome layer for themed icons. The launcher layer draws the mark at 3/4 scale so it sits like the system icons inside the 72dp mask. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + app/src/main/res/drawable/ic_brand_mark.xml | 15 +++- .../res/drawable/ic_launcher_foreground.xml | 16 +++- .../res/drawable/ic_launcher_monochrome.xml | 16 ++++ .../main/res/mipmap-anydpi/ic_launcher.xml | 2 +- .../res/mipmap-anydpi/ic_launcher_round.xml | 2 +- app/src/main/res/values-night/colors.xml | 6 +- app/src/main/res/values/colors.xml | 8 +- docs/images/openime-brand.png | Bin 1073 -> 13036 bytes docs/images/social-preview.png | Bin 10153 -> 17835 bytes scripts/generate_brand_assets.py | 74 +++++++++--------- 11 files changed, 89 insertions(+), 51 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index cb26c0fc..2a897cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 符号大幅扩充:中文标点、特殊符号(星、心、勾叉、音乐、天气、星座等)、颜文字增加到 100 多个,新增“拼音”(带声调字母)、“日文”(平假名、片假名)、“注音”、“制表”(制表线和方块)分类。 - 修复颜文字在符号面板里显示不全:较长的符号改用每行 3 个的宽格子。 - 切换键盘的五个图标统一成同一种样式(与工具栏的键盘图标同一个外框),工具页和切换键盘页的图标大小一致。 +- 新的应用图标:白底上的“IME”,I 是蓝色的文字光标,ME 为深色。桌面图标、App 首页标志、启动画面和 README 图片用同一个标志;Android 13 以上支持主题图标(单色层)。 - 开关按 iOS 的比例重做(51×31,圆钮 27):圆钮离四边都是 2dp,开和关两种状态完全对称,带一层淡阴影,按住时朝移动方向轻轻拉长。之前关闭时圆钮贴着左边、打开时离右边 4dp。整个应用的开关都是这一个组件。 - 修复模糊音页里关着的子开关仍显示蓝色底。 - 构建工具升级(Gradle 8.14、Android Gradle 插件 8.13、Kotlin 2.4)。 diff --git a/app/src/main/res/drawable/ic_brand_mark.xml b/app/src/main/res/drawable/ic_brand_mark.xml index 6046cecb..45dad94c 100644 --- a/app/src/main/res/drawable/ic_brand_mark.xml +++ b/app/src/main/res/drawable/ic_brand_mark.xml @@ -1,4 +1,11 @@ - - - - \ No newline at end of file + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index fc620fcf..60f45dcd 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,10 +1,18 @@ + + - + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..b6ebe96c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml index 5c84730c..c78bee3b 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -2,5 +2,5 @@ - + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 5c84730c..c78bee3b 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -2,5 +2,5 @@ - + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index eb83874c..83124e66 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -28,7 +28,11 @@ #2A2C31 #3A3D43 #2A2C31 - #2D6FD6 #2A2C31 #6B717C + + #1668D0 + #15171C + #FFFFFF + #3A3F4A diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7404afec..7bd6eb1f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -29,8 +29,12 @@ #ECEEF1 #D5D9DF #EEF0F3 - #1668D0 - #1D9BF0 #E8E9ED #8A909B + + #1668D0 + #15171C + #FFFFFF + #E2E6ED + #FFFFFF diff --git a/docs/images/openime-brand.png b/docs/images/openime-brand.png index c43c509cbf3e3a56275d3ac66cc17a45f19e60b0..fde7e86ff599dc20d7a185ad1e43d268e407701e 100644 GIT binary patch literal 13036 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 DoUiBm literal 1073 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�<}HBAdN? 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 R3!IlmMM>lKhhNPC{}1Sx!f^lq literal 10153 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

+1%$rmSPU3zr)^pKV^GcvBcrw*NiY=65U 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 NoRa#PR0WfJ{{=t;2_FCe diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py index 5f087dae..adebd204 100755 --- a/scripts/generate_brand_assets.py +++ b/scripts/generate_brand_assets.py @@ -11,41 +11,39 @@ ROOT = Path(__file__).resolve().parents[1] OUTPUT = ROOT / "docs" / "images" -ACCENT = "#6EC3F7" -DARK = "#1C1C1E" -TEXT = "#F2F2F7" - - -def draw_mark(image: Image.Image, box: tuple[int, int, int, int], fill, cutout) -> None: - draw = ImageDraw.Draw(image) +# One brand, as in app/src/main/res: the app accent, ink, and a white tile. +BLUE = "#1668D0" +INK = "#15171C" +TILE = "#FFFFFF" +TILE_EDGE = "#E2E6ED" +PAGE = "#F4F6FA" +SUPERSAMPLE = 4 + + +def draw_mark(image: Image.Image, box: tuple[int, int, int, int]) -> None: + """The IME mark on its white tile, drawn from the 108-unit grid of + ic_launcher_foreground.xml: the I is a text cursor in blue, ME in ink.""" x0, y0, x1, y1 = box - scale = (x1 - x0) / 48.0 - - def point(x: float, y: float) -> tuple[float, float]: - return x0 + x * scale, y0 + y * scale - - draw.rounded_rectangle( - [*point(4, 4), *point(44, 44)], - radius=11 * scale, - fill=fill, - ) - draw.polygon( - [ - point(17, 11), - point(31, 11), - point(31, 14), - point(26, 14), - point(26, 34), - point(31, 34), - point(31, 37), - point(17, 37), - point(17, 34), - point(22, 34), - point(22, 14), - point(17, 14), - ], - fill=cutout, - ) + size = x1 - x0 + big = Image.new("RGBA", (size * SUPERSAMPLE, size * SUPERSAMPLE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(big) + unit = size * SUPERSAMPLE / 108.0 + + def p(x: float, y: float) -> tuple[float, float]: + return x * unit, y * unit + + draw.rounded_rectangle([*p(2, 2), *p(106, 106)], radius=26 * unit, fill=TILE, outline=TILE_EDGE, width=max(1, round(1.5 * unit))) + # I: stem and two serifs with 1.5-unit corners. + draw.rectangle([*p(31, 41), *p(36, 67)], fill=BLUE) + draw.rounded_rectangle([*p(27, 41), *p(40, 45)], radius=1.5 * unit, fill=BLUE) + draw.rounded_rectangle([*p(27, 63), *p(40, 67)], radius=1.5 * unit, fill=BLUE) + # M and E. + draw.polygon([p(*xy) for xy in [(44, 67), (44, 41), (50, 41), (54, 52), (58, 41), (64, 41), (64, 67), (58, 67), + (58, 53), (55.5, 60), (52.5, 60), (50, 53), (50, 67)]], fill=INK) + draw.polygon([p(*xy) for xy in [(68, 41), (82, 41), (82, 47), (74, 47), (74, 51), (80, 51), (80, 57), (74, 57), + (74, 61), (82, 61), (82, 67), (68, 67)]], fill=INK) + mark = big.resize((size, size), Image.LANCZOS) + image.paste(mark, (x0, y0), mark) def load_font(size: int) -> ImageFont.ImageFont: @@ -63,20 +61,20 @@ def load_font(size: int) -> ImageFont.ImageFont: def generate_brand_png() -> None: image = Image.new("RGBA", (288, 288), (0, 0, 0, 0)) - draw_mark(image, (0, 0, 288, 288), ACCENT, (0, 0, 0, 0)) + draw_mark(image, (0, 0, 288, 288)) image.save(OUTPUT / "openime-brand.png", optimize=True) def generate_social_preview() -> None: - image = Image.new("RGB", (1280, 640), DARK) - draw_mark(image, (520, 105, 760, 345), ACCENT, DARK) + image = Image.new("RGB", (1280, 640), PAGE) + draw_mark(image, (520, 105, 760, 345)) draw = ImageDraw.Draw(image) label = "openIME" font = load_font(92) bounds = draw.textbbox((0, 0), label, font=font) width = bounds[2] - bounds[0] - draw.text(((1280 - width) / 2, 400), label, font=font, fill=TEXT) + draw.text(((1280 - width) / 2, 400), label, font=font, fill=INK) image.save(OUTPUT / "social-preview.png", optimize=True) From c88ba36867d600b40bff45f9a7a3cc480b2b26b7 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:56:05 +0800 Subject: [PATCH 10/12] Preferences and home share one grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icons centre on their row; 音效, 震动手感 and 震动强度 nest under their switch instead of repeating its icon; switches, chevron glyphs and buttons end on the 16dp line; slider tracks span the text column; row padding is even. Home step marks, the voice icon and the ready mark are all 32dp so the cards share one text column. A geometry test pins the rules. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../PreferencesGeometryInstrumentedTest.kt | 75 +++++++++++++++++++ .../openime/panel/SettingsPanelController.kt | 64 +++++++++++----- .../slacker/openime/theme/ImeDesignTokens.kt | 8 +- app/src/main/res/layout/activity_main.xml | 26 +++---- app/src/main/res/raw/keep.xml | 3 + app/src/main/res/values/dimens.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../llc/slacker/openime/TokenDriftTest.kt | 2 +- docs/APP_UI_SPEC.md | 2 +- 10 files changed, 147 insertions(+), 38 deletions(-) create mode 100644 app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt create mode 100644 app/src/main/res/raw/keep.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a897cf2..f630e483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 新的应用图标:白底上的“IME”,I 是蓝色的文字光标,ME 为深色。桌面图标、App 首页标志、启动画面和 README 图片用同一个标志;Android 13 以上支持主题图标(单色层)。 - 开关按 iOS 的比例重做(51×31,圆钮 27):圆钮离四边都是 2dp,开和关两种状态完全对称,带一层淡阴影,按住时朝移动方向轻轻拉长。之前关闭时圆钮贴着左边、打开时离右边 4dp。整个应用的开关都是这一个组件。 - 修复模糊音页里关着的子开关仍显示蓝色底。 +- 偏好设置和首页对齐重做:每行的图标都在整行里上下居中;“音效”“震动手感”“震动强度”是上一行开关的子设置,不再重复放同一个图标,改为缩进到父开关的文字下面;开关、箭头、按钮的右边缘落在同一条线上;滑块轨道两端对齐标题和百分比;每行上下留白一致。首页的步骤圆圈、语音图标和“已就绪”标记统一为 32dp,几张卡片的文字列对齐;标题区不再比卡片多缩进 4dp。新增偏好页几何测试,锁住这些规则。 - 构建工具升级(Gradle 8.14、Android Gradle 插件 8.13、Kotlin 2.4)。 ## [0.0.6-beta.1] - 2026-10-05 diff --git a/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt new file mode 100644 index 00000000..a434c95f --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt @@ -0,0 +1,75 @@ +package llc.slacker.openime + +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.test.ext.junit.runners.AndroidJUnit4 +import llc.slacker.openime.theme.ImeGeometryTokens +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.abs + +/** + * The preferences page keeps one grid: icon tiles centred on their row, every + * label on one text column, and every trailing control ending on one line. + */ +@RunWith(AndroidJUnit4::class) +class PreferencesGeometryInstrumentedTest { + + @Test + fun rowsShareOneTextColumnAndOneTrailingEdge() { + DirectActivityHarness(ImeSettingsActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { activity -> + val root = activity.window.decorView + val rows = collect(root) { it.tag == "setting-row" }.filterIsInstance() + if (rows.size < 10 || rows.any { it.width == 0 }) return@awaitMain null + val density = activity.resources.displayMetrics.density + val slack = 1 + + val labelLefts = rows.flatMap { row -> collect(row) { it.tag == "setting-label" } }.map { left(it) } + assertTrue("Every label starts on one text column: $labelLefts", labelLefts.max() - labelLefts.min() <= slack) + val textColumn = labelLefts.min() + + rows.forEach { row -> + val trailingEdge = left(row) + row.width - row.paddingRight + collect(row) { it.tag == "setting-icon" }.forEach { icon -> + val iconCentre = top(icon) + icon.height / 2 + val rowCentre = top(row) + row.height / 2 + assertTrue("Icon is centred on its row (${row.contentDescription})", abs(iconCentre - rowCentre) <= slack) + } + collect(row) { it.tag == "toggle" }.forEach { toggle -> + assertTrue("Switch ends on the row's 16dp line", abs(left(toggle) + toggle.width - trailingEdge) <= slack) + } + collect(row) { it.tag == "setting-chevron" }.forEach { chevron -> + val glyphEnd = left(chevron) + chevron.width - (ImeGeometryTokens.CHEVRON_GLYPH_END_INSET_DP * density).toInt() + assertTrue("Chevron glyph ends on the row's 16dp line", abs(glyphEnd - trailingEdge) <= slack + 1) + } + collect(row) { it is SeekBar }.forEach { bar -> + val trackStart = left(bar) + bar.paddingLeft + val trackEnd = left(bar) + bar.width - bar.paddingRight + assertTrue("Slider track starts on the text column", abs(trackStart - textColumn) <= slack) + assertTrue("Slider track ends on the row's 16dp line", abs(trackEnd - trailingEdge) <= slack) + } + } + true + } + } + } + + private fun left(view: View): Int = IntArray(2).also { view.getLocationInWindow(it) }[0] + + private fun top(view: View): Int = IntArray(2).also { view.getLocationInWindow(it) }[1] + + private fun collect(root: View, match: (View) -> Boolean): List { + val found = mutableListOf() + fun walk(view: View) { + if (view.visibility != View.VISIBLE) return + if (match(view)) found += view + if (view is ViewGroup) for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + walk(root) + return found + } +} 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 7eaae82b..9438149d 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -186,10 +186,10 @@ internal class SettingsPanelController( content.addSection("按键与输入") content.addCard( toggleRow("按键音效", "按键时播放提示音", R.drawable.ic_pref_sound), - soundStyleRow(R.drawable.ic_pref_sound), + soundStyleRow(NESTED_ROW), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), - hapticStyleRow(R.drawable.ic_pref_haptic), - sliderRow("震动强度", R.drawable.ic_pref_haptic, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), + hapticStyleRow(NESTED_ROW), + sliderRow("震动强度", NESTED_ROW, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble), toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up), @@ -414,19 +414,25 @@ internal class SettingsPanelController( // ---- Rows --------------------------------------------------------------- - /** Row shell: an optional icon tile, then [body] filling the rest. */ - private fun rowShell(iconRes: Int, alignTop: Boolean = false): LinearLayout = + /** + * Row shell: an optional icon tile, then the body filling the rest. The tile + * is centred on the whole row, whatever sits under the label. [NESTED_ROW] + * leaves the tile out and starts the body on the text column, for settings + * that refine the row above (音效 under 按键音效). + */ + private fun rowShell(iconRes: Int): LinearLayout = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - gravity = if (alignTop) Gravity.TOP else Gravity.CENTER_VERTICAL + gravity = Gravity.CENTER_VERTICAL tag = "setting-row" minimumHeight = toPx(if (standalone) ImeGeometryTokens.SETTING_ROW_HEIGHT_DP else 52) + val nestedInset = if (iconRes == NESTED_ROW) toPx(ICON_TILE_DP + ICON_TILE_GAP_DP) else 0 if (standalone) { - setPadding(toPx(16), toPx(12), toPx(16), toPx(12)) + setPadding(toPx(16) + nestedInset, toPx(12), toPx(16), toPx(12)) } else { setPadding(toPx(14), toPx(2), toPx(12), toPx(2)) } - if (iconRes != 0) { + if (iconRes != 0 && iconRes != NESTED_ROW) { addView( ImageView(context).apply { setImageResource(iconRes) @@ -435,7 +441,7 @@ internal class SettingsPanelController( importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO tag = "setting-icon" }, - LinearLayout.LayoutParams(toPx(32), toPx(32)).apply { marginEnd = toPx(12) }, + LinearLayout.LayoutParams(toPx(ICON_TILE_DP), toPx(ICON_TILE_DP)).apply { marginEnd = toPx(ICON_TILE_GAP_DP) }, ) } } @@ -525,7 +531,10 @@ internal class SettingsPanelController( importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO tag = "setting-chevron" }, - LinearLayout.LayoutParams(toPx(18), toPx(18)).apply { marginStart = toPx(ImeSpacingTokens.MD_DP) }, + LinearLayout.LayoutParams(toPx(ImeGeometryTokens.CHEVRON_DP), toPx(ImeGeometryTokens.CHEVRON_DP)).apply { + marginStart = toPx(ImeSpacingTokens.MD_DP) + marginEnd = -toPx(ImeGeometryTokens.CHEVRON_GLYPH_END_INSET_DP) + }, ) } @@ -589,11 +598,11 @@ internal class SettingsPanelController( overScrollMode = View.OVER_SCROLL_NEVER addView(chips) } - val row = rowShell(iconRes, alignTop = true) + val row = rowShell(iconRes) row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - addView(rowLabel(label), wrapParams().apply { topMargin = toPx(if (iconRes != 0) 6 else 8) }) + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(if (standalone) 0 else 8) }) addView( scroller, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP)).apply { @@ -648,12 +657,16 @@ internal class SettingsPanelController( ) } } - val row = rowShell(iconRes, alignTop = standalone) + val row = rowShell(iconRes) if (standalone) { + // The track lines up with the label and the value; the thumb's outer + // half overhangs the text column at 0% and 100%. + row.clipChildren = false + row.clipToPadding = false row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - addView(rowLabel(label), wrapParams().apply { topMargin = toPx(6) }) + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(3) }) addView( track, LinearLayout.LayoutParams( @@ -742,8 +755,12 @@ internal class SettingsPanelController( seekBar.contentDescription = initialDescription if (Build.VERSION.SDK_INT >= 30) seekBar.stateDescription = initialDescription - val row = rowShell(iconRes, alignTop = standalone) + val row = rowShell(iconRes) if (standalone) { + // The track lines up with the label and the value; the thumb's outer + // half overhangs the text column at 0% and 100%. + row.clipChildren = false + row.clipToPadding = false row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -754,18 +771,21 @@ internal class SettingsPanelController( addView(rowLabel(label), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) addView(valueView, wrapParams()) }, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { - topMargin = toPx(6) - }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT), ) addView( seekBar, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(40)), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(40)).apply { + marginStart = -thumbInset + marginEnd = -thumbInset + // The bar's empty lower half hangs into the row padding, so + // the padding stays even and the icon sits on the row's centre. + bottomMargin = -toPx(6) + }, ) }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), ) - row.setPadding(row.paddingLeft, row.paddingTop, row.paddingRight, toPx(4)) } else { row.addView(rowLabel(shortLabel), LinearLayout.LayoutParams(toPx(64), LinearLayout.LayoutParams.WRAP_CONTENT)) row.addView(seekBar, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f)) @@ -977,6 +997,10 @@ internal class SettingsPanelController( ) private companion object { + /** rowShell icon value for a row that refines the one above: no tile, body on the text column. */ + const val NESTED_ROW = -1 + const val ICON_TILE_DP = 32 + const val ICON_TILE_GAP_DP = 12 /** How much a pressed switch knob widens (iOS-like). */ const val KNOB_PRESS_STRETCH = 1.14f 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 6dd6baa0..65cd1846 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -35,6 +35,11 @@ internal object ImeGeometryTokens { const val SWITCH_HEIGHT_DP = 31 const val SWITCH_KNOB_DP = 27 const val SWITCH_PADDING_DP = 2 + // ic_chevron_right at 18dp: the stroke ends 6dp short of the box's right + // edge, so rows pull the box out by that much and the glyph ends on the + // same 16dp line as switches and values. + const val CHEVRON_DP = 18 + const val CHEVRON_GLYPH_END_INSET_DP = 6 const val KEY_GAP_DP = 6 const val KEY_ROW_GAP_DP = KEY_GAP_DP const val KEY_SIDE_MARGIN_DP = 2 @@ -48,7 +53,8 @@ internal object ImeGeometryTokens { const val TOOLBAR_HEIGHT_DP = TOUCH_TARGET_DP const val TOP_BAR_HEIGHT_DP = 48 const val ICON_SIZE_DP = 24 - const val STEP_MARK_SIZE_DP = 28 + // Same as a settings icon tile, so step text lines up with the rows below. + const val STEP_MARK_SIZE_DP = 32 const val HERO_MARK_SIZE_DP = 72 const val COMPOSED_TOP_ZONE_HEIGHT_DP = 70 } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1c746bd5..f94247c1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -22,7 +22,7 @@ - + @@ -35,23 +35,23 @@ - + - + - + - + - + @@ -62,7 +62,7 @@ - + @@ -78,8 +78,8 @@ - - + + @@ -99,16 +99,16 @@ - + - + - + - + diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..43a12a94 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 04b5f1b3..3e4e9e10 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -23,7 +23,7 @@ 48dp 56dp 24dp - 28dp + 32dp 16dp 72dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 260d9553..8c2a175b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,7 +38,7 @@ 语音输入 麦克风 - 允许麦克风后,按住空格说话,离线识别 + 允许麦克风后,按住空格说话 按住空格说话,松开后识别 麦克风权限已被拒绝,请在系统设置中允许 允许 diff --git a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt index ce34024a..51186152 100644 --- a/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt +++ b/app/src/test/java/llc/slacker/openime/TokenDriftTest.kt @@ -81,7 +81,7 @@ class TokenDriftTest { """48dp""", """56dp""", """24dp""", - """28dp""", + """32dp""", """16dp""", """72dp""", ).forEach { expected -> diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md index 92a44c72..e06a99a7 100644 --- a/docs/APP_UI_SPEC.md +++ b/docs/APP_UI_SPEC.md @@ -52,7 +52,7 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 | 分组标题 | 13sp,左缩进 16dp | 距上一组 28dp,距卡片 8dp | | 分组卡片 | 圆角 16dp,无阴影 | 一组设置放在同一张卡里,行与行之间用 1dp 分隔线 | | 分隔线缩进 | 60dp(有图标)/ 56dp(步骤)/ 14dp(键盘内) | 从行文字起点开始,不贯穿图标 | -| 设置行 | 至少 56dp 高,内边距 16/12dp | 图标块 32dp(圆角 9dp,图标 18dp),与文字间隔 12dp | +| 设置行 | 至少 56dp 高,左右内边距都是 16dp | 图标块 32dp(圆角 9dp,图标 18dp),与文字间隔 12dp;开关、数值、按钮和箭头图形的右边缘都落在 16dp 线上(箭头图标框右移 6dp 抵掉自带留白);滑块轨道两端对齐文字列 | | 行文字 | 标签 15sp 中粗,说明 13sp | 标签与说明间隔 4dp;说明允许换行 | | 文字到末端控件 | 至少 12dp | 开关、箭头、数值右对齐 | | 分段选项(偏好设置) | 标签在上,轨道在下占满行宽 | 48dp 点击高度,34dp 可见轨道,选中项 30dp 高 | From ecdad61bfba194d4c5d61688087b821722292eb0 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:00:25 +0800 Subject: [PATCH 11/12] Hairlines start on the text of the row above Icon-less rows (the fuzzy rules) had hairlines inset for an icon they do not have. The geometry test now walks the fuzzy page too. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../PreferencesGeometryInstrumentedTest.kt | 34 +++++++++++++++++++ .../openime/panel/SettingsPanelController.kt | 10 +++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f630e483..943d77da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 新的应用图标:白底上的“IME”,I 是蓝色的文字光标,ME 为深色。桌面图标、App 首页标志、启动画面和 README 图片用同一个标志;Android 13 以上支持主题图标(单色层)。 - 开关按 iOS 的比例重做(51×31,圆钮 27):圆钮离四边都是 2dp,开和关两种状态完全对称,带一层淡阴影,按住时朝移动方向轻轻拉长。之前关闭时圆钮贴着左边、打开时离右边 4dp。整个应用的开关都是这一个组件。 - 修复模糊音页里关着的子开关仍显示蓝色底。 -- 偏好设置和首页对齐重做:每行的图标都在整行里上下居中;“音效”“震动手感”“震动强度”是上一行开关的子设置,不再重复放同一个图标,改为缩进到父开关的文字下面;开关、箭头、按钮的右边缘落在同一条线上;滑块轨道两端对齐标题和百分比;每行上下留白一致。首页的步骤圆圈、语音图标和“已就绪”标记统一为 32dp,几张卡片的文字列对齐;标题区不再比卡片多缩进 4dp。新增偏好页几何测试,锁住这些规则。 +- 偏好设置和首页对齐重做:每行的图标都在整行里上下居中;“音效”“震动手感”“震动强度”是上一行开关的子设置,不再重复放同一个图标,改为缩进到父开关的文字下面;开关、箭头、按钮的右边缘落在同一条线上;滑块轨道两端对齐标题和百分比;每行上下留白一致。首页的步骤圆圈、语音图标和“已就绪”标记统一为 32dp,几张卡片的文字列对齐;标题区不再比卡片多缩进 4dp。模糊音页等没有图标的行,分隔线改为从文字起点开始(之前比文字多缩进 44dp)。新增偏好页和模糊音页的几何测试,锁住这些规则。 - 构建工具升级(Gradle 8.14、Android Gradle 插件 8.13、Kotlin 2.4)。 ## [0.0.6-beta.1] - 2026-10-05 diff --git a/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt index a434c95f..7ac587fc 100644 --- a/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/PreferencesGeometryInstrumentedTest.kt @@ -53,11 +53,45 @@ class PreferencesGeometryInstrumentedTest { assertTrue("Slider track ends on the row's 16dp line", abs(trackEnd - trailingEdge) <= slack) } } + assertHairlinesStartOnText(root) true } } } + @Test + fun fuzzyPageHairlinesStartOnTheRuleText() { + DirectActivityHarness(ImeSettingsActivity::class.java).use { harness -> + harness.launch() + harness.awaitMain { activity -> + val entry = collect(activity.window.decorView) { it.tag == "setting-row" && it.contentDescription?.startsWith("模糊音与智能纠错") == true } + .firstOrNull() ?: return@awaitMain null + entry.performClick() + true + } + harness.awaitMain { activity -> + val root = activity.window.decorView + val hairlines = collect(root) { it.tag == "row-hairline" } + if (hairlines.size < 8 || hairlines.any { it.width == 0 }) return@awaitMain null + assertHairlinesStartOnText(root) + true + } + } + } + + /** Each hairline starts where the text of the row above it starts. */ + private fun assertHairlinesStartOnText(root: View) { + collect(root) { it.tag == "row-hairline" }.forEach { line -> + val card = line.parent as ViewGroup + val above = card.getChildAt(card.indexOfChild(line) - 1) + val label = collect(above) { it.tag == "setting-label" }.firstOrNull() ?: return@forEach + assertTrue( + "Hairline under ${(above as? ViewGroup)?.contentDescription} starts on its text", + abs(left(line) - left(label)) <= 1, + ) + } + } + private fun left(view: View): Int = IntArray(2).also { view.getLocationInWindow(it) }[0] private fun top(view: View): Int = IntArray(2).also { view.getLocationInWindow(it) }[1] 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 9438149d..8be5e219 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -395,7 +395,7 @@ internal class SettingsPanelController( importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(1).coerceAtLeast(1)).apply { - marginStart = toPx(if (standalone) 60 else 14) + marginStart = textStart(rows[index - 1]) }, ) } @@ -412,6 +412,14 @@ internal class SettingsPanelController( ) } + /** Where [row]'s text starts: its padding, plus the icon tile when it has one. */ + private fun textStart(row: View): Int { + val group = row as? ViewGroup ?: return 0 + val icon = (0 until group.childCount).map { group.getChildAt(it) }.firstOrNull { it.tag == "setting-icon" } + val iconSpan = icon?.let { it.layoutParams.width + ((it.layoutParams as? ViewGroup.MarginLayoutParams)?.marginEnd ?: 0) } ?: 0 + return group.paddingLeft + iconSpan + } + // ---- Rows --------------------------------------------------------------- /** From 91f8b381f05a2f7094a9fdbf8dc99941bbb4d027 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:27:55 +0800 Subject: [PATCH 12/12] Date the 0.0.7-beta.1 changelog 2026-10-06 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 943d77da..d1663c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] -## [0.0.7-beta.1] - 2026-10-05 +## [0.0.7-beta.1] - 2026-10-06 第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。