diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..a6073807 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Review requests go to the maintainer automatically. Code-owner approval is not +# required yet (single maintainer); see docs/REPOSITORY.md before turning that on. +* @Slacker-LLC diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..04a82796 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Weekly dependency checks. Minor and patch updates are grouped into one pull +# request per ecosystem so they do not pile up; every PR still has to pass the +# normal CI. See docs/REPOSITORY.md. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 2 + labels: [dependencies, ci] + commit-message: + prefix: "ci" + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: gradle + directories: ["/", "/app"] + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 2 + labels: [dependencies] + commit-message: + prefix: "build" + groups: + gradle-minor-and-patch: + update-types: [minor, patch] + ignore: + # AGP, Kotlin and Gradle majors move together with the pinned NDK/SDK and are upgraded by hand. + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0504ace1..1b078e9f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -21,6 +21,12 @@ 测试设备、Android 版本和命令: +## 变更记录与版本 + +- [ ] 用户可见的改动(功能、行为、修复、权限、数据格式)已写入 `CHANGELOG.md` 的 `[Unreleased]`;纯内部改动不需要 +- [ ] 没有修改 `VERSION`(只有发布 PR 才升级版本,见 `docs/RELEASE.md`) +- [ ] PR 标题能独立说清结果:合并时它就是提交标题,描述就是提交正文 + ## 隐私与交付检查 - [ ] 没有提交密码、剪贴板、录音、设备日志或个人路径 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c6927ff3..7f5943ba 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -26,6 +26,14 @@ jobs: with: lfs: true + # Fails in seconds, before the toolchain is set up: VERSION is valid and + # CHANGELOG.md has the matching dated section (docs/RELEASE.md). + - name: Check version and changelog + shell: bash + run: | + python3 -m unittest discover -s scripts -p 'test_*.py' + python3 scripts/release_check.py check + - name: Set up JDK 17 uses: actions/setup-java@v6 with: @@ -80,6 +88,12 @@ jobs: fi exit $status + - name: Verify APK version identity + shell: bash + run: | + python3 scripts/release_check.py apk app/build/outputs/apk/debug/app-debug.apk \ + --aapt2 "$ANDROID_HOME/build-tools/35.0.0/aapt2" + - name: Upload test APKs uses: actions/upload-artifact@v7 with: @@ -99,7 +113,7 @@ jobs: strategy: fail-fast: false matrix: - api-level: [29, 31] + api-level: [26, 29, 31, 34] steps: - name: Enable KVM group perms @@ -134,9 +148,16 @@ jobs: test -n "$test_apk" adb install -r "$app_apk" adb install -r "$test_apk" + report="ci-reports/instrumentation-api-${api_level}.txt" adb shell am instrument -w -r \ llc.slacker.openime.test/androidx.test.runner.AndroidJUnitRunner \ - | tee "ci-reports/instrumentation-api-${api_level}.txt" + | tee "$report" + # 'am instrument' exits 0 even when tests fail; a clean run ends with "OK (N tests)". + if ! grep -aq '^OK (' "$report"; then + echo "::error::Instrumentation tests failed on API ${api_level}" + grep -a -B1 -A12 'INSTRUMENTATION_STATUS_CODE: -[12]' "$report" | head -120 || true + exit 1 + fi EOF chmod +x ci-run-instrumentation.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5575bf61..45ae6381 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,24 +1,86 @@ name: Android Release +# A release is a signed arm64 APK built from a vX.Y.Z tag on main. The same build +# and verification script (scripts/release_build.sh) is rehearsed with a throwaway +# key on manual runs and on pull requests that touch the release pipeline, so the +# first real release is not the first time it runs. See docs/RELEASE.md. on: push: tags: - - 'v*' + - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + pull_request: + branches: [main] + paths: + - '.github/workflows/release.yml' + - 'scripts/release_build.sh' + - 'scripts/release_check.py' + - 'VERSION' + - 'CHANGELOG.md' + - 'app/build.gradle.kts' permissions: - contents: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} jobs: - release: - name: Build signed arm64 release + # Holds the signing key but cannot write to the repository. + build: + name: ${{ github.ref_type == 'tag' && 'Build signed arm64 release' || 'Release rehearsal (throwaway key)' }} runs-on: ubuntu-latest timeout-minutes: 60 + permissions: + contents: read + checks: read steps: - name: Checkout source and LFS files uses: actions/checkout@v7 with: lfs: true + fetch-depth: 0 + + - name: Release tag is on main and its CI passed + if: github.ref_type == 'tag' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_REF_NAME must point at a commit on main" + exit 1 + fi + + required=("Build and verify" "Compatibility API 29" "Compatibility API 31") + deadline=$((SECONDS + 2400)) + while true; do + runs="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs?per_page=100" --jq '.check_runs')" + waiting=0 + for name in "${required[@]}"; do + latest="$(jq -c --arg n "$name" '[.[] | select(.name == $n)] | sort_by(.started_at) | last // {}' <<<"$runs")" + conclusion="$(jq -r '.conclusion // "pending"' <<<"$latest")" + case "$conclusion" in + success) ;; + pending) waiting=1 ;; + *) + echo "::error::CI check '$name' ended as '$conclusion' for $GITHUB_SHA" + exit 1 + ;; + esac + done + [[ "$waiting" -eq 0 ]] && break + if (( SECONDS >= deadline )); then + echo "::error::CI for $GITHUB_SHA did not finish within 40 minutes" + exit 1 + fi + echo "Waiting for CI on $GITHUB_SHA ..." + sleep 30 + done + echo "CI passed for $GITHUB_SHA" - name: Set up JDK 17 uses: actions/setup-java@v6 @@ -57,7 +119,10 @@ jobs: shell: bash run: chmod +x gradlew - - name: Validate signing secrets + # Only this step ever reads the signing secrets, and only for tags. Pull + # requests and manual runs sign with a key that exists for this job alone. + - name: Use the release signing key + if: github.ref_type == 'tag' shell: bash env: OPENIME_KEYSTORE_B64: ${{ secrets.OPENIME_KEYSTORE_B64 }} @@ -65,115 +130,122 @@ jobs: OPENIME_KEY_ALIAS: ${{ secrets.OPENIME_KEY_ALIAS }} OPENIME_KEY_PASSWORD: ${{ secrets.OPENIME_KEY_PASSWORD }} run: | - for name in \ - OPENIME_KEYSTORE_B64 \ - OPENIME_KEYSTORE_PASSWORD \ - OPENIME_KEY_ALIAS \ - OPENIME_KEY_PASSWORD - do + for name in OPENIME_KEYSTORE_B64 OPENIME_KEYSTORE_PASSWORD OPENIME_KEY_ALIAS OPENIME_KEY_PASSWORD; do if [[ -z "${!name}" ]]; then - echo "Missing required release signing secret: ${name}" >&2 + echo "::error::Missing release signing secret ${name}. Run scripts/setup_release_signing.sh (docs/RELEASE.md)." exit 1 fi done - - - name: Decode release keystore - shell: bash - env: - OPENIME_KEYSTORE_B64: ${{ secrets.OPENIME_KEYSTORE_B64 }} - run: | - printf '%s' "$OPENIME_KEYSTORE_B64" \ - | base64 --decode \ - > "$RUNNER_TEMP/openime-release.jks" + # These three go to GITHUB_ENV as NAME=value lines; a newline would inject more. + for name in OPENIME_KEYSTORE_PASSWORD OPENIME_KEY_ALIAS OPENIME_KEY_PASSWORD; do + if [[ "${!name}" == *$'\n'* ]]; then + echo "::error::${name} must be a single line" + exit 1 + fi + done + printf '%s' "$OPENIME_KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/openime-release.jks" chmod 600 "$RUNNER_TEMP/openime-release.jks" - - - name: Run tests, lint, and release build + { + echo "OPENIME_KEYSTORE_PATH=$RUNNER_TEMP/openime-release.jks" + echo "OPENIME_KEYSTORE_PASSWORD=$OPENIME_KEYSTORE_PASSWORD" + echo "OPENIME_KEY_ALIAS=$OPENIME_KEY_ALIAS" + echo "OPENIME_KEY_PASSWORD=$OPENIME_KEY_PASSWORD" + echo "OPENIME_RELEASE_TAG=$GITHUB_REF_NAME" + } >> "$GITHUB_ENV" + + - name: Use a throwaway rehearsal key + if: github.ref_type != 'tag' shell: bash - env: - OPENIME_KEYSTORE_PATH: ${{ runner.temp }}/openime-release.jks - OPENIME_KEYSTORE_PASSWORD: ${{ secrets.OPENIME_KEYSTORE_PASSWORD }} - OPENIME_KEY_ALIAS: ${{ secrets.OPENIME_KEY_ALIAS }} - OPENIME_KEY_PASSWORD: ${{ secrets.OPENIME_KEY_PASSWORD }} run: | - ./gradlew \ - :app:testDebugUnitTest \ - :app:lintRelease \ - :app:assembleRelease \ - --no-daemon \ - --console=plain - - - name: Verify signed arm64 artifact - id: artifact + password="$(openssl rand -hex 16)" + echo "::add-mask::$password" + keytool -genkeypair -keystore "$RUNNER_TEMP/rehearsal.jks" -storetype PKCS12 \ + -alias rehearsal -keyalg RSA -keysize 2048 -validity 2 \ + -storepass "$password" -keypass "$password" \ + -dname "CN=openIME Rehearsal, O=CI throwaway" + { + echo "OPENIME_KEYSTORE_PATH=$RUNNER_TEMP/rehearsal.jks" + echo "OPENIME_KEYSTORE_PASSWORD=$password" + echo "OPENIME_KEY_ALIAS=rehearsal" + echo "OPENIME_KEY_PASSWORD=$password" + echo "OPENIME_REHEARSAL=1" + } >> "$GITHUB_ENV" + + - name: Test, lint, build and verify the release APK + id: release shell: bash - run: | - APK="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release*.apk' | head -n 1)" - if [[ -z "$APK" || ! -f "$APK" ]]; then - echo "Release APK not found" >&2 - exit 1 - fi - - "$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose "$APK" - - mapfile -t ABIS < <( - unzip -Z1 "$APK" \ - | awk -F/ '/^lib\/[^/]+\/[^/]+$/ {print $2}' \ - | sort -u - ) - if [[ "${#ABIS[@]}" -ne 1 || "${ABIS[0]}" != "arm64-v8a" ]]; then - echo "Release APK must contain only arm64-v8a; found: ${ABIS[*]:-none}" >&2 - exit 1 - fi - - VERSION_NAME="$( - sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' app/build.gradle.kts \ - | head -n 1 - )" - if [[ -z "$VERSION_NAME" ]]; then - echo "Unable to read versionName" >&2 - exit 1 - fi - if [[ "$GITHUB_REF_NAME" != "v$VERSION_NAME" ]]; then - echo "Tag $GITHUB_REF_NAME does not match versionName $VERSION_NAME" >&2 - exit 1 - fi - - OUTPUT="openIME-v${VERSION_NAME}-arm64-release.apk" - cp "$APK" "$OUTPUT" - sha256sum "$OUTPUT" > SHA256SUMS.txt - cp THIRD_PARTY_NOTICES.md THIRD_PARTY_NOTICES.release.md - echo "apk=$OUTPUT" >> "$GITHUB_OUTPUT" - echo "version=$VERSION_NAME" >> "$GITHUB_OUTPUT" + run: bash scripts/release_build.sh - - name: Create release notes + - name: Summary shell: bash run: | - cat > RELEASE_NOTES.md <<'EOF' - ## 更新内容 - - 此版本包含标签所对应提交中的 openIME 功能、稳定性与界面更新。 - - ## 已知问题 - - 首次安装时,完整 Rime 词典可能需要完成首次部署后才达到稳定候选表现。 - - 发布 APK 仅包含 arm64-v8a;模拟器继续使用 Debug 构建。 + { + echo "### openIME ${{ steps.release.outputs.version }}" + echo + echo "- APK: \`${{ steps.release.outputs.apk }}\`" + echo "- Signing certificate SHA-256: \`${{ steps.release.outputs.cert_sha256 }}\`" + if [[ "$GITHUB_REF_TYPE" != "tag" ]]; then + echo "- **Rehearsal only**: signed with a throwaway key, nothing is published." + fi + echo + echo '```' + cat "${{ steps.release.outputs.out_dir }}/SHA256SUMS.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload release files for publishing + if: github.ref_type == 'tag' + uses: actions/upload-artifact@v7 + with: + name: release-files + path: ${{ steps.release.outputs.out_dir }} + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + # Can write to the repository but never sees the signing key. + publish: + name: Publish GitHub Release + needs: build + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write - ## 校验方式 - 下载 APK 后,在同一目录执行: + steps: + - name: Download release files + uses: actions/download-artifact@v7 + with: + name: release-files + path: release-files - ```bash + - name: Verify checksums + shell: bash + run: | + cd release-files sha256sum -c SHA256SUMS.txt - ``` - - 校验通过后再安装。 - EOF - - name: Publish GitHub Release + # Created as a draft and only published once every asset is attached, so a + # failed upload never leaves a public release without its APK. + - name: Create draft, check assets, publish shell: bash env: GH_TOKEN: ${{ github.token }} run: | gh release create "$GITHUB_REF_NAME" \ - "${{ steps.artifact.outputs.apk }}" \ - SHA256SUMS.txt \ - THIRD_PARTY_NOTICES.release.md \ + release-files/openIME-*-arm64-release.apk \ + release-files/SHA256SUMS.txt \ + release-files/THIRD_PARTY_NOTICES.release.md \ + --repo "$GITHUB_REPOSITORY" \ --verify-tag \ + --draft \ --title "openIME $GITHUB_REF_NAME" \ - --notes-file RELEASE_NOTES.md + --notes-file release-files/RELEASE_NOTES.md + + count="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" + if [[ "$count" -ne 3 ]]; then + echo "::error::Expected 3 release assets, found $count; the draft was left unpublished." + exit 1 + fi + gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --latest diff --git a/.gitignore b/.gitignore index c70d5c04..417cf5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,9 +32,21 @@ artifacts/ !app/src/main/assets/emoji/ !app/src/main/assets/emoji/fluent/ !app/src/main/assets/emoji/fluent/*.png +!app/src/main/assets/emoji/reference/ +!app/src/main/assets/emoji/reference/*.png # Optional local test evidence (the canonical evidence in docs is curated) .local/ +# Design-reference captures and review renders produced by scripts/*.py +output/ + # Scratch files test_patch_probe.txt +__pycache__/ +*.pyc + +# Release signing material must never be committed +*.jks +*.keystore +*.p12 diff --git a/CHANGELOG.md b/CHANGELOG.md index bbac87d3..828c2307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,60 @@ # 更新记录 -## Unreleased - -- 修复九键方案未编译导致的 librime 静默降级:`luna_pinyin_simp` 与 `luna_pinyin_simp_fuzzy` 把字面代数规则写进了 `speller/algebra` 的 `__patch` 列表,librime 会把列表项当作补丁路径解析并报循环依赖,整个方案构建失败(部署日志 `4 success, 5 failure`),中文候选一直退回本地九键解码器。现改为在 `pinyin.yaml` 中定义命名的 `t9_transliteration` 节点并引用它。 -- `versionCode` 3 → 4:rime 数据部署标记随版本号变化,让已安装的设备重新拷贝修复后的 schema。 -- 持续完善输入法 UI、自适应布局和本地语音模型接入。 -- 引入固定版本的 Rime Ice 基础、扩展和 8105 字表,并增加首次部署期间即时可用的高频候选层。 -- 增强全拼、简拼、显式分词、首选命中和用户词排序,候选读取上限扩展到 96 项。 -- 统一选词、空格及回车提交后的 composition 清理,删除键不再误删残留候选状态。 -- 增加连续长句、扩展候选、分词和选词后回删的真实 IME 回归脚本。 -- 建立 L0~L3 正式测试 SOP、统一证据目录、输入框实验室和隐私回归门禁。 -- 将 `DebugKeyboardActivity` 从主变体迁入 debug,release APK 不再导出测试宿主。 -- 将本地语音模型改为输入框出现时后台预热、隐藏后 10 秒热驻留并异步释放,键盘主线程不再执行模型哈希或映射。 -- 长按空格在模型预热期间先录音缓存,修复仅返回 final 时不上屏,并加入动态热词、本地语音纠错学习、音频路由隔离和无文本性能指标。 -- debug E2E 入口增加 `android.permission.DUMP` 保护,保留 adb 回归能力并阻止普通第三方 App 调用。 - -## 1.0.0 - -- 建立独立 `openIME` APK,包名为 `llc.slacker.openime`。 -- 接入 librime、OpenCC、中文拼音候选和多种键盘模式。 -- 接入 APK 内置的 sherpa-onnx 中英双语语音模型。 -- 建立真实 IME、生命周期、隐私和多宽度布局回归脚本。 +本文件记录面向用户和维护者的变更,格式遵循 +[Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 +[语义化版本](https://semver.org/lang/zh-CN/)。每个 `## [版本] - 日期` 小节就是该版本 +GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs/RELEASE.md)。 +1.0.0 之前的开发期记录见 [docs/CHANGELOG_PRE_1.0.md](docs/CHANGELOG_PRE_1.0.md)。 + +## [Unreleased] + +## [1.0.0] - 2026-10-02 + +首个正式版。openIME 是独立的 Android 系统输入法:拼音候选、用户学习和语音识别都在设备内运行,应用不声明 `INTERNET` 权限。 + +### 输入 +- 键盘:26 键拼音、九键拼音、英文 26 键、数字与符号;不提供英文九键。 +- 拼音引擎:基于 librime 的全拼、简拼、显式分词、候选读取(上限 96 项)、用户学习和 OpenCC 简繁转换;内置约 90 万条 Rime Ice 词典记录(固定版本,含 8105 字表)。首次部署完整词典期间,高频快速词库即时提供候选。 +- 九键:输入时左栏变成拼音列表,一个字一个拼音——每项是下一个字的一个音节(`ni`、`mi`),点选即锁定,列表随即移到下一个字;锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`9694264244326` → `wo'xiang'chi'fan`「我想吃饭」),数字刚好拼得出的词排在预测词之前。做法与取舍见 [九键参考](https://github.com/Slacker-LLC/openIME/blob/main/docs/NINE_KEY_REFERENCE.md)。 +- 选词只覆盖一部分输入时只上屏该词,剩余输入继续作为预编辑;不会把没选过的整句写进用户词库。 +- 空格提交首选候选,回车(确定)提交已输入的拼音原文;提交后统一清除拼音、候选与 Rime composition,随后删除键只处理目标输入框。上屏后的联想栏为「‹ 联想词 ∨」。 +- 删除键:长按连续删除;上滑 ≥ 32dp 松手清空,清空后 5 秒内下滑撤回。清空与撤回在自绘、Compose、Web 等没有「全选」也没有完整 ExtractedText 的输入框里同样可用,提示统一为一个气泡,清空后顶栏显示「已清空 · 撤销」。 + +### 语音 +- 长按空格(达到系统长按时长)开始本地语音输入,松手识别并上屏,上滑取消。使用 APK 内置的 sherpa-onnx 与中英双语模型,没有网络语音服务。 +- 输入框出现时后台预热模型,隐藏 10 秒后释放;预热期间的语音先录音缓存;支持动态热词与本地纠错学习。 +- 麦克风权限被拒绝时可恢复,不影响普通输入;本地模型使用前校验完整性。 + +### 面板与外观 +- Emoji、符号、剪贴板与常用语、文本编辑、浮动键盘和设置面板。 +- 按输入法窗口实际可用宽度计算布局,宽屏限制内容最大宽度并居中;系统底部区域通过 WindowInsets 处理。 +- 强调色可选可自定义,界面里的强调元素统一从它推导;设置页分段控件、剪贴板清除按钮等可点区域保持 48dp 触控目标;安装引导页的卡片对读屏软件是一个整体,不再逐个朗读装饰元素。 +- 手写目前只保留笔迹采集界面,识别引擎尚未接入,入口默认隐藏。 + +### 兼容性 +- 物理键盘(平板、折叠屏键盘套、Chromebook、桌面模式)可以打中文:字母组成拼音,空格选首选,1–9 选候选,回车保留拼音,Esc 取消,`,` `.` 等输出全角标点,数字后的 `,` `.` `:` 保持 ASCII;快捷键、大写字母原样交给应用。 +- 终端、游戏、远程桌面这类没有文本类型(TYPE_NULL)的输入框:默认英文,字母立即送出,退格改用按键事件(此前什么也删不掉);日期时间输入框默认数字键盘。 +- 一次提交超大文本(大段粘贴)会分块,不会因超过 Binder 事务上限而崩溃。 +- 横屏不再进入全屏「提取」模式:应用自己的输入框不会被盖住,键盘始终是底部面板。 +- 横屏、平板和折叠屏上按键行宽度现在跟随内容区,不会再被裁掉一部分;布局在测量前就按可用宽度计算。 +- 系统字体放大到 200% 时按键字符不再变成省略号,「中/英」不再缺字:按键标签最多跟随系统字体放大到 1.3 倍,功能键标签会自动缩小以放入按键。 +- 竖屏、横屏、字体 130% / 200%、深色、小屏、窄屏、平板(竖 / 横)和折叠屏内屏都由 `scripts/display_matrix_regression.py` 逐一验证。 + +### 稳定性 +- 一次按键处理失败不会让键盘退出;崩溃、原生崩溃和卡死(ANR)会记录在本机(只含异常类型和代码位置,不含输入内容),10 分钟内三次进入安全模式(关闭原生词库与语音预加载),「关于与数据」里可以复制诊断信息或退出安全模式。 +- librime 启动时进程原生崩溃会逐级自愈:清理编译产物、备份并重建用户词库、最后不再启动原生引擎。 +- 语音输入静音媒体音量期间进程意外退出,下次启动会恢复音量,并带两分钟看门狗。 +- 词库和九键解码器改在后台线程构建,冷启动不再卡住主线程。 + +### 隐私与安全 +- 不声明 `INTERNET` 权限;`allowBackup=false`。 +- 密码输入框不写入候选、剪贴板历史或日志(允许从剪贴板粘贴);语音 PCM 只在当前会话的内存缓冲区中处理,结束、取消或失败时清空。 +- 「设置 → 关于与数据」可导出 / 合并导入用户数据,剪贴板历史不导出;卸载会清除本机全部数据,包括学习的用户词库。 +- 测试用 Activity 与 E2E 接收器只存在于 debug 变体,release APK 不导出。 + +### 工程 +- 版本号单一来源(根目录 `VERSION`),`versionCode = 主 × 10000 + 次 × 100 + 修订`。 +- 发布 APK 为固定签名的 `arm64-v8a`,随附 SHA-256 校验和与第三方许可清单;CI 在 API 29 / 31 上运行全部仪器测试。 + +[Unreleased]: https://github.com/Slacker-LLC/openIME/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/Slacker-LLC/openIME/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e26eabd..aeebf175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,11 +9,20 @@ ## 分支与提交 -- 从 `main` 创建短生命周期分支,例如 `fix/pinyin-candidate` 或 `docs/repository`。 -- 每个提交只解决一个主题,提交说明使用清晰的中文动词开头,例如 - `修复九键拼音候选提交`、`整理 GitHub Actions 构建流程`。 +- 从最新的 `main` 创建短生命周期分支,命名为 `类型/主题`,类型取 `feat`、`fix`、`docs`、 + `chore`、`ci`、`refactor`、`test`,例如 `fix/pinyin-candidate`、`docs/repository`。 +- 分支里的提交保持“一个提交一个主题”,说明用简短的一句话写结果(中文或英文均可),例如 + `修复九键拼音候选提交`、`fix: keep the rest of the input composing`。合并时会被 squash, + 最终进入 `main` 的提交信息是 PR 标题和描述。 - 不要提交 `local.properties`、Gradle/build 输出、根目录截图、UI dump、设备日志、 - 密码、录音或未经确认的模型文件。 + 密码、录音、签名密钥或未经确认的模型文件。 + +## 变更记录与版本 + +- 用户可见的改动(功能、行为、修复、权限、数据格式)在同一个 PR 里写进 `CHANGELOG.md` 的 + `## [Unreleased]`;纯内部改动不需要。 +- 不要在功能 PR 里修改 `VERSION`:版本只在发布 PR 里升级。版本规则、签名和发布步骤见 + [docs/RELEASE.md](docs/RELEASE.md),分支与合并规则见 [docs/REPOSITORY.md](docs/REPOSITORY.md)。 ## 提交前检查 @@ -23,6 +32,13 @@ git diff --check git status --short ``` +Linux / macOS / Git Bash 上再运行版本与变更记录检查(CI 也会运行): + +```bash +python3 -m unittest discover -s scripts -p 'test_*.py' +python3 scripts/release_check.py check +``` + 如果改动了真实输入链路,使用明确的设备 Serial 运行至少一组核心回归: ```powershell @@ -37,3 +53,6 @@ Android 版本、窗口宽度和是否使用浮动键盘。 PR 描述应包含:改动目的、影响范围、测试命令和结果、已知限制,以及是否修改了 词典、模型、权限或数据格式。涉及截图时请脱敏;不要在 PR 中上传真实输入内容、 密码、剪贴板或录音。 + +`main` 受保护:只能通过 PR 合并,且 **Build and verify** 必须通过;PR 一律 squash 合并, +合并后分支自动删除。API 26 / 29 / 31 / 34 兼容测试同样会在 PR 上运行,红了请先修再合并。 diff --git a/README.md b/README.md index 35d8ee71..451192c7 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,16 @@ openIME 是独立 Android 系统输入法,包名 `llc.slacker.openime`。拼 ## 下载 -正式版本从 [GitHub Releases](https://github.com/Slacker-LLC/openIME/releases/latest) 下载。发布 APK 使用固定发布签名,文件名为 `openIME-v{versionName}-arm64-release.apk`,同一 Release 同时提供 `SHA256SUMS.txt`。 +正式版本从 [GitHub Releases](https://github.com/Slacker-LLC/openIME/releases/latest) 下载,各版本的变化见 [CHANGELOG.md](CHANGELOG.md)。发布 APK 使用固定发布签名,文件名为 `openIME-v{版本}-arm64-release.apk`,同一 Release 同时提供 `SHA256SUMS.txt` 和第三方许可清单。下载后先校验再安装: + +```bash +sha256sum -c SHA256SUMS.txt +``` 当前正式发布包只包含 `arm64-v8a`;开发用 Debug APK 仍保留 `x86_64`,用于模拟器回归。 +版本号遵循[语义化版本](https://semver.org/lang/zh-CN/),唯一来源是根目录 `VERSION`;发布流程见 [docs/RELEASE.md](docs/RELEASE.md)。 + ## 安装四步 1. **启用**:安装 APK 后打开 openIME,进入系统输入法设置并启用 `openIME`。 @@ -68,10 +74,12 @@ InputConnection ```text app/ Android APK、IME Service、Rime JNI、内置模型与词典 -scripts/ PowerShell/Bash 构建、回归、性能和视觉检查脚本 -docs/ 架构、适配、测试证据和本地模型接入文档 +scripts/ PowerShell/Bash 构建、回归、性能、视觉检查与发布脚本 +docs/ 架构、适配、测试、发布与仓库管理文档 gradle/ Gradle Wrapper -.github/ GitHub Actions、Issue 模板和 PR 模板 +.github/ GitHub Actions(CI 与发布)、Dependabot、Issue 与 PR 模板 +VERSION 版本号的唯一来源(MAJOR.MINOR.PATCH) +CHANGELOG.md 各版本变更记录,同时是 Release 说明的来源 ``` 第三方 C/C++ 源码位于 `app/src/main/cpp/vendor/`,其上游许可证随源代码保留。 @@ -170,7 +178,9 @@ bash scripts/verify_linux.sh emulator-5554 - [适配与坐标规范](docs/COORDINATE_SYSTEM.md) - [测试流程 SOP](docs/TEST_SOP.md) - [脚本说明](scripts/README.md) -- [发布流程](docs/RELEASE.md) +- [发布与版本管理](docs/RELEASE.md) +- [仓库管理](docs/REPOSITORY.md) +- [贡献指南](CONTRIBUTING.md) ## 隐私与安全 diff --git a/SECURITY.md b/SECURITY.md index 3524e423..a3be1220 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,25 @@ 不要通过公开 Issue 报告可能泄露用户文字、剪贴板、密码、录音或模型文件的问题。 -请使用 GitHub 仓库的 Security Advisories 私下提交;如果该入口暂不可用,请先联系 -仓库维护者并提供最小复现信息,不要附带真实用户数据。报告中建议包含: +## 如何报告 -- 受影响的 commit、APK 变体和 Android 版本; +请使用仓库的 **Security → Report a vulnerability**(私密漏洞报告)私下提交。如果该入口暂不可用, +请先联系仓库维护者并提供最小复现信息,不要附带真实用户数据。报告中建议包含: + +- 受影响的版本或 commit、APK 变体和 Android 版本; - 复现步骤与影响范围; - 已脱敏的日志或截图; - 临时缓解方式(如果有)。 输入法相关报告请特别说明是否发生在密码编辑器、语音权限拒绝、跨应用编辑器切换、 -剪贴板或模型加载路径中。维护者会先确认问题,再决定修复、发布说明和披露时间。 +剪贴板或模型加载路径中。维护者会先确认问题,再决定修复、发布说明和披露时间; +修复发布前请不要公开细节。 + +## 受支持的版本 + +| 版本 | 状态 | +|---|---| +| 最新的 1.x 正式版 | 接收安全修复,以 PATCH 版本发布并写入 CHANGELOG | +| 更早的版本、Debug 签名的开发版 | 不再维护,请升级到最新正式版 | + +发布 APK 的真伪可以用 `SHA256SUMS.txt` 和签名证书指纹核对,见 [docs/RELEASE.md](docs/RELEASE.md)。 diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70da241e..8cc08207 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,6 +25,29 @@ plugins { id("org.jetbrains.kotlin.android") } +// The root VERSION file (MAJOR.MINOR.PATCH) is the only place a version is +// written. versionCode is derived from it, so the two cannot drift and every +// release is strictly greater than the one before. scripts/release_check.py +// applies the same rules in CI and verifies the built APK. +val appVersionName: String = providers + .fileContents(rootProject.layout.projectDirectory.file("VERSION")) + .asText + .get() + .trim() +val appVersionCode: Int = run { + val parts = Regex("""(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)""") + .matchEntire(appVersionName) + ?.destructured + ?.toList() + ?.map(String::toInt) + ?: throw GradleException("VERSION must be MAJOR.MINOR.PATCH, found '$appVersionName'") + val (major, minor, patch) = parts + if (major < 1 || minor > 99 || patch > 99) { + throw GradleException("VERSION $appVersionName is out of range (major >= 1, minor and patch <= 99)") + } + major * 10_000 + minor * 100 + patch +} + android { namespace = "llc.slacker.openime" compileSdk = 36 @@ -35,9 +58,8 @@ android { minSdk = 26 targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - versionCode = 4 - versionName = "1.0.3" - + versionCode = appVersionCode + versionName = appVersionName } signingConfigs { diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index 66b1d782..b04d6e6d 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -73,7 +73,9 @@ class AuditInteractionInstrumentedTest { null } "onFuzzyChanged" -> { fuzzyChanges.add(args!![0] as Boolean); null } - else -> null + // A proxy answers null for everything else; a primitive boolean + // query (hasClearUndo, onUndoClear) must answer false instead. + else -> if (method.returnType == java.lang.Boolean.TYPE) false else null } } as ImeKeyboardView.Listener } @@ -395,11 +397,11 @@ class AuditInteractionInstrumentedTest { harness.awaitMain { keyboard.showPanel(Panel.EMOJI) assertNotNull("Default smiley category must render a smiley", keyboard.findTestTarget("😀")) - assertTrue(keyboard.findTestTarget("人物/手势")!!.performClick()) + assertTrue(keyboard.findTestTarget("手势")!!.performClick()) assertNull("Switching category must replace, not append to, the old emoji grid", keyboard.findTestTarget("😀")) assertNotNull("People category must render skin-tone variants", keyboard.findTestTarget("👍🏿")) - assertTrue(keyboard.findTestTarget("动物/自然")!!.performClick()) + assertTrue(keyboard.findTestTarget("动物")!!.performClick()) assertNotNull("Animal category must render its own content", keyboard.findTestTarget("🐶")) assertNull("Previous people grid must be removed", keyboard.findTestTarget("👍🏿")) true @@ -432,7 +434,8 @@ class AuditInteractionInstrumentedTest { @Test fun settingsSlidersExposeCurrentValuesToTouchAndAccessibility() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - keyboard.showPanel(Panel.SETTINGS) + // Corner radius, opacity and key font size live on the skin sub-page. + keyboard.showPanel(Panel.SKIN_SETTINGS) val settings = keyboard.findViewWithTag("settings-panel") listOf("圆角", "不透明度", "按键字号").forEach { label -> val slider = settings.findViewWithTag("settings-slider:$label") @@ -444,19 +447,24 @@ class AuditInteractionInstrumentedTest { } @Test - fun settingsExposeEveryKeyboardThemeAndKeepSelectionAccessible() = withKeyboard { harness, _, keyboard -> + fun settingsExposeEveryAppearanceAndKeepSelectionAccessible() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.showPanel(Panel.SETTINGS) - ImeTheme.entries.forEach { theme -> - val chip = keyboard.findTestTarget(theme.label) - assertTrue("${theme.label} must be selectable from settings", chip != null) - assertTrue("${theme.label} must expose a 48dp target", chip!!.minimumHeight >= keyboard.resources.displayMetrics.density * 48f) + // The product ships one skin, so appearance (system / light / dark) is the picker. + ImeAppearance.entries.forEach { appearance -> + val option = keyboard.findTestTarget(appearance.label) + assertTrue("${appearance.label} must be selectable from settings", option != null) + assertTrue("${appearance.label} must be keyboard-focusable", option!!.isFocusable) + assertTrue( + "${appearance.label} must expose a 48dp target", + option.minimumHeight >= keyboard.resources.displayMetrics.density * 48f, + ) } - val ios = keyboard.findTestTarget(ImeTheme.IOS.label) - assertTrue(ios!!.performClick()) + val light = keyboard.findTestTarget(ImeAppearance.LIGHT.label) + assertTrue(light!!.performClick()) assertTrue( - "Selected theme must expose its accessible state", - keyboard.findTestTarget(ImeTheme.IOS.label)!!.contentDescription.toString().contains("已选中"), + "Selected appearance must expose its accessible state", + keyboard.findTestTarget(ImeAppearance.LIGHT.label)!!.contentDescription.toString().contains("已选中"), ) true } @@ -524,13 +532,25 @@ class AuditInteractionInstrumentedTest { } @Test - fun customAccentControlExposesSelectionAndUsesTheAccentBackground() = withKeyboard { harness, _, keyboard -> + fun customAccentControlExposesSelectionAndNoPresetClaimsIt() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.setSkin(96, 10, 18, "#123456") - keyboard.showPanel(Panel.SETTINGS) + keyboard.showPanel(Panel.SKIN_SETTINGS) val custom = keyboard.findViewWithTag("accent-custom") assertTrue(custom.contentDescription.toString().contains("已选中")) - assertEquals(Color.parseColor("#123456"), ((custom.background as StateListDrawable).current as GradientDrawable).color?.defaultColor) + assertTrue("The custom row must stay a 48dp target", custom.minimumHeight >= keyboard.resources.displayMetrics.density * 48f) + AccentPalette.presets.forEach { (hex, label) -> + assertNull( + "$label must not look selected while a custom accent is active", + keyboard.findViewWithTag("accent-selected-mark:$hex"), + ) + } + // And the other way round: a preset takes the selection from the custom row. + keyboard.setSkin(96, 10, 18, "#1D9BF0") + keyboard.showPanel(Panel.SKIN_SETTINGS) + assertTrue( + keyboard.findViewWithTag("accent-custom").contentDescription.toString().contains("未选中"), + ) true } } @@ -550,14 +570,19 @@ class AuditInteractionInstrumentedTest { fontSize = 19, primaryColor = "#123456", ) - keyboard.showPanel(Panel.SETTINGS) - assertEquals( - Color.parseColor("#123456"), - ((keyboard.findViewWithTag("accent-custom").background as StateListDrawable).current as GradientDrawable).color?.defaultColor, + keyboard.showPanel(Panel.SKIN_SETTINGS) + assertTrue( + "The persisted custom accent must be what the skin page reports as selected", + keyboard.findViewWithTag("accent-custom").contentDescription.toString().contains("已选中"), ) keyboard.showPanel(Panel.FUZZY_SETTINGS) - assertTrue( - keyboard.findViewWithTag("toggle").contentDescription.toString().contains("已开启"), + val toggle = keyboard.findViewWithTag("toggle") + assertTrue(toggle.contentDescription.toString().contains("已开启")) + // An enabled switch is painted with the accent, so this proves the + // persisted colour reached the theme, not just the settings text. + assertEquals( + Color.parseColor("#123456"), + (toggle.background as GradientDrawable).color?.defaultColor, ) true } @@ -567,7 +592,7 @@ class AuditInteractionInstrumentedTest { fun presetAccentSelectionHasAVisibleNonColorMark() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.setSkin(96, 10, 18, "#1D9BF0") - keyboard.showPanel(Panel.SETTINGS) + keyboard.showPanel(Panel.SKIN_SETTINGS) assertTrue( "Selected accent must expose a visible check mark in addition to color", keyboard.findViewWithTag("accent-selected-mark:#1D9BF0") != null, @@ -579,7 +604,7 @@ class AuditInteractionInstrumentedTest { @Test fun presetAccentSwatchesExposeAVisibleKeyboardFocusState() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - keyboard.showPanel(Panel.SETTINGS) + keyboard.showPanel(Panel.SKIN_SETTINGS) val swatch = keyboard.findViewWithTag("accent-swatch") assertTrue("Accent swatches must be keyboard-focusable", swatch.isFocusable) assertTrue("Accent swatches must expose a focusable background", swatch.background is StateListDrawable) @@ -606,11 +631,13 @@ class AuditInteractionInstrumentedTest { @Test fun candidateShortcutsExposePressedAndKeyboardFocusFeedback() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - val emoji = keyboard.findViewWithTag("candidate-emoji") + // The reference design keeps one shortcut in the candidate bar: overflow. + assertNull( + "The candidate bar must not carry a second shortcut", + keyboard.findViewWithTag("candidate-emoji"), + ) val expand = keyboard.findViewWithTag("candidate-expand") - assertTrue("Candidate emoji shortcut must expose a stateful background", emoji.background is StateListDrawable) assertTrue("Candidate overflow shortcut must expose a stateful background", expand.background is StateListDrawable) - assertTrue("Candidate emoji shortcut must be focusable", emoji.isFocusable) assertTrue("Candidate overflow shortcut must be focusable", expand.isFocusable) true } @@ -831,18 +858,23 @@ class AuditInteractionInstrumentedTest { } @Test - fun voiceLanguageButtonUpdatesItsAccessibleState() = withKeyboard { harness, _, keyboard -> + fun voiceLanguageControlUpdatesItsAccessibleState() = withKeyboard { harness, _, keyboard -> harness.awaitMain { keyboard.showPanel(Panel.VOICE) - val language = keyboard.findViewWithTag("voice-language") - assertTrue(language.contentDescription.toString().contains("普通话")) - assertTrue(language.performClick()) - assertTrue(language.contentDescription.toString().contains("英文")) + val mandarin = keyboard.findTestTarget("语音语言:普通话")!! + val english = keyboard.findTestTarget("语音语言:英文")!! + assertTrue("Mandarin starts selected", mandarin.isSelected) + assertFalse(english.isSelected) + assertTrue(english.performClick()) + assertTrue("English must become selected", english.isSelected) + assertFalse(mandarin.isSelected) keyboard.startVoiceFromSpace() - assertFalse("Voice language must lock for the active session", language.isEnabled) - assertTrue(language.contentDescription.toString().contains("识别进行中不可切换")) + assertFalse("Voice language must lock for the active session", english.isEnabled) + assertFalse(mandarin.isEnabled) + assertTrue(english.contentDescription.toString().contains("识别进行中不可切换")) keyboard.cancelVoiceForManualInput() - assertTrue("Voice language must unlock after cancellation", language.isEnabled) + assertTrue("Voice language must unlock after cancellation", english.isEnabled) + assertTrue(mandarin.isEnabled) true } } @@ -850,10 +882,13 @@ class AuditInteractionInstrumentedTest { @Test fun voiceErrorReleasesGestureLockAndAllowsRetry() = withKeyboard { harness, recorder, keyboard -> lateinit var failedEvents: VoiceRecognitionEvents + // A locked control is deliberately not clickable, so it can only be + // looked up before the lock engages. + lateinit var language: View harness.awaitMain { keyboard.showPanel(Panel.VOICE) + language = keyboard.findTestTarget("语音语言:普通话")!! keyboard.startVoiceFromSpace() - val language = keyboard.findViewWithTag("voice-language") assertFalse("Voice language must lock while recognition is starting", language.isEnabled) true } @@ -868,7 +903,6 @@ class AuditInteractionInstrumentedTest { true } harness.awaitMain { - val language = keyboard.findViewWithTag("voice-language") if (!language.isEnabled) return@awaitMain null assertFalse("Terminal error must clear active voice state", keyboard.isVoiceActive()) assertTrue("Terminal error must release the gesture-owned language lock", language.isEnabled) @@ -930,15 +964,18 @@ class AuditInteractionInstrumentedTest { @Test fun modeSwitchDismissesLongPressChoicePopup() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - keyboard.setMode(KeyboardMode.PINYIN_26, notifyListener = false) - val segment = keyboard.findViewWithTag("key-segment") + // 1 is the nine-key segmentation key; its long press offers @ # /. + keyboard.setMode(KeyboardMode.PINYIN_9, notifyListener = false) + val segment = keyboard.findViewWithTag("key-9:1") val baseline = keyboard.childCount assertTrue(segment.performLongClick()) + assertTrue("Long-press choice popup must be showing", keyboard.isKeyPopupShown()) assertEquals("Long-press choice popup must attach to the root", baseline + 1, keyboard.childCount) keyboard.setMode(KeyboardMode.ENGLISH_26, notifyListener = false) - assertEquals("Mode switch must retire popup whose anchor was rebuilt", baseline, keyboard.childCount) + assertFalse("Mode switch must retire popup whose anchor was rebuilt", keyboard.isKeyPopupShown()) + assertEquals("The retired popup must leave the root", baseline, keyboard.childCount) true } } @@ -954,11 +991,12 @@ class AuditInteractionInstrumentedTest { if (key.width == 0) return@awaitMain null baseline = keyboard.childCount touch(key, MotionEvent.ACTION_DOWN) - assertEquals("Ordinary key preview must attach to the root", baseline + 1, keyboard.childCount) + assertTrue("Ordinary key preview must be showing", keyboard.isKeyPopupShown()) keyboard.showPanel(Panel.EMOJI) - assertEquals("Opening a panel must retire the transient key preview", baseline, keyboard.childCount) + assertFalse("Opening a panel must retire the transient key preview", keyboard.isKeyPopupShown()) + assertEquals("The preview is reused, not re-added to the root", baseline, keyboard.childCount) assertEquals(Panel.EMOJI, keyboard.currentPanel()) touch(key, MotionEvent.ACTION_CANCEL) true @@ -975,19 +1013,18 @@ class AuditInteractionInstrumentedTest { harness.awaitMain { val key = keyboard.findViewWithTag("key:q") ?: return@awaitMain null if (key.width == 0) return@awaitMain null - val baseline = keyboard.childCount touch(key, MotionEvent.ACTION_DOWN) try { - assertEquals("Enabled preview must actually appear", baseline + 1, keyboard.childCount) + assertTrue("Enabled preview must actually appear", keyboard.isKeyPopupShown()) } finally { touch(key, MotionEvent.ACTION_CANCEL) } - assertEquals(baseline, keyboard.childCount) + assertFalse(keyboard.isKeyPopupShown()) keyboard.setSettings(sound = false, haptic = false, popup = false) assertSame(key, keyboard.findViewWithTag("key:q")) touch(key, MotionEvent.ACTION_DOWN) try { - assertEquals("Existing key must respect the updated popup preference", baseline, keyboard.childCount) + assertFalse("Existing key must respect the updated popup preference", keyboard.isKeyPopupShown()) } finally { touch(key, MotionEvent.ACTION_CANCEL) } @@ -1005,15 +1042,12 @@ class AuditInteractionInstrumentedTest { harness.awaitMain { val key = keyboard.findViewWithTag("key:5") ?: return@awaitMain null if (key.width == 0) return@awaitMain null - val baseline = keyboard.childCount touch(key, MotionEvent.ACTION_DOWN) try { - assertEquals(baseline + 1, keyboard.childCount) + assertTrue(keyboard.isKeyPopupShown()) + // The preview is brought to the front when it opens. val popup = keyboard.getChildAt(keyboard.childCount - 1) - val expectedHeight = ( - ImeGeometryTokens.KEY_POPUP_HEIGHT_DP * - keyboard.resources.displayMetrics.density - ).toInt() + val expectedHeight = keyboard.scaledPx(ImeGeometryTokens.KEY_POPUP_HEIGHT_DP) assertEquals("Key popup height must use the shared product token", expectedHeight, popup.layoutParams.height) assertTrue( "Wide keys must not produce a preview narrower than the source key", @@ -1061,7 +1095,10 @@ class AuditInteractionInstrumentedTest { @Test fun spaceJitterDoesNotEnterCursorModeAndHorizontalSwipeDoes() = withKeyboard { harness, recorder, keyboard -> - val density = keyboard.resources.displayMetrics.density + // Gesture thresholds scale with the keyboard (reference scale), so the + // drag is expressed in the same px: 18dp starts cursor mode, each 12dp + // after that is one step. The 2dp margins keep px rounding out of it. + val px = { dp: Int -> keyboard.scaledPx(dp).toFloat() } harness.awaitMain { val origin = keyPoint(keyboard, "key-space") var downTime = SystemClock.uptimeMillis() @@ -1071,46 +1108,26 @@ class AuditInteractionInstrumentedTest { keyboard, downTime, MotionEvent.ACTION_MOVE, - listOf(origin.copy(x = origin.x + 3f * density)), + listOf(origin.copy(x = origin.x + px(3))), ) SystemClock.sleep(40L) - pointers(keyboard, downTime, MotionEvent.ACTION_UP, listOf(origin.copy(x = origin.x + 3f * density))) + pointers(keyboard, downTime, MotionEvent.ACTION_UP, listOf(origin.copy(x = origin.x + px(3)))) assertTrue("A small jitter must not move the cursor", recorder.textEdits.isEmpty()) val spacesAfterJitter = recorder.spaces val swipeOrigin = keyPoint(keyboard, "key-space") downTime = SystemClock.uptimeMillis() pointers(keyboard, downTime, MotionEvent.ACTION_DOWN, listOf(swipeOrigin)) - pointers( - keyboard, - downTime, - MotionEvent.ACTION_MOVE, - listOf(swipeOrigin.copy(x = swipeOrigin.x + 19f * density)), - ) - pointers( - keyboard, - downTime, - MotionEvent.ACTION_MOVE, - listOf(swipeOrigin.copy(x = swipeOrigin.x + 31f * density)), - ) - pointers( - keyboard, - downTime, - MotionEvent.ACTION_MOVE, - listOf(swipeOrigin.copy(x = swipeOrigin.x + 43f * density)), - ) - pointers( - keyboard, - downTime, - MotionEvent.ACTION_MOVE, - listOf(swipeOrigin.copy(x = swipeOrigin.x + 30f * density)), - ) - pointers( - keyboard, - downTime, - MotionEvent.ACTION_UP, - listOf(swipeOrigin.copy(x = swipeOrigin.x + 30f * density)), - ) + var x = swipeOrigin.x + fun dragBy(delta: Float, action: Int = MotionEvent.ACTION_MOVE) { + x += delta + pointers(keyboard, downTime, action, listOf(swipeOrigin.copy(x = x))) + } + dragBy(px(18) + px(2)) // crosses the cursor-mode threshold; no step yet + dragBy(px(12) + px(2)) // first step right + dragBy(px(12) + px(2)) // second step right + dragBy(-(px(12) + px(6))) // reversing steps left + dragBy(0f, MotionEvent.ACTION_UP) assertEquals( "A deliberate drag must move across characters and reverse direction", diff --git a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt index df6ec690..3985389d 100644 --- a/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/CandidatePresentationInstrumentedTest.kt @@ -48,10 +48,11 @@ class CandidatePresentationInstrumentedTest { keyboard.findViewWithTag("candidate-expand").performClick() val overlay = keyboard.findViewWithTag("candidate-overlay") assertEquals(View.GONE, keyboard.findViewWithTag("keyboard-body").visibility) - val scroll = overlay.getChildAt(1) as ScrollView + // The expanded overlay is just the scrolling grid (no header row). + val scroll = overlay.getChildAt(0) as ScrollView keyboard.renderState(state) assertSame(first, keyboard.findViewWithTag("candidate-first-row")) - assertSame(scroll, overlay.getChildAt(1)) + assertSame(scroll, overlay.getChildAt(0)) keyboard.renderState(ImeState()) assertEquals(View.GONE, overlay.visibility) true diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt new file mode 100644 index 00000000..2c5d59c9 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -0,0 +1,155 @@ +package llc.slacker.openime + +import android.content.Context +import android.content.res.Configuration +import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.reflect.Proxy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The keyboard has to stay usable in every display environment, not just the + * default portrait phone: landscape (content box narrower than the window), + * large system fonts, and every layout. + */ +@RunWith(AndroidJUnit4::class) +class DisplayEnvironmentInstrumentedTest { + + private val listener: ImeKeyboardView.Listener = Proxy.newProxyInstance( + ImeKeyboardView.Listener::class.java.classLoader, + arrayOf(ImeKeyboardView.Listener::class.java), + ) { _, method, _ -> + when (method.returnType) { + java.lang.Boolean.TYPE -> false + java.lang.Integer.TYPE -> 0 + else -> null + } + } as ImeKeyboardView.Listener + + private fun configured(base: Context, change: Configuration.() -> Unit): Context = + base.createConfigurationContext(Configuration(base.resources.configuration).apply(change)) + + private fun allViews(root: View): Sequence = sequence { + yield(root) + if (root is ViewGroup) { + for (index in 0 until root.childCount) yieldAll(allViews(root.getChildAt(index))) + } + } + + /** Show a keyboard built from [contextFor] and hand it to [check] once the first layout is done. */ + private fun withKeyboard( + mode: KeyboardMode, + contextFor: (Context) -> Context, + check: (ImeKeyboardView) -> Unit, + ) { + DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness -> + harness.launch() + val keyboard = harness.awaitMain { activity -> + ImeKeyboardView(contextFor(activity), listener).also { + activity.findViewById(android.R.id.content).addView( + it, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + } + try { + harness.awaitMain { + keyboard.setMode(mode, notifyListener = false) + true + } + // First layout done: some visible key has a size. + harness.awaitMain { + val shown = allViews(keyboard).filterIsInstance().filter { it.isShown }.toList() + if (keyboard.width > 0 && shown.isNotEmpty() && shown.all { it.width > 0 }) true else null + } + // Geometry corrections are applied around the first layout; give + // any deferred relayout time to run before looking at sizes. + Thread.sleep(600) + harness.awaitMain { + check(keyboard) + true + } + } finally { + harness.awaitMain { + keyboard.shutdown() + (keyboard.parent as? ViewGroup)?.removeView(keyboard) + true + } + } + } + } + + @Test + fun landscapeContentBoxKeepsEveryRowInsideIt() { + // A landscape configuration makes the reference scale follow the height, + // so the content box (padding on both sides) is narrower than the window. + // Rows used to keep the width they were measured with before that padding + // was applied: as wide as the window, clipped to the box. + for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { + withKeyboard( + mode = mode, + contextFor = { base -> + configured(base) { + orientation = Configuration.ORIENTATION_LANDSCAPE + screenWidthDp = 914 + screenHeightDp = 411 + } + }, + ) { keyboard -> + val body = keyboard.findViewWithTag("keyboard-body") + assertNotNull("keyboard body", body) + val content = body.width - body.paddingLeft - body.paddingRight + assertTrue("$mode must be laid out in a clamped content box (padding ${body.paddingLeft})", body.paddingLeft > 0) + for (index in 0 until body.childCount) { + val row = body.getChildAt(index) + assertTrue( + "$mode row $index is ${row.width}px wide inside a ${content}px content box", + row.width <= content + 1, + ) + } + val toolbar = keyboard.findViewWithTag("toolbar-row") + if (toolbar != null) { + val toolbarContent = toolbar.width - toolbar.paddingLeft - toolbar.paddingRight + var used = 0 + for (index in 0 until toolbar.childCount) { + val child = toolbar.getChildAt(index) + if (child.visibility == View.VISIBLE) used += child.width + } + assertTrue( + "$mode toolbar items need ${used}px but the content box is ${toolbarContent}px", + used <= toolbarContent + 1, + ) + } + } + } + } + + @Test + fun keyLabelsStayReadableAtEverySystemFontSize() { + // Key labels follow the system font only up to 1.3x; beyond that a key + // cannot grow, and "m" turned into an ellipsis while "中/英" lost a glyph. + for (fontScale in listOf(1.0f, 1.3f, 1.5f, 2.0f)) { + for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { + withKeyboard( + mode = mode, + contextFor = { base -> configured(base) { this.fontScale = fontScale } }, + ) { keyboard -> + val clipped = allViews(keyboard) + .filterIsInstance() + .filter { !it.mainLabelFits() } + .map { "'${it.currentMainText}'" } + .toList() + assertEquals("labels cut off at font scale $fontScale in $mode", emptyList(), clipped) + } + } + } + } +} diff --git a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt index e6b3a46e..528652d3 100644 --- a/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/NineKeyChineseInstrumentedTest.kt @@ -56,19 +56,21 @@ class NineKeyChineseInstrumentedTest { val paths = harness.awaitMain { activity -> val root = activity.findViewById(android.R.id.content) ?: return@awaitMain emptyList() - val content = findView(root) { it.tag == "nine-symbol-scroll-content" } as? ViewGroup + val content = findView(root) { it.tag == "nine-pinyin-panel" } as? ViewGroup ?: return@awaitMain emptyList() (0 until content.childCount).mapNotNull { index -> (content.getChildAt(index) as? TextView)?.text?.toString() } } - assertTrue("64 must keep ni as an available path: $paths", "ni" in paths) - assertTrue("64 must keep mi as an available path: $paths", "mi" in paths) + // While composing, the rail is the reading list ("nine-pinyin-panel"), + // not the punctuation stack. + assertTrue("64 must keep ni as an available reading: $paths", "ni" in paths) + assertTrue("64 must keep mi as an available reading: $paths", "mi" in paths) assertFalse("64 must not show the non-Pinyin spelling oh: $paths", "oh" in paths) val selected = harness.awaitMain { activity -> val root = activity.findViewById(android.R.id.content) ?: return@awaitMain false - val content = findView(root) { it.tag == "nine-symbol-scroll-content" } as? ViewGroup + val content = findView(root) { it.tag == "nine-pinyin-panel" } as? ViewGroup ?: return@awaitMain false val choice = (0 until content.childCount) .mapNotNull { content.getChildAt(it) as? TextView } diff --git a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt index 90fd4720..e0091549 100644 --- a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt @@ -48,14 +48,14 @@ class TextEditControlsInstrumentedTest { harness.awaitMain { true } harness.awaitMain { - listOf("撤销", "▲", "▼").forEach { label -> + listOf("撤销", "↑", "↓").forEach { label -> val control = findInteractiveControl(keyboard, label) assertNotNull("missing supported text-edit control $label", control) assertTrue("$label should remain clickable", control!!.isClickable) assertTrue("$label should remain enabled", control.isEnabled) } - listOf("全选", "复制", "剪切", "粘贴", "◀", "▶").forEach { label -> + listOf("全选", "复制", "剪切", "粘贴", "←", "→").forEach { label -> val control = findInteractiveControl(keyboard, label) assertNotNull("missing supported text-edit control $label", control) assertTrue("$label should remain clickable", control!!.isClickable) @@ -118,7 +118,7 @@ class TextEditControlsInstrumentedTest { ) } } - assertTrue("cursor movement must remain available", findInteractiveControl(keyboard, "◀")!!.isEnabled) + assertTrue("cursor movement must remain available", findInteractiveControl(keyboard, "←")!!.isEnabled) } } diff --git a/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt new file mode 100644 index 00000000..d3272c39 --- /dev/null +++ b/app/src/androidTest/java/llc/slacker/openime/VoiceMediaMuteRecoveryInstrumentedTest.kt @@ -0,0 +1,45 @@ +package llc.slacker.openime + +import android.content.Context +import android.media.AudioManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VoiceMediaMuteRecoveryInstrumentedTest { + + @Test + fun mediaVolumeComesBackAfterTheKeyboardProcessDiesWhileRecording() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val stream = AudioManager.STREAM_MUSIC + val originalVolume = audio.getStreamVolume(stream) + val originalMuted = audio.isStreamMute(stream) + try { + if (originalMuted) audio.adjustStreamVolume(stream, AudioManager.ADJUST_UNMUTE, 0) + val volume = audio.getStreamMaxVolume(stream).coerceAtMost(6).coerceAtLeast(2) + audio.setStreamVolume(stream, volume, 0) + + assertTrue(VoiceMediaMuteController(context).mute()) + assertTrue("media must be silent while recording", audio.isStreamMute(stream) || audio.getStreamVolume(stream) == 0) + + // The process dies here: the controller above is never asked to restore(). + VoiceMediaMuteController.recoverAfterCrash(context) + + assertFalse("media must not stay muted after an unclean exit", audio.isStreamMute(stream)) + assertEquals(volume, audio.getStreamVolume(stream)) + // The recovery is one-shot: a second start must not touch the volume again. + audio.setStreamVolume(stream, 1, 0) + VoiceMediaMuteController.recoverAfterCrash(context) + assertEquals(1, audio.getStreamVolume(stream)) + } finally { + audio.setStreamVolume(stream, originalVolume, 0) + if (originalMuted) audio.adjustStreamVolume(stream, AudioManager.ADJUST_MUTE, 0) + } + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 9cdffcbb..007264e4 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -11,6 +11,10 @@ android:name=".ImeTestLabActivity" android:exported="true" android:permission="android.permission.DUMP" /> + = + candidatePipeline.nineKeyReadingsFor(digits, preferred) + + override fun nineKeyReadingFor(digits: String, candidate: String): List? = + candidatePipeline.nineKeyReadingFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = candidatePipeline.nineKeyPathsFor(code) diff --git a/app/src/main/assets/emoji/reference/1f600.png b/app/src/main/assets/emoji/reference/1f600.png new file mode 100644 index 00000000..ee94eef4 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f600.png differ diff --git a/app/src/main/assets/emoji/reference/1f601.png b/app/src/main/assets/emoji/reference/1f601.png new file mode 100644 index 00000000..e99b87e7 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f601.png differ diff --git a/app/src/main/assets/emoji/reference/1f602.png b/app/src/main/assets/emoji/reference/1f602.png new file mode 100644 index 00000000..7cec94e6 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f602.png differ diff --git a/app/src/main/assets/emoji/reference/1f603.png b/app/src/main/assets/emoji/reference/1f603.png new file mode 100644 index 00000000..ef06a2cc Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f603.png differ diff --git a/app/src/main/assets/emoji/reference/1f604.png b/app/src/main/assets/emoji/reference/1f604.png new file mode 100644 index 00000000..60c92322 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f604.png differ diff --git a/app/src/main/assets/emoji/reference/1f605.png b/app/src/main/assets/emoji/reference/1f605.png new file mode 100644 index 00000000..bff9745f Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f605.png differ diff --git a/app/src/main/assets/emoji/reference/1f606.png b/app/src/main/assets/emoji/reference/1f606.png new file mode 100644 index 00000000..d53bfb69 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f606.png differ diff --git a/app/src/main/assets/emoji/reference/1f607.png b/app/src/main/assets/emoji/reference/1f607.png new file mode 100644 index 00000000..d5756b17 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f607.png differ diff --git a/app/src/main/assets/emoji/reference/1f609.png b/app/src/main/assets/emoji/reference/1f609.png new file mode 100644 index 00000000..ece3de3b Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f609.png differ diff --git a/app/src/main/assets/emoji/reference/1f60a.png b/app/src/main/assets/emoji/reference/1f60a.png new file mode 100644 index 00000000..a4a09efd Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f60a.png differ diff --git a/app/src/main/assets/emoji/reference/1f60c.png b/app/src/main/assets/emoji/reference/1f60c.png new file mode 100644 index 00000000..37874aa0 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f60c.png differ diff --git a/app/src/main/assets/emoji/reference/1f60d.png b/app/src/main/assets/emoji/reference/1f60d.png new file mode 100644 index 00000000..40de8e5f Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f60d.png differ diff --git a/app/src/main/assets/emoji/reference/1f60e.png b/app/src/main/assets/emoji/reference/1f60e.png new file mode 100644 index 00000000..1241532d Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f60e.png differ diff --git a/app/src/main/assets/emoji/reference/1f60f.png b/app/src/main/assets/emoji/reference/1f60f.png new file mode 100644 index 00000000..05771279 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f60f.png differ diff --git a/app/src/main/assets/emoji/reference/1f612.png b/app/src/main/assets/emoji/reference/1f612.png new file mode 100644 index 00000000..2d0c715c Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f612.png differ diff --git a/app/src/main/assets/emoji/reference/1f614.png b/app/src/main/assets/emoji/reference/1f614.png new file mode 100644 index 00000000..fa447bcf Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f614.png differ diff --git a/app/src/main/assets/emoji/reference/1f615.png b/app/src/main/assets/emoji/reference/1f615.png new file mode 100644 index 00000000..c8633b93 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f615.png differ diff --git a/app/src/main/assets/emoji/reference/1f617.png b/app/src/main/assets/emoji/reference/1f617.png new file mode 100644 index 00000000..a698a8e7 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f617.png differ diff --git a/app/src/main/assets/emoji/reference/1f618.png b/app/src/main/assets/emoji/reference/1f618.png new file mode 100644 index 00000000..eb2f1867 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f618.png differ diff --git a/app/src/main/assets/emoji/reference/1f619.png b/app/src/main/assets/emoji/reference/1f619.png new file mode 100644 index 00000000..b60be473 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f619.png differ diff --git a/app/src/main/assets/emoji/reference/1f61a.png b/app/src/main/assets/emoji/reference/1f61a.png new file mode 100644 index 00000000..1ede2e03 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f61a.png differ diff --git a/app/src/main/assets/emoji/reference/1f61b.png b/app/src/main/assets/emoji/reference/1f61b.png new file mode 100644 index 00000000..b4984377 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f61b.png differ diff --git a/app/src/main/assets/emoji/reference/1f61c.png b/app/src/main/assets/emoji/reference/1f61c.png new file mode 100644 index 00000000..d9966e3b Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f61c.png differ diff --git a/app/src/main/assets/emoji/reference/1f61d.png b/app/src/main/assets/emoji/reference/1f61d.png new file mode 100644 index 00000000..bcf151ca Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f61d.png differ diff --git a/app/src/main/assets/emoji/reference/1f61e.png b/app/src/main/assets/emoji/reference/1f61e.png new file mode 100644 index 00000000..cbca02a1 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f61e.png differ diff --git a/app/src/main/assets/emoji/reference/1f61f.png b/app/src/main/assets/emoji/reference/1f61f.png new file mode 100644 index 00000000..1d6a75fb Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f61f.png differ diff --git a/app/src/main/assets/emoji/reference/1f633.png b/app/src/main/assets/emoji/reference/1f633.png new file mode 100644 index 00000000..3d5610b7 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f633.png differ diff --git a/app/src/main/assets/emoji/reference/1f641.png b/app/src/main/assets/emoji/reference/1f641.png new file mode 100644 index 00000000..dbd3d6d6 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f641.png differ diff --git a/app/src/main/assets/emoji/reference/1f642.png b/app/src/main/assets/emoji/reference/1f642.png new file mode 100644 index 00000000..8274f185 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f642.png differ diff --git a/app/src/main/assets/emoji/reference/1f643.png b/app/src/main/assets/emoji/reference/1f643.png new file mode 100644 index 00000000..66fcb1e1 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f643.png differ diff --git a/app/src/main/assets/emoji/reference/1f913.png b/app/src/main/assets/emoji/reference/1f913.png new file mode 100644 index 00000000..9a94473d Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f913.png differ diff --git a/app/src/main/assets/emoji/reference/1f923.png b/app/src/main/assets/emoji/reference/1f923.png new file mode 100644 index 00000000..ffc1e616 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f923.png differ diff --git a/app/src/main/assets/emoji/reference/1f929.png b/app/src/main/assets/emoji/reference/1f929.png new file mode 100644 index 00000000..606cb50e Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f929.png differ diff --git a/app/src/main/assets/emoji/reference/1f92a.png b/app/src/main/assets/emoji/reference/1f92a.png new file mode 100644 index 00000000..58138b61 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f92a.png differ diff --git a/app/src/main/assets/emoji/reference/1f970.png b/app/src/main/assets/emoji/reference/1f970.png new file mode 100644 index 00000000..04b80876 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f970.png differ diff --git a/app/src/main/assets/emoji/reference/1f973.png b/app/src/main/assets/emoji/reference/1f973.png new file mode 100644 index 00000000..67d782f3 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f973.png differ diff --git a/app/src/main/assets/emoji/reference/1f978.png b/app/src/main/assets/emoji/reference/1f978.png new file mode 100644 index 00000000..192cac60 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f978.png differ diff --git a/app/src/main/assets/emoji/reference/1f979.png b/app/src/main/assets/emoji/reference/1f979.png new file mode 100644 index 00000000..9e2a7064 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f979.png differ diff --git a/app/src/main/assets/emoji/reference/1f97a.png b/app/src/main/assets/emoji/reference/1f97a.png new file mode 100644 index 00000000..8ec9d266 Binary files /dev/null and b/app/src/main/assets/emoji/reference/1f97a.png differ diff --git a/app/src/main/assets/emoji/reference/263a_fe0f.png b/app/src/main/assets/emoji/reference/263a_fe0f.png new file mode 100644 index 00000000..e2a70a6c Binary files /dev/null and b/app/src/main/assets/emoji/reference/263a_fe0f.png differ diff --git a/app/src/main/cpp/local_rime_jni.cc b/app/src/main/cpp/local_rime_jni.cc index e4c6f817..21b25384 100644 --- a/app/src/main/cpp/local_rime_jni.cc +++ b/app/src/main/cpp/local_rime_jni.cc @@ -392,6 +392,34 @@ Java_llc_slacker_openime_RimeNative_nativeSelectCandidate( return utf8_to_jstring(env, finish_selection()); } +// How much of the current input each candidate spells, as an absolute offset +// into the input string (the candidate's end()). A candidate whose end is +// shorter than the input is a partial match: choosing it must leave the rest +// of the input composing instead of dropping it. +extern "C" JNIEXPORT jintArray JNICALL +Java_llc_slacker_openime_RimeNative_nativeCandidateEnds( + JNIEnv* env, jclass, jint count) { + std::lock_guard lock(g_mutex); + const jsize size = count > 0 ? static_cast(count) : 0; + std::vector ends(static_cast(size), -1); + if (g_api && g_session && size > 0) { + auto session = rime::Service::instance().GetSession(g_session); + auto* context = session ? session->context() : nullptr; + if (context && !context->composition().empty()) { + auto& segment = context->composition().back(); + for (jsize i = 0; i < size; ++i) { + auto candidate = segment.GetCandidateAt(static_cast(i)); + if (!candidate) continue; + ends[static_cast(i)] = static_cast(candidate->end()); + } + } + } + jintArray result = env->NewIntArray(size); + if (!result) return nullptr; + if (size > 0) env->SetIntArrayRegion(result, 0, size, ends.data()); + return result; +} + extern "C" JNIEXPORT jboolean JNICALL Java_llc_slacker_openime_RimeNative_nativeIsUserLearnedCandidate( JNIEnv*, jclass, jint index) { diff --git a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt index 9f6bd606..df19b8f7 100644 --- a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt +++ b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt @@ -13,16 +13,21 @@ import android.widget.Toast import java.io.File class AboutDataActivity : Activity() { + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(SetupUi.appearanceContext(newBase)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + CrashGuard.install(this) val content = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding( - dp(ImeSpacingTokens.XL_DP), dp(ImeSpacingTokens.LG_DP), - dp(ImeSpacingTokens.XL_DP), + 0, + dp(ImeSpacingTokens.LG_DP), dp(ImeSpacingTokens.XXL_DP), ) addView( @@ -34,23 +39,20 @@ class AboutDataActivity : Activity() { LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, dp(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), - ), + ).apply { marginStart = -dp(16); marginEnd = -dp(16) }, ) addView( infoCard( - title = "openIME " + versionName(), + title = "隐私", body = "本应用不含联网权限,数据只存在本机。", ), - wrap().apply { topMargin = dp(ImeSpacingTokens.MD_DP) }, + wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, ) - addView( - infoCard( - title = "用户数据", - body = "JSON 导出包含常用语、自定义符号、备用用户词条和设置项;剪贴板历史不导出。Rime 自动学习词库在已加载时一并导出,并在导入时按词库合并。", - ), - wrap().apply { topMargin = dp(ImeSpacingTokens.MD_DP) }, + val dataCard = infoCard( + title = "用户数据", + body = "JSON 导出包含常用语、自定义符号、备用用户词条和设置项;剪贴板历史不导出。Rime 自动学习词库在已加载时一并导出,并在导入时按词库合并。", ) - + addView(dataCard, wrap().apply { topMargin = dp(12) }) val actions = LinearLayout(this@AboutDataActivity).apply { orientation = LinearLayout.HORIZONTAL addView( @@ -59,7 +61,7 @@ class AboutDataActivity : Activity() { }, LinearLayout.LayoutParams( 0, - dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), + dp(44), 1f, ).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, ) @@ -69,17 +71,17 @@ class AboutDataActivity : Activity() { }, LinearLayout.LayoutParams( 0, - dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), + dp(44), 1f, ), ) } - addView( + dataCard.addView( actions, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), - ).apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, + dp(44), + ).apply { topMargin = dp(12); marginStart = dp(44) }, ) addView( infoCard( @@ -88,6 +90,50 @@ class AboutDataActivity : Activity() { ), wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, ) + val safeMode = CrashGuard.isSafeMode(this@AboutDataActivity) + val lastReport = CrashGuard.lastReport(this@AboutDataActivity) + val diagnostics = infoCard( + title = "诊断", + body = when { + safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。" + lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread") + else -> "没有异常记录。" + } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", + ) + addView(diagnostics, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }) + if (lastReport != null || safeMode) { + val row = LinearLayout(this@AboutDataActivity).apply { + orientation = LinearLayout.HORIZONTAL + if (lastReport != null) { + addView( + SetupUi.secondaryButton(this@AboutDataActivity, "复制诊断信息") { + val clipboard = getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport)) + Toast.makeText(this@AboutDataActivity, "已复制", Toast.LENGTH_SHORT).show() + }, + LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, + ) + } + if (safeMode) { + addView( + SetupUi.primaryButton(this@AboutDataActivity, "退出安全模式") { + CrashGuard.clearHistory(this@AboutDataActivity) + Toast.makeText(this@AboutDataActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show() + recreate() + }, + LinearLayout.LayoutParams(0, dp(44), 1f), + ) + } + } + diagnostics.addView( + row, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { topMargin = dp(12); marginStart = dp(44) }, + ) + } + addView(TextView(this@AboutDataActivity).apply { + text = "openIME · 版本 " + versionName(); textSize = ImeTypographyTokens.SMALL_SP; gravity = android.view.Gravity.CENTER + setTextColor(getColor(R.color.setup_body)) + }, wrap().apply { topMargin = dp(18) }) } setContentView( @@ -329,24 +375,25 @@ class AboutDataActivity : Activity() { dp(ImeGeometryTokens.CARD_RADIUS_DP).toFloat(), getColor(R.color.setup_input_line), ) - addView( - TextView(this@AboutDataActivity).apply { - text = title - textSize = ImeTypographyTokens.TITLE_SP - setTextColor(getColor(R.color.setup_title)) - setTypeface(typeface, android.graphics.Typeface.BOLD) - }, - wrap(), - ) - addView( - TextView(this@AboutDataActivity).apply { - text = body - textSize = ImeTypographyTokens.BODY_SP - setTextColor(getColor(R.color.setup_body)) - setPadding(0, dp(ImeSpacingTokens.SM_DP), 0, 0) - }, - wrap(), - ) + addView(LinearLayout(this@AboutDataActivity).apply { + orientation = LinearLayout.HORIZONTAL + addView(android.widget.ImageView(this@AboutDataActivity).apply { + setImageResource(if (title == "用户数据") R.drawable.ic_download else if (title == "隐私") R.drawable.ic_shield else R.drawable.ic_info) + imageTintList = android.content.res.ColorStateList.valueOf(SetupUi.accent(this@AboutDataActivity)) + scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + setPadding(dp(8), dp(8), dp(8), dp(8)) + background = SetupUi.rounded(ImeDrawableFactory.blend(SetupUi.accent(this@AboutDataActivity), getColor(R.color.setup_surface), 0.14f), dp(8).toFloat()) + }, LinearLayout.LayoutParams(dp(32), dp(32)).apply { marginEnd = dp(12) }) + addView(LinearLayout(this@AboutDataActivity).apply { + orientation = LinearLayout.VERTICAL + addView(TextView(this@AboutDataActivity).apply { + text = title; textSize = ImeTypographyTokens.BODY_SP; setTextColor(getColor(R.color.setup_title)); typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + }, wrap()) + addView(TextView(this@AboutDataActivity).apply { + text = body; textSize = ImeTypographyTokens.BODY_SP; setTextColor(getColor(R.color.setup_body)); setLineSpacing(0f, 1.3f) + }, wrap().apply { topMargin = dp(4) }) + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + }, wrap()) } @Suppress("DEPRECATION") diff --git a/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt b/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt index eabce336..57880c7b 100644 --- a/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/BackspaceGestureController.kt @@ -2,24 +2,47 @@ package llc.slacker.openime import android.os.Handler import android.os.Looper +import android.util.Log import android.view.View import android.view.ViewConfiguration import kotlin.math.abs +/** What the delete-key gesture currently offers; drives one shared hint bubble. */ +internal enum class GestureHint { + NONE, + + /** Moving up, not yet far enough to clear. */ + CLEAR_PREVIEW, + + /** Far enough: releasing now clears the whole field. */ + CLEAR_ARMED, + + /** Moving down while a clear can still be undone. */ + UNDO_PREVIEW, + + /** Far enough: releasing now restores the cleared text. */ + UNDO_ARMED, +} + /** * Owns the held-backspace gesture: repeat delete, upward clear-all, and the * one-shot downward restore that is available immediately after a successful * clear gesture. Editor mutations remain callbacks. + * + * Feedback is a single [GestureHint] stream. Clear and restore used to be two + * unrelated popups (a red pill and a white card) plus a tiny label inside the + * key; both are now the same bubble with a different label and fill. */ internal class BackspaceGestureController( private val toPx: (Int) -> Int, private val onDeleteOne: () -> Unit, private val onClearAll: () -> Unit, private val onUndoClear: () -> Boolean, + /** The editor-side truth: a clear can be undone only while its snapshot is alive. */ + private val hasUndoSnapshot: () -> Boolean, private val onPressFeedback: () -> Unit, private val onHapticFeedback: () -> Unit, - private val onShowClearPopup: (View) -> Unit, - private val onShowUndoPopup: (View) -> Unit, + private val onGestureHint: (View, GestureHint) -> Unit, private val onHidePopup: () -> Unit, ) { private val handler = Handler(Looper.getMainLooper()) @@ -33,20 +56,19 @@ internal class BackspaceGestureController( private var clearArmed = false private var undoArmed = false - private var undoAvailable = false + private var undoAvailable = false // re-read from the editor on every press private var repeatStarted = false private var repeatSuspended = false + private var hint = GestureHint.NONE private var startX = 0f private var startY = 0f private var anchor: View? = null - private var clearPreviewChanged: ((Boolean) -> Unit)? = null private var repeatStartAction: Runnable? = null private val repeatAction = object : Runnable { override fun run() { if (!active || clearArmed || undoArmed) return repeatStarted = true - undoAvailable = false onDeleteOne() handler.postDelayed(this, REPEAT_INTERVAL_MS) } @@ -57,26 +79,26 @@ internal class BackspaceGestureController( pointerId: Int, rawX: Float, rawY: Float, - onClearPreviewChanged: (Boolean) -> Unit, ) { if (active) finish(commit = false) handler.removeCallbacks(repeatAction) repeatStartAction?.let(handler::removeCallbacks) + undoAvailable = hasUndoSnapshot() + Log.d(TAG, "bs begin x=$rawX y=$rawY pointer=$pointerId undoAvailable=$undoAvailable") active = true clearArmed = false undoArmed = false repeatStarted = false repeatSuspended = false + hint = GestureHint.NONE startX = rawX startY = rawY this.anchor = anchor this.pointerId = pointerId - clearPreviewChanged = onClearPreviewChanged anchor.isPressed = true anchor.parent?.requestDisallowInterceptTouchEvent(true) - onClearPreviewChanged(false) onHidePopup() onPressFeedback() @@ -92,13 +114,15 @@ internal class BackspaceGestureController( val upward = startY - rawY val downward = rawY - startY val horizontal = abs(rawX - startX) + val armDistance = toPx(CLEAR_ARM_DP) + val previewDistance = toPx(PREVIEW_DP) + val onAxis = horizontal <= toPx(AXIS_TOLERANCE_DP) // Suspend repeat-delete as soon as the gesture clearly becomes a // vertical command. Otherwise a slow swipe could mutate text before // clear/restore is armed. val verticalCommand = - horizontal <= toPx(96) && - (upward >= toPx(8) || (undoAvailable && downward >= toPx(8))) + onAxis && (upward >= previewDistance || (undoAvailable && downward >= previewDistance)) if (verticalCommand) { repeatSuspended = true repeatStartAction?.let(handler::removeCallbacks) @@ -113,49 +137,61 @@ internal class BackspaceGestureController( } } + // Restore is only offered right after a clear; the two commands are + // mutually exclusive, with hysteresis so a wobble cannot flip them. val shouldUndo = undoAvailable && if (undoArmed) { - downward > toPx(16) && horizontal <= toPx(120) + downward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP) } else { - downward >= toPx(36) && horizontal <= toPx(96) + downward >= armDistance && onAxis } if (shouldUndo != undoArmed) { + Log.d(TAG, "bs undoArmed=$shouldUndo down=${downward.toInt()} h=${horizontal.toInt()}") undoArmed = shouldUndo if (undoArmed) { clearArmed = false - clearPreviewChanged?.invoke(false) repeatStartAction?.let(handler::removeCallbacks) handler.removeCallbacks(repeatAction) - anchor?.let(onShowUndoPopup) - onHapticFeedback() - return - } else { - onHidePopup() - onHapticFeedback() } + onHapticFeedback() } - if (undoArmed) return - val shouldClear = if (clearArmed) { - upward > toPx(16) && horizontal <= toPx(120) - } else { - upward >= toPx(56) && horizontal <= toPx(96) + if (!undoArmed) { + val shouldClear = if (clearArmed) { + upward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP) + } else { + upward >= armDistance && onAxis + } + if (shouldClear != clearArmed) { + Log.d(TAG, "bs clearArmed=$shouldClear up=${upward.toInt()} h=${horizontal.toInt()}") + clearArmed = shouldClear + if (clearArmed) { + repeatStartAction?.let(handler::removeCallbacks) + handler.removeCallbacks(repeatAction) + } + onHapticFeedback() + } } - if (shouldClear == clearArmed) return - clearArmed = shouldClear - clearPreviewChanged?.invoke(shouldClear) - if (shouldClear) { - repeatStartAction?.let(handler::removeCallbacks) - handler.removeCallbacks(repeatAction) - anchor?.let(onShowClearPopup) - } else { - onHidePopup() - } - onHapticFeedback() + setHint( + when { + undoArmed -> GestureHint.UNDO_ARMED + clearArmed -> GestureHint.CLEAR_ARMED + undoAvailable && onAxis && downward >= previewDistance -> GestureHint.UNDO_PREVIEW + onAxis && upward >= previewDistance -> GestureHint.CLEAR_PREVIEW + else -> GestureHint.NONE + }, + ) } - fun finish(commit: Boolean) { + /** + * End the gesture. A release is treated as one last move first: the final + * UP carries coordinates that no MOVE reported, and a quick flick can cross + * the threshold exactly there. + */ + fun finish(commit: Boolean, rawX: Float? = null, rawY: Float? = null) { if (!active) return + if (commit && rawX != null && rawY != null) update(rawX, rawY) + Log.d(TAG, "bs finish commit=$commit clearArmed=$clearArmed undoArmed=$undoArmed repeat=$repeatStarted") val restoreClear = commit && undoArmed val clearAll = commit && clearArmed val deleteOnce = commit && !clearArmed && !undoArmed && !repeatStarted @@ -163,6 +199,7 @@ internal class BackspaceGestureController( handler.removeCallbacks(repeatAction) repeatStartAction?.let(handler::removeCallbacks) repeatStartAction = null + setHint(GestureHint.NONE) anchor?.apply { isPressed = false parent?.requestDisallowInterceptTouchEvent(false) @@ -170,33 +207,24 @@ internal class BackspaceGestureController( active = false pointerId = -1 - clearPreviewChanged?.invoke(false) clearArmed = false undoArmed = false repeatStarted = false repeatSuspended = false anchor = null - clearPreviewChanged = null onHidePopup() when { restoreClear -> { onHapticFeedback() onUndoClear() - // The gateway independently validates whether the one-shot - // snapshot is still legal. Either way this gesture consumes - // our local affordance so stale undo is never offered twice. - undoAvailable = false + // The gateway validates the one-shot snapshot and consumes it. } clearAll -> { onHapticFeedback() onClearAll() - undoAvailable = true - } - deleteOnce -> { - undoAvailable = false - onDeleteOne() } + deleteOnce -> onDeleteOne() } } @@ -210,7 +238,33 @@ internal class BackspaceGestureController( undoAvailable = false } - private companion object { + private fun setHint(next: GestureHint) { + if (next == hint) return + hint = next + anchor?.let { onGestureHint(it, next) } + } + + internal companion object { const val REPEAT_INTERVAL_MS = 60L + private const val TAG = "OpenIme" + + /** + * Travel from the press point that arms clear (up) or restore (down). + * Half a key height past its edge: a normal thumb flick reaches it, a + * wobble while holding repeat-delete does not. It used to be 56dp, more + * than a full key above the key centre, so ordinary flicks fell through + * to a single delete. + */ + const val CLEAR_ARM_DP = 32 + + /** The bubble appears (unarmed) once the thumb clearly moves vertically. */ + const val PREVIEW_DP = 8 + + /** Once armed, backing off this far (from the press point) disarms. */ + private const val RELEASE_DP = 16 + + /** Sideways drift allowed while arming, and while already armed. */ + private const val AXIS_TOLERANCE_DP = 96 + private const val HYSTERESIS_DRIFT_DP = 120 } } diff --git a/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt b/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt index f0b9210b..1a591168 100644 --- a/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt +++ b/app/src/main/java/llc/slacker/openime/BackspaceKeyFactory.kt @@ -1,14 +1,10 @@ package llc.slacker.openime import android.content.Context -import android.graphics.Color import android.util.Log -import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.accessibility.AccessibilityNodeInfo -import android.widget.FrameLayout -import android.widget.TextView /** * Builds and binds the production backspace key. Gesture state and thresholds @@ -58,60 +54,14 @@ internal class BackspaceKeyFactory( } } - val clearHint = TextView(context).apply { - text = "清空" - textSize = ImeTypographyTokens.CAPTION_SP - gravity = Gravity.CENTER - includeFontPadding = false - alpha = 0.72f - setTextColor(Color.GRAY) - isClickable = false - isFocusable = false - tag = "backspace-clear-hint" - contentDescription = null - visibility = View.INVISIBLE - } - addView( - clearHint, - FrameLayout.LayoutParams(toPx(30), toPx(14)).apply { - gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - topMargin = toPx(2) - }, - ) - - fun setClearHintActive(active: Boolean) { - clearHint.visibility = - if (gestureController.active) View.VISIBLE else View.INVISIBLE - if (active) { - val destructive = currentTokens().destructive - clearHint.text = "清空" - clearHint.setTextColor( - ImeDrawableFactory.contrastText(destructive), - ) - clearHint.background = ImeDrawableFactory.rounded( - destructive, - toPx(ImeGeometryTokens.BADGE_RADIUS_DP), - ) - clearHint.alpha = 1f - } else { - val secondary = currentTokens().keySecondaryText - clearHint.text = "↑ 清空" - clearHint.setTextColor(secondary) - clearHint.background = null - clearHint.alpha = 0.72f - } - } - setOnTouchListener { view, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - clearHint.alpha = 1f gestureController.begin( anchor = view, pointerId = event.getPointerId(event.actionIndex), rawX = event.rawX, rawY = event.rawY, - onClearPreviewChanged = ::setClearHintActive, ) if (debugLogging()) { Log.d( diff --git a/app/src/main/java/llc/slacker/openime/CandidateBarController.kt b/app/src/main/java/llc/slacker/openime/CandidateBarController.kt index 071e504a..6eb75e93 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/CandidateBarController.kt @@ -105,6 +105,7 @@ internal class CandidateBarController( compositionPreview: String, showCompositionWhenEmpty: Boolean, ) { + syncExpandControl(candidates.isNotEmpty()) val visibleCandidates = candidates.take(STRIP_LIMIT) if (renderedCandidates == visibleCandidates && renderedComposition == compositionPreview) return @@ -149,8 +150,8 @@ internal class CandidateBarController( createItem(index, candidate), LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { marginEnd = toPx(6) }, + toPx(42), + ).apply { marginEnd = toPx(4) }, ) } else { bindItem(existing, index, candidate) @@ -218,7 +219,7 @@ internal class CandidateBarController( } val previousScroll = if (renderedExpandedComposition == compositionPreview) { - (overlay.getChildAt(1) as? ScrollView)?.scrollY ?: 0 + (overlay.getChildAt(0) as? ScrollView)?.scrollY ?: 0 } else { 0 } @@ -238,14 +239,6 @@ internal class CandidateBarController( overlay.alpha = 0f overlay.translationY = toPx(8).toFloat() overlay.removeAllViews() - overlay.addView( - createHeader(), - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ), - ) - val scroll = ScrollView(context) val column = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -265,12 +258,14 @@ internal class CandidateBarController( } chunk.forEach { candidate -> rowView.addView( - createExpandedCandidate(candidate), + createExpandedCandidate(candidate).apply { + tag = if (candidate == candidates.first()) "candidate-grid-first" else "candidate-grid" + }, LinearLayout.LayoutParams( 0, keyRowHeightPx(), candidateColumnSpan(candidate).toFloat(), - ).apply { marginEnd = toPx(5) }, + ), ) } val remaining = 4 - chunk.sumOf(::candidateColumnSpan) @@ -329,7 +324,7 @@ internal class CandidateBarController( LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + minimumHeight = toPx(42) isFocusable = true importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES isClickable = true @@ -360,9 +355,9 @@ internal class CandidateBarController( val palette = tokens() val normalBackground = - if (index == 0) ImeSurfacePolicy.selectedSurface(palette) else Color.TRANSPARENT + if (index == 0) palette.keyBackground else Color.TRANSPARENT word.setTextColor( - if (index == 0) ImeSurfacePolicy.selectedText(palette) else palette.candidateText, + palette.candidateText, ) item.background = statefulBackground( normalBackground, @@ -403,7 +398,7 @@ internal class CandidateBarController( dialog.setOnShowListener { SetupUi.styleDialog(dialog, context, destructivePositive = true) } - dialog.show() + SetupUi.showDialog(dialog, context, row) } private companion object { diff --git a/app/src/main/java/llc/slacker/openime/CandidateEngine.kt b/app/src/main/java/llc/slacker/openime/CandidateEngine.kt index 998910c2..b3827bf3 100644 --- a/app/src/main/java/llc/slacker/openime/CandidateEngine.kt +++ b/app/src/main/java/llc/slacker/openime/CandidateEngine.kt @@ -161,7 +161,7 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { // A space or | is an explicit syllable boundary from the 9-key // "分词" key. Keep the boundary in the composing field so it stays // editable, but never expose it as a literal candidate character. - if (py.any { it == '|' || it.isWhitespace() }) { + if (py.any { it == '|' || it == '\'' || it.isWhitespace() }) { return getSegmentedCandidates(py, fuzzy) } @@ -253,6 +253,7 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { private fun getSegmentedCandidates(raw: String, fuzzy: Boolean): List { val parts = raw .replace('|', ' ') + .replace('\'', ' ') .trim() .split(Regex("\\s+")) .map { it.filter(Char::isLetter) } diff --git a/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt index 54d3fc04..47a4f1cc 100644 --- a/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/CandidatePipeline.kt @@ -1,5 +1,20 @@ package llc.slacker.openime +/** + * One way to read the open nine-key digits as pinyin, for the left rail. + * [syllables] are in order; [coversAll] is true when they spell every open + * digit (a whole reading such as `ni'hao`) and false for a first-syllable + * choice (`zhong`) after which the rest is still open. + */ +data class NineKeyReading( + val syllables: List, + val coversAll: Boolean, + /** False for a bare initial such as `w`: shown for orientation, not fixable. */ + val complete: Boolean = true, +) { + val display: String get() = syllables.joinToString("'") +} + /** Capability exposed by the service to a thin keyboard renderer. */ interface CandidateResolver { fun candidatesFor( @@ -13,8 +28,15 @@ interface CandidateResolver { segmentPrefix: String, preferredSuffix: String?, fuzzy: Boolean, + lockPreferred: Boolean = false, ): CandidatePipeline.NineKeyResolution + /** Syllables of [candidate] if it spells exactly all of [digits]; null otherwise. */ + fun nineKeyReadingFor(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() + fun nineKeyPathsFor(code: String?): List fun selectedNineKeyPathFor(code: String?): String? fun selectNineKeyPath(code: String, path: String) @@ -122,6 +144,7 @@ class CandidatePipeline internal constructor( segmentPrefix: String, preferredSuffix: String?, fuzzy: Boolean, + lockPreferred: Boolean, ): NineKeyResolution { val boundedDigits = digits .filter { it in '2'..'9' } @@ -136,7 +159,18 @@ class CandidatePipeline internal constructor( ) } - val nativeInput = NineKeyLocalDecoder.nativeCode(segmentPrefix, boundedDigits) + // A syllable the user tapped on the rail is a decision, not a guess: + // hand Rime its letters so zhong/xiong (same digits) stay distinct and + // the candidates agree with the pinyin shown. + val locked = preferredSuffix + ?.lowercase() + ?.trim() + ?.takeIf { lockPreferred && NineKeyLocalDecoder.digitsForPinyin(it) == boundedDigits } + val nativeInput = if (locked != null) { + NineKeyLocalDecoder.nativeCode(segmentPrefix + locked, "", lockLetters = true) + } else { + NineKeyLocalDecoder.nativeCode(segmentPrefix, boundedDigits, lockLetters = true) + } val effectivePreferred = preferredSuffix ?: nineKeyUiState.preferredSuffixFor(nativeInput, segmentPrefix) val local = nineKeyDecoder.resolve( @@ -144,7 +178,10 @@ class CandidatePipeline internal constructor( preferredSuffix = effectivePreferred, fuzzy = fuzzy, ) - val preview = segmentPrefix + local.previewSuffix + // The decoder separates guessed syllables with spaces; the pre-edit shows + // apostrophes everywhere (ni'hao), and only the user-fixed prefix may + // carry a boundary of its own. + val preview = segmentPrefix + local.previewSuffix.replace(' ', '\'') val displayPaths = buildList { // An incomplete-but-valid continuation (for example nia after // selecting ni and typing one more digit) must stay visible in the @@ -194,6 +231,40 @@ class CandidatePipeline internal constructor( internal fun nineKeyFallbackCandidatesFor(code: String): List = nineKeyFallbackRegistry.candidatesFor(code) + override fun nineKeyReadingsFor(digits: String, preferred: String?): List = + nineKeyDecoder.readingOptions(digits, preferred) + .map { NineKeyReading(it.syllables, it.coversAll, it.complete) } + + /** + * Put the words the typed digits spell *exactly* ahead of longer words that + * merely start with them. Rime ranks by weight, so typing xian (9426) could + * lead with 自从 (zi'cong, a prediction) while the pre-edit says xian; nine-key + * keyboards list exact readings first and predictions after. Rime's own + * order is kept inside each group; nothing is dropped. + */ + fun preferExactNineKeyMatches(code: String?, candidates: List): List { + val digits = code?.let(::digitsOfNineKeyCode) ?: return candidates + if (digits.isEmpty() || candidates.size < 2) return candidates + val (exact, rest) = candidates.partition { nineKeyDecoder.readingFor(digits, it) != null } + return if (exact.isEmpty() || rest.isEmpty()) candidates else exact + rest + } + + /** `ni'426` / `64'hao` / `64426` -> the digits all of them type: 64426. */ + private fun digitsOfNineKeyCode(code: String): String? { + val out = StringBuilder(code.length) + code.forEach { ch -> + when { + ch in '2'..'9' -> out.append(ch) + ch == '\'' || ch == ' ' || ch == '|' -> Unit + else -> out.append(NineKeyLocalDecoder.digitsForPinyin(ch.toString()) ?: return null) + } + } + return out.toString() + } + + override fun nineKeyReadingFor(digits: String, candidate: String): List? = + nineKeyDecoder.readingFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = nineKeyUiState.pathsFor(code) diff --git a/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt b/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt index ebeae88a..7707fd39 100644 --- a/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/ClipboardPanelController.kt @@ -13,6 +13,7 @@ import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import android.widget.ImageView /** * Owns clipboard/quick-phrase panel presentation and transient load state. @@ -56,41 +57,35 @@ internal class ClipboardPanelController( } fun render(reusePanel: Boolean = false) { - if (!reusePanel || expandedPanel.childCount == 0) { - expandedPanel.removeAllViews() - expandedPanel.addView( - createHeader("剪贴板"), - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ), - ) - } else { - while (expandedPanel.childCount > 1) { - expandedPanel.removeViewAt(expandedPanel.childCount - 1) + expandedPanel.removeAllViews() + val header = createHeader("") + while (header.childCount > 1) header.removeViewAt(header.childCount - 1) + header.addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL; tag = "segmented-track" + setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) + listOf("剪贴板", "常用语").forEachIndexed { index, label -> + addView(TextView(context).apply { + text = label; textSize = ImeTypographyTokens.BODY_SP; gravity = Gravity.CENTER; includeFontPadding = false + tag = if (tab == index) "segment-selected" else "segment-option" + contentDescription = label; isClickable = true; isFocusable = true + setOnClickListener { onFeedback(); tab = index; render(true) } + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) } - } - + }, LinearLayout.LayoutParams(0, toPx(34), 1f).apply { marginStart = toPx(36); marginEnd = toPx(20) }) + header.addView(TextView(context).apply { + text = if (tab == 0) "↻" else "+ 新增" + tag = if (tab == 0) "clipboard-refresh" else "quick-phrase-add" + textSize = if (tab == 0) 24f else 12f; gravity = Gravity.CENTER + contentDescription = if (tab == 0) "重新读取剪贴板" else "新增常用语" + isClickable = true; isFocusable = true + setOnClickListener { onFeedback(); if (tab == 0) render(true) else onOpenQuickPhraseEditor(null) } + }, LinearLayout.LayoutParams(toPx(if (tab == 0) 48 else 70), toPx(34))) + expandedPanel.addView(header, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(48))) val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(10), toPx(10), toPx(10), toPx(10)) + setPadding(toPx(12), toPx(10), toPx(12), toPx(6)) tag = "clipboard-panel" } - val tabs = createChipScroll( - listOf("剪贴板", "常用语"), - if (tab == 0) "剪贴板" else "常用语", - ) { label -> - tab = if (label == "剪贴板") 0 else 1 - render(reusePanel = true) - } - body.addView( - tabs, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(8) }, - ) - val column = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } @@ -219,26 +214,12 @@ internal class ClipboardPanelController( }, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(36), ).apply { topMargin = toPx(8) }, ) } private fun renderQuickPhrases(column: LinearLayout) { - column.addView( - createPanelButton("新增常用语", ImeTypographyTokens.BODY_SP, true).apply { - tag = "quick-phrase-add" - setOnClickListener { - onFeedback() - onOpenQuickPhraseEditor(null) - } - }, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(8) }, - ) - val phrases = QuickPhraseRepository.load(context) if (phrases.isEmpty()) { column.addView( @@ -251,14 +232,14 @@ internal class ClipboardPanelController( ) } + column.addView(createSectionTitle("${phrases.size} 条常用语 · 点选即输入"), wrapParams()) phrases.groupBy { it.category }.forEach { (category, grouped) -> - column.addView(createSectionTitle(category), wrapParams()) grouped.forEach { phrase -> column.addView( quickPhraseRow(phrase), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(60), ).apply { bottomMargin = toPx(7) }, ) } @@ -267,136 +248,43 @@ internal class ClipboardPanelController( private fun quickPhraseRow(phrase: QuickPhrase): LinearLayout = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - tag = "phrase-card" - addView( - createKey(phrase.text, ImeTypographyTokens.BODY_SP) { - onCharacter(phrase.text) - }.apply { - setPadding(toPx(12), 0, toPx(12), 0) - tag = "phrase:${phrase.id}" - contentDescription = - if (phrase.inputCode.isBlank()) { - "常用语:${phrase.text}" - } else { - "常用语:${phrase.text},输入码${phrase.inputCode}" - } - }, - LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f).apply { - marginEnd = toPx(5) - }, - ) - addView( - createPanelButton("编辑", ImeTypographyTokens.CAPTION_SP, true).apply { - tag = "phrase-edit:${phrase.id}" - setOnClickListener { - onFeedback() - onOpenQuickPhraseEditor(phrase) - } - }, - LinearLayout.LayoutParams(toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(ImeGeometryTokens.TOUCH_TARGET_DP)).apply { - marginEnd = toPx(5) - }, - ) - addView( - createPanelButton("删除", ImeTypographyTokens.CAPTION_SP, true).apply { - tag = "phrase-delete:${phrase.id}" - setOnClickListener { - onFeedback() - val dialog = AlertDialog.Builder(context) - .setTitle("删除常用语?") - .setMessage(phrase.text) - .setNegativeButton("取消", null) - .setPositiveButton("删除") { _, _ -> - QuickPhraseRepository.remove(context, phrase.id) - render(reusePanel = true) - } - .create() - dialog.setOnShowListener { - SetupUi.styleDialog( - dialog, - context, - destructivePositive = true, - ) - } - dialog.show() - } - }, - LinearLayout.LayoutParams(toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(ImeGeometryTokens.TOUCH_TARGET_DP)), - ) + orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL + tag = "phrase-card"; setPadding(toPx(14), 0, toPx(4), 0) + addView(TextView(context).apply { + text = phrase.text; textSize = ImeTypographyTokens.TITLE_SP; maxLines = 2; ellipsize = TextUtils.TruncateAt.END + tag = "phrase:${phrase.id}"; contentDescription = "常用语:${phrase.text}" + isClickable = true; isFocusable = true + setOnClickListener { onFeedback(); onCharacter(phrase.text) } + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + addView(iconButton(R.drawable.ic_edit, "编辑常用语", "phrase-edit:${phrase.id}") { onOpenQuickPhraseEditor(phrase) }, + LinearLayout.LayoutParams(toPx(40), toPx(48))) + addView(iconButton(R.drawable.ic_delete, "删除常用语", "phrase-delete:${phrase.id}") { + val dialog = AlertDialog.Builder(context).setTitle("删除常用语?").setMessage("“${phrase.text}” 将被移除。") + .setNegativeButton("取消", null).setPositiveButton("删除") { _, _ -> QuickPhraseRepository.remove(context, phrase.id); render(true) }.create() + dialog.setOnShowListener { SetupUi.styleDialog(dialog, context, destructivePositive = true) } + SetupUi.showDialog(dialog, context, expandedPanel) + }, LinearLayout.LayoutParams(toPx(40), toPx(48))) } private fun historyCard(entry: ClipboardEntry): LinearLayout = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(10), toPx(12), toPx(8)) - minimumHeight = toPx(70) - tag = "clip-card" - contentDescription = "剪贴板:${entry.text},点击使用" - if (Build.VERSION.SDK_INT >= 30) { - stateDescription = if (entry.pinned) "已置顶" else "未置顶" - } - isClickable = true - isFocusable = true - setOnClickListener { - onFeedback() - onCharacter(entry.text) - } - - addView( - TextView(context).apply { - text = entry.text - textSize = ImeTypographyTokens.PANEL_BODY_SP - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, - wrapParams(), - ) + orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL + setPadding(toPx(14), toPx(4), toPx(4), toPx(4)); minimumHeight = toPx(56) + tag = "clip-card"; contentDescription = "剪贴板:${entry.text},点击使用" + isClickable = true; isFocusable = true + setOnClickListener { onFeedback(); onCharacter(entry.text) } + addView(TextView(context).apply { text = entry.text; textSize = ImeTypographyTokens.TITLE_SP; maxLines = 1; ellipsize = TextUtils.TruncateAt.END }, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + addView(iconButton(R.drawable.ic_pin, if (entry.pinned) "取消置顶" else "置顶", "clip-pin:${entry.text}") { + ClipboardHistoryRepository.togglePin(context, entry.text); render(true) + }.apply { isSelected = entry.pinned }, LinearLayout.LayoutParams(toPx(40), toPx(48))) + } - val meta = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - } - meta.addView( - TextView(context).apply { - text = if (entry.pinned) { - "已置顶" - } else { - DateUtils.getRelativeTimeSpanString( - entry.timestamp, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - ) - } - textSize = ImeTypographyTokens.CAPTION_SP - }, - LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), - ) - meta.addView( - createPanelButton( - if (entry.pinned) "取消置顶" else "置顶", - ImeTypographyTokens.CAPTION_SP, - true, - ).apply { - tag = "clip-pin:${entry.text}" - setOnClickListener { - onFeedback() - ClipboardHistoryRepository.togglePin(context, entry.text) - render(reusePanel = true) - } - }, - wrapParams(), - ) - meta.addView( - createPanelButton("使用", ImeTypographyTokens.CAPTION_SP, true).apply { - tag = "clip-use:${entry.text}" - setOnClickListener { - onFeedback() - onCharacter(entry.text) - } - }, - wrapParams(), - ) - addView(meta, wrapParams()) + private fun iconButton(icon: Int, label: String, tagValue: String, onClick: () -> Unit): ImageView = + ImageView(context).apply { + setImageResource(icon); scaleType = ImageView.ScaleType.CENTER_INSIDE + tag = tagValue; contentDescription = label; isClickable = true; isFocusable = true + setOnClickListener { onFeedback(); onClick() } } private fun addRetentionControls(body: LinearLayout) { @@ -422,12 +310,14 @@ internal class ClipboardPanelController( }, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), ) + // The buttons are painted 36dp inside this 48dp row (see the theme), + // so the visible gap above them is unchanged. body.addView( row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { topMargin = toPx(6) }, + ), ) applyTheme() } @@ -465,7 +355,7 @@ internal class ClipboardPanelController( LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { topMargin = toPx(6) }, + ), ) applyTheme() row.findViewWithTag("clipboard-clear-confirm")?.requestFocus() @@ -479,7 +369,7 @@ internal class ClipboardPanelController( text = label textSize = ImeTypographyTokens.PANEL_NOTE_SP gravity = Gravity.CENTER - minHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + minHeight = 0 minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) isClickable = true isFocusable = true diff --git a/app/src/main/java/llc/slacker/openime/CrashGuard.kt b/app/src/main/java/llc/slacker/openime/CrashGuard.kt new file mode 100644 index 00000000..27a75d09 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/CrashGuard.kt @@ -0,0 +1,233 @@ +package llc.slacker.openime + +import android.content.Context +import android.os.Build +import android.util.Log +import java.io.File + +/** + * Crash resilience for a keyboard that has to keep working inside other apps. + * + * An input method that dies is replaced by the system with another keyboard, so + * the goals are: remember what happened (locally; the app has no INTERNET + * permission), never turn one bad key press into a crash, and stop a crash loop + * from locking the user out of typing. + * + * What is stored never contains typed text: only the time, the app version, the + * exception types and stack frames. + */ +internal object CrashGuard { + private const val TAG = "OpenImeCrash" + private const val PREFS = "openime_crash_guard" + private const val KEY_TIMES = "crash_times" + private const val KEY_LAST = "last_report" + private const val KEY_HANDLED = "handled_count" + private const val KEY_EXIT_SEEN = "exit_seen" + private const val MAX_TIMES = 10 + private const val MAX_FRAMES = 14 + + /** This many crashes within [LOOP_WINDOW_MS] switch the next start to safe mode. */ + internal const val LOOP_CRASHES = 3 + internal const val LOOP_WINDOW_MS = 10 * 60 * 1000L + + @Volatile + private var installed = false + + /** Idempotent; chains to whatever handler was installed before. */ + fun install(context: Context) { + if (installed) return + installed = true + val app = context.applicationContext + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { recordCrash(app, thread.name, throwable) } + previous?.uncaughtException(thread, throwable) + } + } + + internal fun recordCrash(context: Context, threadName: String, throwable: Throwable, now: Long = System.currentTimeMillis()) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + now + prefs.edit() + .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES))) + .putString(KEY_LAST, report(context, "crash", threadName, throwable, now)) + .commit() // the process is about to die: no async apply() + } + + /** An exception that was caught and survived; kept for diagnostics, not counted as a crash. */ + fun recordHandled(context: Context, where: String, throwable: Throwable, now: Long = System.currentTimeMillis()) { + runCatching { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs.edit() + .putInt(KEY_HANDLED, prefs.getInt(KEY_HANDLED, 0) + 1) + .putString(KEY_LAST, report(context, "handled in $where", Thread.currentThread().name, throwable, now)) + .apply() + } + } + + /** A native startup that never came back counts as a crash, even though no Java handler ran. */ + fun recordNativeStartupCrash(context: Context, now: Long = System.currentTimeMillis()) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + now + prefs.edit() + .putString(KEY_TIMES, formatTimes(times.takeLast(MAX_TIMES))) + .putString(KEY_LAST, "${header(context, "native crash while starting librime", "local-rime-startup", now)}\n(no Java stack: the process died inside native code)") + .commit() + } + + /** + * Android 11+ remembers why earlier processes died. Native crashes (no Java + * handler ever runs) and ANRs (the keyboard froze) of this app are folded + * into the crash history here, once each, so a freeze loop also ends in safe mode. + */ + fun ingestProcessExitReasons(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + runCatching { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val seen = prefs.getLong(KEY_EXIT_SEEN, 0L) + val bad = manager.getHistoricalProcessExitReasons(context.packageName, 0, 8).filter { + it.timestamp > seen && it.reason in countedExitReasons + } + if (bad.isEmpty()) return + val times = parseTimes(prefs.getString(KEY_TIMES, null)) + bad.map { it.timestamp } + val newest = bad.maxBy { it.timestamp } + prefs.edit() + .putString(KEY_TIMES, formatTimes(times.sorted().takeLast(MAX_TIMES))) + .putLong(KEY_EXIT_SEEN, newest.timestamp) + .putString(KEY_LAST, "${header(context, "previous process ended: ${exitReasonName(newest.reason)}", "process", newest.timestamp)}\n(reported by Android; no stack)") + .commit() + } + } + + /** + * Whether the process before this one ended in a native crash. null when + * Android cannot say (before Android 11). A marker left by a startup that was + * merely killed (force stop, update, low memory) must not count as a crash. + */ + fun previousExitWasNativeCrash(context: Context): Boolean? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + return runCatching { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val latest = manager.getHistoricalProcessExitReasons(context.packageName, 0, 1).firstOrNull() + ?: return@runCatching null + latest.reason == android.app.ApplicationExitInfo.REASON_CRASH_NATIVE + }.getOrNull() + } + + /** Leave safe mode now, e.g. from the About screen. */ + fun clearHistory(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_TIMES).commit() + } + + private val countedExitReasons: Set by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setOf( + android.app.ApplicationExitInfo.REASON_CRASH, + android.app.ApplicationExitInfo.REASON_CRASH_NATIVE, + android.app.ApplicationExitInfo.REASON_ANR, + android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE, + ) + } else { + emptySet() + } + } + + @android.annotation.SuppressLint("NewApi") + private fun exitReasonName(reason: Int): String = when (reason) { + android.app.ApplicationExitInfo.REASON_CRASH -> "crash" + android.app.ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash" + android.app.ApplicationExitInfo.REASON_ANR -> "not responding (ANR)" + android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> "initialisation failure" + else -> "reason $reason" + } + + fun isSafeMode(context: Context, now: Long = System.currentTimeMillis()): Boolean = + inCrashLoop(parseTimes(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_TIMES, null)), now) + + fun lastReport(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LAST, null) + + internal fun inCrashLoop(times: List, now: Long): Boolean = + times.count { now - it in 0..LOOP_WINDOW_MS } >= LOOP_CRASHES + + internal fun parseTimes(raw: String?): List = + raw.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() } + + internal fun formatTimes(times: List): String = times.joinToString(",") + + private fun header(context: Context, kind: String, thread: String, now: Long): String { + val version = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty() + return "openIME $version | $kind | thread=$thread | at=$now | android=${Build.VERSION.SDK_INT} | device=${Build.MANUFACTURER} ${Build.MODEL}" + } + + /** Exception types and stack frames only. Messages are left out on purpose: they can quote user text. */ + internal fun report(context: Context, kind: String, thread: String, throwable: Throwable, now: Long): String = + buildString { + append(header(context, kind, thread, now)).append('\n') + var current: Throwable? = throwable + var depth = 0 + while (current != null && depth < 4) { + append(if (depth == 0) "" else "caused by ").append(current.javaClass.name).append('\n') + current.stackTrace.take(MAX_FRAMES).forEach { append(" at ").append(it).append('\n') } + current = current.cause?.takeIf { it !== current } + depth++ + } + } + + internal fun log(message: String, throwable: Throwable? = null) { + runCatching { Log.e(TAG, message, throwable) } + } +} + +/** + * Librime lives in native code: a segmentation fault there cannot be caught and + * kills the whole keyboard process, and with a damaged compiled dictionary or + * user database it would do so on every start. A marker file is written before + * native startup and removed once the engine proves itself, so a marker that is + * still there at the next start means the last start died natively. Each such + * death escalates: clear the compiled data, then set the user database aside, + * then run without the native engine. + */ +internal class RimeStartupRecovery(private val stateDir: File) { + enum class Action { NORMAL, CLEAN_BUILD, RESET_USER_DATA, SKIP_NATIVE } + + private val marker = File(stateDir, ".rime-startup-pending") + private val failuresFile = File(stateDir, ".rime-startup-failures") + + /** Call before native startup. Returns what to do and arms the marker unless native is skipped. */ + fun begin(): Action { + stateDir.mkdirs() + if (marker.exists()) writeFailures(failures() + 1) + val action = when (failures()) { + 0 -> Action.NORMAL + 1 -> Action.CLEAN_BUILD + 2 -> Action.RESET_USER_DATA + else -> Action.SKIP_NATIVE + } + if (action == Action.SKIP_NATIVE) marker.delete() else marker.writeText("pending\n") + return action + } + + /** The engine started and passed its health probe. */ + fun succeeded() { + marker.delete() + failuresFile.delete() + } + + /** Startup ended with a Java exception: not a crash, so it is not counted. */ + fun failedWithoutCrash() { + marker.delete() + } + + /** True when the previous native startup never finished. */ + fun lastStartupDiedNatively(): Boolean = marker.exists() + + fun failures(): Int = runCatching { failuresFile.readText().trim().toInt() }.getOrDefault(0) + + private fun writeFailures(value: Int) { + failuresFile.writeText("$value\n") + } +} diff --git a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt b/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt index 15d70de5..191749f4 100644 --- a/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt +++ b/app/src/main/java/llc/slacker/openime/EditorInfoAdapter.kt @@ -15,6 +15,12 @@ object EditorInfoAdapter { URL, PASSWORD, MULTILINE, + + /** Date / time fields: digits and separators. */ + DATETIME, + + /** TYPE_NULL: terminals, games, remote desktops. They take key events, not text editing. */ + RAW_KEYS, UNKNOWN, } @@ -23,6 +29,8 @@ object EditorInfoAdapter { val cls = t and InputType.TYPE_MASK_CLASS val variation = t and InputType.TYPE_MASK_VARIATION return when { + info != null && t == InputType.TYPE_NULL -> EditorKind.RAW_KEYS + cls == InputType.TYPE_CLASS_DATETIME -> EditorKind.DATETIME cls == InputType.TYPE_CLASS_TEXT && variation in setOf( InputType.TYPE_TEXT_VARIATION_PASSWORD, InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, @@ -52,10 +60,12 @@ object EditorInfoAdapter { EditorKind.NUMBER, EditorKind.DECIMAL, EditorKind.PHONE, + EditorKind.DATETIME, -> KeyboardMode.DIGITS EditorKind.EMAIL, EditorKind.URL, EditorKind.PASSWORD, + EditorKind.RAW_KEYS, -> KeyboardMode.ENGLISH_26 else -> KeyboardMode.PINYIN_26 } @@ -75,6 +85,8 @@ object EditorInfoAdapter { EditorKind.NUMBER, EditorKind.DECIMAL, EditorKind.PHONE, + EditorKind.DATETIME, + EditorKind.RAW_KEYS, EditorKind.UNKNOWN, -> return false EditorKind.TEXT, diff --git a/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt b/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt index db2515d5..8c6ce47e 100644 --- a/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt +++ b/app/src/main/java/llc/slacker/openime/EmojiCellFactory.kt @@ -18,6 +18,7 @@ import java.util.concurrent.Executors */ internal class EmojiCellFactory( private val context: Context, + private val toPx: (Int) -> Int = { (it * context.resources.displayMetrics.density).toInt() }, private val onFeedback: () -> Unit, private val onEmojiSelected: (String) -> Unit, ) { @@ -53,10 +54,7 @@ internal class EmojiCellFactory( } cell.addView( image, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ), + FrameLayout.LayoutParams(toPx(28), toPx(28)).apply { gravity = Gravity.CENTER }, ) requestBitmap(context, assetPath) { bitmap -> if (image.tag == assetPath) { @@ -74,6 +72,8 @@ internal class EmojiCellFactory( return cell } + private fun dp(value: Int): Int = (value * context.resources.displayMetrics.density).toInt() + private companion object { private const val CACHE_BYTES = 4 * 1024 * 1024 diff --git a/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt b/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt index c3efa443..fc931652 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt +++ b/app/src/main/java/llc/slacker/openime/FloatingKeyboardController.kt @@ -71,6 +71,8 @@ internal class FloatingKeyboardController( fun applyTheme(tokens: ImeTheme.Tokens) { (handle as? DragHandleView)?.setDotColor(tokens.border) + handle.setBackgroundColor(tokens.toolbarBackground) + mainDock.elevation = if (enabled) toPx(10).toFloat() else 0f if (enabled) { mainDock.background = ImeDrawableFactory.rounded( tokens.keyboardBackground, @@ -104,21 +106,13 @@ internal class FloatingKeyboardController( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val radius = toPx(2) - val gapX = toPx(7) - val gapY = toPx(7) - val startX = width / 2f - gapX - val startY = height / 2f - gapY / 2f - for (row in 0..1) { - for (column in 0..2) { - canvas.drawCircle( - startX + column * gapX, - startY + row * gapY, - radius.toFloat(), - dotPaint, - ) - } - } + val halfWidth = toPx(18).toFloat() + val halfHeight = toPx(2).toFloat() + canvas.drawRoundRect( + width / 2f - halfWidth, height / 2f - halfHeight, + width / 2f + halfWidth, height / 2f + halfHeight, + halfHeight, halfHeight, dotPaint, + ) } } } diff --git a/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt b/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt index 365dd65d..1f80ec0c 100644 --- a/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt +++ b/app/src/main/java/llc/slacker/openime/FloatingWindowController.kt @@ -158,7 +158,7 @@ internal class FloatingWindowController( private fun floatingWidth(screenWidth: Int): Int { val landscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val percent = floatingWidthPercent().coerceIn(72, 96) + val percent = floatingWidthPercent().coerceIn(72, 100) val preferred = (screenWidth * percent / 100f).toInt() val maximum = dp( if (landscape) ImeGeometryTokens.FLOATING_LANDSCAPE_WIDTH_DP else 420, diff --git a/app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt b/app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt index d0d55eca..7f44066d 100644 --- a/app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt +++ b/app/src/main/java/llc/slacker/openime/FluentEmojiAssetRepository.kt @@ -7,11 +7,11 @@ object FluentEmojiAssetRepository { private const val ROOT = "emoji/fluent" @Volatile - private var availableFiles: Set? = null + private var availableFiles: Map? = null fun pathFor(context: Context, emoji: String): String? { val fileName = fileNameFor(emoji) - return if (fileName in files(context)) "$ROOT/$fileName" else null + return files(context)[fileName] } internal fun fileNameFor(emoji: String): String = @@ -24,12 +24,15 @@ object FluentEmojiAssetRepository { * merely to probe whether a PNG existed. List the directory once per * process instead; actual bitmap decoding remains on the background pool. */ - private fun files(context: Context): Set { + private fun files(context: Context): Map { availableFiles?.let { return it } return synchronized(this) { availableFiles ?: runCatching { - context.applicationContext.assets.list(ROOT)?.toSet().orEmpty() - }.getOrDefault(emptySet()).also { availableFiles = it } + val assets = context.applicationContext.assets + val bundled = assets.list(ROOT).orEmpty().associateWith { "$ROOT/$it" } + val reference = "emoji/reference" + bundled + assets.list(reference).orEmpty().associateWith { "$reference/$it" } + }.getOrDefault(emptyMap()).also { availableFiles = it } } } } diff --git a/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt new file mode 100644 index 00000000..a019b810 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/HardwareKeyPolicy.kt @@ -0,0 +1,107 @@ +package llc.slacker.openime + +import android.view.KeyEvent + +/** What a physical key should do while the keyboard is in Chinese pinyin mode. */ +internal sealed interface HardwareKeyAction { + /** Leave the key to the app. */ + data object PassThrough : HardwareKeyAction + + /** The key is ours and does nothing (e.g. a digit that selects no candidate). */ + data object Consume : HardwareKeyAction + + /** Commit what is being composed, then let the key reach the app. */ + data object FinishCompositionThenPassThrough : HardwareKeyAction + + data class Letter(val char: Char) : HardwareKeyAction + data object Backspace : HardwareKeyAction + data object Space : HardwareKeyAction + data object Enter : HardwareKeyAction + data object Cancel : HardwareKeyAction + data object Apostrophe : HardwareKeyAction + data class SelectCandidate(val index: Int) : HardwareKeyAction + + /** Full-width punctuation; the first candidate is committed first when a composition is open. */ + data class Punctuation(val text: String, val commitFirstCandidate: Boolean) : HardwareKeyAction +} + +internal data class HardwareKey( + val keyCode: Int, + /** The character the key produces with the current layout and modifiers (0 for none). */ + val unicode: Int, + val shift: Boolean = false, + val ctrl: Boolean = false, + val alt: Boolean = false, + val meta: Boolean = false, + val capsLock: Boolean = false, + val repeat: Boolean = false, +) + +internal data class HardwareContext( + /** The keyboard is in the Chinese 26-key mode and the editor accepts composing. */ + val pinyinMode: Boolean, + val composing: Boolean, + val candidateCount: Int, + /** The character before the cursor, when known: "3.14" must keep its ASCII dot. */ + val charBeforeCursor: Char?, +) + +/** + * Physical-keyboard typing for Chinese: letters compose pinyin, space picks the + * first candidate, 1-9 pick a candidate, Enter keeps the typed pinyin, Esc + * cancels. Everything else, and every shortcut, belongs to the app. Pure, so + * it can be tested without a device. + */ +internal object HardwareKeyPolicy { + private val modifierKeys = setOf( + KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT, + KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT, + KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT, + KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT, + KeyEvent.KEYCODE_CAPS_LOCK, KeyEvent.KEYCODE_NUM_LOCK, KeyEvent.KEYCODE_FUNCTION, + ) + + private val fullWidth = mapOf( + ',' to ",", '.' to "。", '?' to "?", '!' to "!", ';' to ";", ':' to ":", + '(' to "(", ')' to ")", + ) + + /** These keep their ASCII form right after a digit (3.14, 12:30, 1,000). */ + private val asciiAfterDigit = setOf(',', '.', ':') + + fun decide(key: HardwareKey, context: HardwareContext): HardwareKeyAction { + if (!context.pinyinMode) return HardwareKeyAction.PassThrough + if (key.keyCode in modifierKeys) return HardwareKeyAction.PassThrough + if (key.ctrl || key.alt || key.meta) return HardwareKeyAction.PassThrough + + val composing = context.composing + when (key.keyCode) { + KeyEvent.KEYCODE_DEL -> return if (composing) HardwareKeyAction.Backspace else HardwareKeyAction.PassThrough + KeyEvent.KEYCODE_SPACE -> + return if (!composing) HardwareKeyAction.PassThrough + else if (key.repeat) HardwareKeyAction.Consume else HardwareKeyAction.Space + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> + return if (!composing) HardwareKeyAction.PassThrough + else if (key.repeat) HardwareKeyAction.Consume else HardwareKeyAction.Enter + KeyEvent.KEYCODE_ESCAPE -> + return if (composing && !key.repeat) HardwareKeyAction.Cancel else HardwareKeyAction.PassThrough + } + + val char = key.unicode.takeIf { it in 0x20..0x7e }?.toChar() ?: return HardwareKeyAction.PassThrough + return when { + char in 'a'..'z' && !key.shift && !key.capsLock -> HardwareKeyAction.Letter(char) + // A capital letter is English: close the composition, then type it. + char in 'A'..'Z' -> if (composing) HardwareKeyAction.FinishCompositionThenPassThrough else HardwareKeyAction.PassThrough + char in '1'..'9' && composing -> + if (char - '1' < context.candidateCount) HardwareKeyAction.SelectCandidate(char - '1') else HardwareKeyAction.Consume + char == '\'' && composing -> HardwareKeyAction.Apostrophe + char in fullWidth -> { + val numeric = char in asciiAfterDigit && context.charBeforeCursor?.let { it in '0'..'9' } == true && !composing + if (numeric) HardwareKeyAction.PassThrough + else HardwareKeyAction.Punctuation(fullWidth.getValue(char), commitFirstCandidate = composing) + } + composing -> HardwareKeyAction.FinishCompositionThenPassThrough + else -> HardwareKeyAction.PassThrough + } + } +} diff --git a/app/src/main/java/llc/slacker/openime/ImeData.kt b/app/src/main/java/llc/slacker/openime/ImeData.kt index ca847652..21d7cb8f 100644 --- a/app/src/main/java/llc/slacker/openime/ImeData.kt +++ b/app/src/main/java/llc/slacker/openime/ImeData.kt @@ -609,6 +609,15 @@ object ImeData { ), ) + /** First screen of the 笑脸 tab: the order the reference design shows. */ + val referenceSmileys: List = listOf( + "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", + "🥹", "😊", "😇", "🙂", "🙃", "😉", "😌", "😍", + "🥰", "😘", "😗", "😙", "😚", "☺️", "😛", "😝", + "😜", "🤪", "😳", "🥺", "🤓", "😎", "🥸", "🤩", + "🥳", "😏", "😒", "😞", "😔", "😟", "😕", "🙁", + ) + /** Unicode Smileys & Emotion collection used by the Fluent asset bundle. */ val fluentSmileys: List = listOf( "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "🫠", "😉", "😊", "😇", diff --git a/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt index 8dac18cc..2082a10e 100644 --- a/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/ImeDesignTokens.kt @@ -11,6 +11,8 @@ import android.graphics.Color * prevents each panel from inventing another near-identical radius or size. */ internal object ImeGeometryTokens { + /** Portrait key row height on the reference design's 390-unit canvas. */ + const val KEY_ROW_HEIGHT_DP = 54 const val KEY_RADIUS_DP = 8 const val CONTROL_RADIUS_DP = 12 const val CARD_RADIUS_DP = 16 @@ -25,7 +27,7 @@ internal object ImeGeometryTokens { const val FIELD_HEIGHT_DP = 56 const val PRIMARY_ROW_HEIGHT_DP = 56 const val SETTING_ROW_HEIGHT_DP = 56 - const val TOOL_CARD_HEIGHT_DP = 64 + const val TOOL_CARD_HEIGHT_DP = 92 const val VOICE_CONTROL_HEIGHT_DP = TOUCH_TARGET_DP const val SWITCH_WIDTH_DP = 48 const val SWITCH_HEIGHT_DP = 28 @@ -44,7 +46,7 @@ internal object ImeGeometryTokens { // The toolbar itself is compact; the top zone still reserves the larger // composed height so typing never moves the keyboard window. const val TOOLBAR_HEIGHT_DP = TOUCH_TARGET_DP - const val TOP_BAR_HEIGHT_DP = 56 + const val TOP_BAR_HEIGHT_DP = 48 const val ICON_SIZE_DP = 24 const val STEP_MARK_SIZE_DP = 28 const val HERO_MARK_SIZE_DP = 72 @@ -70,13 +72,21 @@ internal object ImeMotionTokens { const val STANDARD_TRANSITION_MS = 160L } -/** Six text roles shared by the keyboard and every app surface. */ +/** + * Text roles shared by the keyboard and every app surface. The six keyboard + * roles come first; the last three are the sizes the reference design uses + * outside them (helper text on setup/data pages, a glyph handle, and the large + * digit / symbol keys), named here so no screen invents its own number. + */ internal object ImeTypographyTokens { const val CAPTION_SP = 11f + const val SMALL_SP = 12f const val BODY_SP = 14f const val TITLE_SP = 16f const val CANDIDATE_SP = 18f + const val GLYPH_SP = 20f const val KEY_LETTER_SP = 21f + const val SYMBOL_SP = 24f const val DISPLAY_SP = 28f // Compatibility names for existing callers; every alias resolves to the @@ -149,23 +159,24 @@ enum class ImeTheme(val key: String, val label: String) { } val base = if (useDark) { Tokens( - c("#6EC3F7"), c("#1C1C1E"), c("#242426"), c("#262628"), c("#F2F2F7"), + c("#6EC3F7"), c("#1C1C1E"), c("#242426"), c("#242426"), c("#F2F2F7"), c("#3A3A3C"), c("#F2F2F7"), c("#AEAEB2"), c("#2C2C2E"), c("#F2F2F7"), c("#4A4A4D"), - c("#242426"), c("#48484A"), c("#2C2C2E"), c("#202022"), c("#242426"), - c("#3A3A3C"), c("#F2F2F7"), c("#2C2C2E"), c("#F2F2F7"), c("#303033"), c("#242426"), + c("#242426"), c("#48484A"), c("#2C2C2E"), c("#1C1C1E"), c("#242426"), + c("#3A3A3C"), c("#F2F2F7"), c("#2C2C2E"), c("#F2F2F7"), c("#303032"), c("#242426"), success = c("#5BD08A"), ) } else { Tokens( - c("#1D9BF0"), c("#D5D8DE"), c("#EEF0F3"), c("#F7F8FA"), c("#1F2023"), - c("#FFFFFF"), c("#1C1C1E"), c("#6E6E73"), c("#C5C9D1"), c("#2C2D31"), c("#DDE1E7"), - c("#F2F3F5"), c("#B7BCC5"), c("#C5C9D1"), c("#F1F2F4"), c("#F8F9FA"), - c("#FFFFFF"), c("#1C1C1E"), c("#C5C9D1"), c("#2C2D31"), c("#FFFFFF"), c("#E4E7EB"), + c("#1D9BF0"), c("#D5D8DF"), c("#EFF0F4"), c("#EFF0F4"), c("#1F2023"), + c("#FFFFFF"), c("#1C1C1E"), c("#6D6D72"), c("#C5C9D2"), c("#2C2D31"), c("#DDE1E7"), + c("#F2F3F5"), c("#B7BCC5"), c("#C5C9D2"), c("#F1F2F4"), c("#F8F9FA"), + c("#FFFFFF"), c("#1C1C1E"), c("#C5C9D2"), c("#2C2D31"), c("#FFFFFF"), c("#E4E7EC"), success = c("#1F8A4C"), textSecondaryRole = c("#6D6D72"), ) } val accent = accentOverride ?: return base + if (accent == AccentPalette.parse(AccentPalette.DEFAULT)) return base return base.copy(primary = accent) } } @@ -177,6 +188,9 @@ enum class ImeTheme(val key: String, val label: String) { */ internal object ImeSurfacePolicy { const val DISABLED_ALPHA = 0.42f + private const val TEXT_CONTRAST = 4.5 + private const val SHADE_STEP = 0.03f + private const val MAX_SHADE_STEPS = 24 fun isDark(tokens: ImeTheme.Tokens): Boolean = ImeContrastPolicy.relativeLuminance(tokens.keyboardBackground) < 0.16 @@ -184,18 +198,33 @@ internal object ImeSurfacePolicy { fun selectedSurface(tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.blend( tokens.primary, - tokens.candidateBackground, - if (isDark(tokens)) 0.24f else 0.12f, + tokens.keyboardBackground, + if (isDark(tokens)) 0.24f else 0.14f, ) + /** + * The accent as text on the keyboard surface. Derived from the active + * accent (never a fixed blue) so a custom accent stays one colour family + * across pre-edit text, selected items and icons. + */ fun selectedText(tokens: ImeTheme.Tokens): Int = - if ( - ImeContrastPolicy.contrastRatio(tokens.primary, selectedSurface(tokens)) >= 4.5 - ) { - tokens.primary - } else { - tokens.keyText + accentTextOn(tokens.primary, tokens.keyboardBackground) + + /** + * [accent] itself when it already reads on [background] (4.5:1); otherwise + * the nearest shade of the same hue that does. Light surfaces get a + * darker shade, dark surfaces a lighter one. + */ + fun accentTextOn(accent: Int, background: Int): Int { + if (ImeContrastPolicy.contrastRatio(accent, background) >= TEXT_CONTRAST) return accent + val darker = ImeContrastPolicy.relativeLuminance(background) >= 0.2 + var shade = accent + repeat(MAX_SHADE_STEPS) { + shade = adjustHslLightness(shade, if (darker) -SHADE_STEP else SHADE_STEP) + if (ImeContrastPolicy.contrastRatio(shade, background) >= TEXT_CONTRAST) return shade } + return shade + } fun pressedSurface(base: Int, tokens: ImeTheme.Tokens): Int = ImeDrawableFactory.blend( @@ -262,6 +291,10 @@ internal object ImeSurfacePolicy { if (isDark(tokens)) 0.18f else 0.10f, ) + /** Outlined destructive controls (clear / delete): a red tuned to read on panel heads. */ + fun destructiveLabel(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#FF6771") else Color.parseColor("#D60016") + fun destructiveText(tokens: ImeTheme.Tokens): Int { val surface = destructiveSurface(tokens) return if (ImeContrastPolicy.contrastRatio(tokens.destructive, surface) >= 4.5) { diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt index c1649360..ce7a4360 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt @@ -29,12 +29,23 @@ class ImeKeyView( iconRes: Int = 0, mainTextSize: Float = ImeTypographyTokens.KEY_LETTER_SP, fitMainText: Boolean = false, + private val toPx: (Int) -> Int = { (it * context.resources.displayMetrics.density).toInt() }, ) : FrameLayout(context) { private val density = resources.displayMetrics.density private var touchFeedbackPending = false private var touchGeneration = 0L private val baseMainTextSize = mainTextSize + private val fitMain = fitMainText + + /** + * Key labels follow the system font size only up to [MAX_LABEL_FONT_SCALE]. + * A key has a fixed width, so at 200% a plain "m" no longer fits and became + * "…", and "中/英" lost its last glyph. Everything else (candidates, panels, + * settings) still follows the system setting in full. + */ + private fun labelPx(sp: Float): Float = + sp * density * minOf(resources.configuration.fontScale, MAX_LABEL_FONT_SCALE) override fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { @@ -89,6 +100,7 @@ class ImeKeyView( private val mainTextView: TextView? private val secondaryTextView: TextView? private val iconView: ImageView? + private var returnIcon: ImageView? = null init { isClickable = true @@ -124,7 +136,7 @@ class ImeKeyView( } }.apply { this.text = text - textSize = mainTextSize + setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(mainTextSize)) gravity = Gravity.CENTER isAllCaps = false includeFontPadding = false @@ -134,10 +146,10 @@ class ImeKeyView( // scales. Shrink within a controlled range instead of // replacing the action with an ellipsis such as “中/…”. setAutoSizeTextTypeUniformWithConfiguration( - (mainTextSize * 0.68f).toInt().coerceAtLeast(10), - mainTextSize.toInt().coerceAtLeast(12), + labelPx((mainTextSize * 0.68f).coerceAtLeast(10f)).toInt(), + labelPx(mainTextSize.coerceAtLeast(12f)).toInt(), 1, - TypedValue.COMPLEX_UNIT_SP, + TypedValue.COMPLEX_UNIT_PX, ) ellipsize = null } else { @@ -153,7 +165,7 @@ class ImeKeyView( secondaryTextView = secondary?.takeIf { it.isNotEmpty() }?.let { sub -> TextView(context).apply { this.text = sub - textSize = ImeTypographyTokens.CAPTION_SP + setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(ImeTypographyTokens.CAPTION_SP)) gravity = Gravity.CENTER isAllCaps = false includeFontPadding = false @@ -166,17 +178,17 @@ class ImeKeyView( addView( view, FrameLayout.LayoutParams( - dp(if (text.isEmpty()) 20 else 16), - dp(if (text.isEmpty()) 20 else 16), + dp(if (text.isEmpty()) { if (iconRes == R.drawable.ic_backspace) 24 else 20 } else 10), + dp(if (text.isEmpty()) { if (iconRes == R.drawable.ic_backspace) 24 else 20 } else 10), ).apply { gravity = if (text.isEmpty()) { Gravity.CENTER } else { - Gravity.BOTTOM or Gravity.END + Gravity.TOP or Gravity.END } if (text.isNotEmpty()) { rightMargin = dp(6) - bottomMargin = dp(4) + topMargin = dp(4) } }, ) @@ -207,12 +219,14 @@ class ImeKeyView( }, ) } + if (text == "↵") setMainText(text) } fun setColors(mainColor: Int = Color.BLACK, secondaryColor: Int = Color.GRAY, iconColor: Int = mainColor) { mainTextView?.setTextColor(mainColor) secondaryTextView?.setTextColor(secondaryColor) iconView?.imageTintList = ColorStateList.valueOf(iconColor) + returnIcon?.imageTintList = ColorStateList.valueOf(mainColor) } val currentMainText: String @@ -220,12 +234,49 @@ class ImeKeyView( fun setMainText(value: String) { mainTextView?.text = value + if (value == "↵" && returnIcon == null) { + returnIcon = ImageView(context).apply { + setImageResource(R.drawable.ic_return) + scaleType = ImageView.ScaleType.FIT_CENTER + imageTintList = ColorStateList.valueOf(mainTextView?.currentTextColor ?: Color.BLACK) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }.also { addView(it, FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER)) } + } + returnIcon?.visibility = if (value == "↵") View.VISIBLE else View.GONE + mainTextView?.visibility = if (value == "↵") View.INVISIBLE else View.VISIBLE contentDescription = value } /** Update key typography without rebuilding the keyboard hierarchy. */ fun applyMainTextScale(scale: Float) { - mainTextView?.textSize = baseMainTextSize * scale.coerceAtLeast(0.5f) + val sp = baseMainTextSize * scale.coerceAtLeast(0.4f) + mainTextView?.apply { + if (fitMain) { + // Function labels ("中/英", "完成") must keep shrinking to fit their key. + setAutoSizeTextTypeUniformWithConfiguration( + labelPx((sp * 0.68f).coerceAtLeast(10f)).toInt(), + labelPx(sp.coerceAtLeast(12f)).toInt(), + 1, + TypedValue.COMPLEX_UNIT_PX, + ) + } else { + setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE) + setTextSize(TypedValue.COMPLEX_UNIT_PX, labelPx(sp)) + } + } + secondaryTextView?.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + labelPx(ImeTypographyTokens.CAPTION_SP) * (toPx(100) / (100f * density)), + ) + } + + /** True when the main label is fully visible (no ellipsis, nothing clipped). Used by tests. */ + internal fun mainLabelFits(): Boolean { + val view = mainTextView ?: return true + if (view.visibility != View.VISIBLE || view.width == 0) return true + val layout = view.layout ?: return false + val available = view.width - view.paddingLeft - view.paddingRight + return layout.getEllipsisCount(0) == 0 && layout.getLineWidth(0) <= available + 0.5f } /** @@ -254,5 +305,9 @@ class ImeKeyView( iconView.setImageResource(value) } - private fun dp(value: Int): Int = (value * density).toInt() + private fun dp(value: Int): Int = toPx(value) + + private companion object { + const val MAX_LABEL_FONT_SCALE = 1.3f + } } diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt index 9f4bf482..d5d1713b 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt @@ -10,6 +10,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.os.SystemClock +import android.util.Log import android.text.TextUtils import android.view.Gravity import android.view.HapticFeedbackConstants @@ -44,6 +45,9 @@ open class ImeKeyboardView( fun onClearAll() fun onUndoClear(): Boolean = false fun onUndoClearExpired() {} + + /** Whether the last clear-all can still be restored (drives the swipe-down hint). */ + fun hasClearUndo(): Boolean = false fun onSpace() fun onFloatingKeyboardChanged(floating: Boolean) fun onFloatingKeyboardDragged(deltaX: Float, deltaY: Float) @@ -112,6 +116,9 @@ open class ImeKeyboardView( private val MARK_FUNCTION_KEY = 0x1F000003 companion object { + /** Scale differences below this are rounding noise, not a new geometry. */ + private const val SCALE_CHANGE_EPSILON = 0.01f + /** How often a deferred row rebuild re-checks whether the press ended. */ private const val ROW_REBUILD_POLL_MS = 40L } @@ -123,10 +130,10 @@ open class ImeKeyboardView( onDeleteOne = ::performBackspaceOnce, onClearAll = listener::onClearAll, onUndoClear = listener::onUndoClear, + hasUndoSnapshot = listener::hasClearUndo, onPressFeedback = ::feedback, onHapticFeedback = ::hapticFeedback, - onShowClearPopup = { anchor -> showPopup(anchor, "清空") }, - onShowUndoPopup = { anchor -> showPopup(anchor, "撤回") }, + onGestureHint = { anchor, hint -> keyPopupController.showGestureHint(anchor, hint) }, onHidePopup = ::hidePopup, ) private val backspaceKeyFactory: BackspaceKeyFactory by lazy { @@ -226,16 +233,19 @@ open class ImeKeyboardView( } // Only these configuration values change the derived row and IME heights. private var appliedOrientation = resources.configuration.orientation + private var appliedScreenWidthDp = resources.configuration.screenWidthDp private var appliedFontScale = resources.configuration.fontScale private var appliedDensityDpi = resources.displayMetrics.densityDpi private var keyboardHandedness = ImeSettingsRepository.loadHandedness(context) private var keyboardHeightPercent = ImeSettingsRepository.loadKeyboardHeightPercent(context) private var floatingWidthPercent = ImeSettingsRepository.loadFloatingWidthPercent(context) private var floatingOpacityPercent = ImeSettingsRepository.loadFloatingOpacityPercent(context) + private var referenceScale = ImeReferenceSizing.scale(context) private var layoutMetrics = KeyboardLayoutMetrics( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, fontScale = appliedFontScale, heightPercent = keyboardHeightPercent, + availableWidthDp = resources.configuration.screenWidthDp, ) private var lastTextMode = KeyboardMode.PINYIN_26 private var preferredChineseMode = ImeSettingsRepository.loadPreferredChineseMode(context) @@ -247,6 +257,11 @@ open class ImeKeyboardView( fun currentPanel(): Panel = panel /** Persist the standalone settings panel's viewport across Activity recreation. */ + internal fun editAccentColor() { + showPanel(Panel.SKIN_SETTINGS) + settingsPanelController.showCustomAccentDialog() + } + internal fun settingsScrollPosition(): Int = settingsPanelController.scrollPosition() @@ -277,13 +292,13 @@ open class ImeKeyboardView( } private var shiftState = ShiftState.LOWERCASE - private var soundEnabled = true - private var hapticEnabled = true + private var soundEnabled = ImeSettingsRepository.loadSound(context) + private var hapticEnabled = ImeSettingsRepository.loadHaptic(context) private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } - private var popupEnabled = false - private var fuzzyEnabled = false + private var popupEnabled = ImeSettingsRepository.loadPopup(context) + private var fuzzyEnabled = ImeSettingsRepository.loadFuzzy(context) private var skinRadius = ImeSettingsRepository.loadSkinRadius(context) private var skinOpacity = ImeSettingsRepository.loadSkinOpacity(context) private var skinFontSize = ImeSettingsRepository.loadSkinFont(context) @@ -293,6 +308,9 @@ open class ImeKeyboardView( private val pinyinBuffer = StringBuilder() private var lastNineDigits = "" + + /** A syllable the user fixed that is still the open tail; typing more digits seals it. */ + private var lockedNineTail: String? = null private var lastNineCandidates = emptyList() private var lastNineSegmentPrefix = "" private var lastNinePinyinPaths = emptyList() @@ -311,13 +329,14 @@ open class ImeKeyboardView( onDock = { listener.onFloatingKeyboardChanged(false) }, ) } - private var contentInsetPx = dp(5) + private var contentInsetPx = dp(0) private var navigationBottomInsetPx = 0 private val themeApplier: ImeThemeApplier by lazy { ImeThemeApplier( toPx = ::dp, statefulRounded = ::statefulRounded, keyMainTextScale = ::skinFontScale, + referenceScale = { referenceScale }, skinRadiusPx = { dp(skinRadius) }, skinOpacity = { skinOpacity }, skinPrimaryColor = { skinPrimaryColor }, @@ -428,16 +447,16 @@ open class ImeKeyboardView( onEnter = listener::onEnter, ) } + private var floatingWindowMode = false private var systemBottomInsetPx = 0 private val maxContentWidthDp = 600 - // Portrait keeps the historical 296dp total. Landscape uses a compact - // keyboard, and key rows grow with the system font scale so sp labels are - // never clipped inside a fixed-height key. + // Portrait row height follows the available screen width. Landscape stays + // compact; larger system fonts and the height preference grow the rows. private fun keyRowHeightDp(): Int = layoutMetrics.keyRowHeightDp private fun nineGridHeightDp(): Int = layoutMetrics.nineGridHeightDp private fun nineBodyHeightDp(): Int = layoutMetrics.nineBodyHeightDp private fun doubleKeyHeightDp(): Int = layoutMetrics.doubleKeyHeightDp - private fun imeHeightDp(): Int = layoutMetrics.imeHeightDp + private fun imeHeightDp(): Int = layoutMetrics.imeHeightDp + if (floatingWindowMode) 20 else 0 /** The top zone is reserved at its composed height in every state. */ private fun topZoneHeightDp(): Int = layoutMetrics.topZoneHeightDp @@ -461,6 +480,7 @@ open class ImeKeyboardView( private val emojiCellFactory: EmojiCellFactory by lazy { EmojiCellFactory( context = context, + toPx = ::dp, onFeedback = ::feedback, onEmojiSelected = listener::onEmojiSelected, ) @@ -566,7 +586,7 @@ open class ImeKeyboardView( toPx = ::dp, createHeader = panelHeaderFactory::create, createSectionTitle = ::sectionTitle, - createChipScroll = panelRenderer::panelChipScroll, + createChipScroll = { labels, selected, onSelected -> panelRenderer.panelChipScroll(labels, selected, onSelected) }, currentTheme = { theme }, currentAppearance = { appearance }, currentSound = { soundEnabled }, @@ -604,6 +624,7 @@ open class ImeKeyboardView( onKeyboardHeightChanged = ::setKeyboardHeightPercent, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, + onShowSkinSettings = { showPanel(Panel.SKIN_SETTINGS) }, onOpenAboutData = listener::onOpenAboutData, onFeedback = ::feedback, applyTheme = ::applyTheme, @@ -628,7 +649,7 @@ open class ImeKeyboardView( }, createPanelButton = ::button, createSectionTitle = ::sectionTitle, - createChipScroll = panelRenderer::panelChipScroll, + createChipScroll = { labels, selected, onSelected -> panelRenderer.panelChipScroll(labels, selected, onSelected) }, createVerticalScroll = panelRenderer::panelVerticalScroll, rememberVerticalScroll = panelRenderer::rememberPanelVerticalScroll, onCharacter = listener::onCharacter, @@ -696,20 +717,6 @@ open class ImeKeyboardView( init { tag = "ime_root" - setOnApplyWindowInsetsListener { _, insets -> - val reported = if (Build.VERSION.SDK_INT >= 30) { - insets.getInsets(WindowInsets.Type.navigationBars()).bottom - } else { - @Suppress("DEPRECATION") - insets.systemWindowInsetBottom - } - val next = ImeBottomInsetPolicy.clampInset(reported, dp(32)) - if (next != navigationBottomInsetPx) { - navigationBottomInsetPx = next - requestLayout() - } - insets - } // Some IME windows inherit the host's disabled sound-effect flag. // Keep the view channel enabled; the preference still gates feedback(). isSoundEffectsEnabled = true @@ -745,7 +752,7 @@ open class ImeKeyboardView( } keyboardBody.orientation = LinearLayout.VERTICAL keyboardBody.tag = "keyboard-body" - keyboardBody.setPadding(dp(5), dp(6), dp(5), dp(16)) + keyboardBody.setPadding(dp(0), dp(6), dp(0), dp(10)) expandedPanel.orientation = LinearLayout.VERTICAL expandedPanel.tag = "panel-overlay" expandedPanel.visibility = View.GONE @@ -767,12 +774,9 @@ open class ImeKeyboardView( FrameLayout.LayoutParams.MATCH_PARENT, ), ) - keyboardHost.addView( + mainDock.addView( floatingKeyboardController.handle, - FrameLayout.LayoutParams(dp(ImeGeometryTokens.TOUCH_TARGET_DP), dp(24)).apply { - gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL - bottomMargin = dp(4) - }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(20)), ) buildTopZone() @@ -804,6 +808,12 @@ open class ImeKeyboardView( @Suppress("DEPRECATION") insets.systemWindowInsetBottom }.coerceAtMost(dp(32)) + val next = if (floatingWindowMode) 0 else ImeBottomInsetPolicy.clampInset(systemBottomInsetPx, dp(32)) + if (next != navigationBottomInsetPx) { + navigationBottomInsetPx = next + applyDynamicHeights() + requestLayout() + } updateResponsiveGeometry(width) insets } @@ -821,9 +831,11 @@ open class ImeKeyboardView( // font rows take effect. Unrelated configuration changes (locale, UI mode, // keyboard presence) do not alter the derived geometry, so they must not // tear down the key surface. - val geometryChanged = newConfig.orientation != appliedOrientation || + val geometryChanged = newConfig.screenWidthDp != appliedScreenWidthDp || + newConfig.orientation != appliedOrientation || newConfig.fontScale != appliedFontScale || newConfig.densityDpi != appliedDensityDpi + appliedScreenWidthDp = newConfig.screenWidthDp appliedOrientation = newConfig.orientation appliedFontScale = newConfig.fontScale appliedDensityDpi = newConfig.densityDpi @@ -831,6 +843,7 @@ open class ImeKeyboardView( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, fontScale = appliedFontScale, heightPercent = keyboardHeightPercent, + availableWidthDp = resources.configuration.screenWidthDp, ) if (!geometryChanged) return // Do not yank the user out of an open panel. @@ -883,6 +896,19 @@ open class ImeKeyboardView( super.onMeasure(widthMeasureSpec, heightMeasureSpec) return } + // The content box (padding, row widths) follows the width the parent + // offers. Apply it here, before the children are measured: doing it from + // onSizeChanged changes paddings of views laid out later in the same + // pass and the framework then keeps their stale measurements. + val offeredWidth = MeasureSpec.getSize(widthMeasureSpec) + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED && offeredWidth > 0) { + applyingGeometryInMeasure = true + try { + updateResponsiveGeometry(offeredWidth) + } finally { + applyingGeometryInMeasure = false + } + } val desiredHeight = dp(imeHeightDp()) val mode = MeasureSpec.getMode(heightMeasureSpec) val size = MeasureSpec.getSize(heightMeasureSpec) @@ -918,8 +944,8 @@ open class ImeKeyboardView( val totalPx = dp(imeHeightDp()) val bodyPx = dp(keyboardBodyHeightDp()) (layoutParams as? FrameLayout.LayoutParams)?.let { - if (it.height != totalPx) { - it.height = totalPx + if (it.height != totalPx + navigationBottomInsetPx) { + it.height = totalPx + navigationBottomInsetPx layoutParams = it } } @@ -944,7 +970,7 @@ open class ImeKeyboardView( } /** - * The 390dp prototype is a design reference only. Runtime geometry is + * The 390dp prototype is a design reference only. Runtime content width is * derived from the measured IME width, with a 600dp maximum on tablets and * foldables. The bottom inset is added only when the system reports one so * the last row cannot sit underneath a gesture/navigation bar. @@ -961,6 +987,27 @@ open class ImeKeyboardView( .coerceAtMost(measuredWidthPx) } + val nextScale = ImeReferenceSizing.scale(context, dockWidthPx) + // Configuration.screenWidthDp is a whole number while the measured width + // is not (411dp vs 411.43dp on a 1080px / 420dpi screen), so the two + // scales differ by up to 1/390 for the same window. That rounding noise + // used to cross the old 0.001 threshold and rebuilt every key from + // onSizeChanged, in the middle of the first layout pass. Only a real + // change (one-handed dock, floating window, rotation) rescales. + if (kotlin.math.abs(nextScale - referenceScale) > SCALE_CHANGE_EPSILON) { + val ratio = nextScale / referenceScale + referenceScale = nextScale + rescaleTopZone(topZone, ratio) + applyDynamicHeights() + if (!standalonePanel) { + if (panel == Panel.NONE) renderModeBody() else renderPanel(panel) + } + applyTheme() + // Rows rebuilt here are added after their parent was measured and + // would stay at 0x0 (a blank keyboard on first show) otherwise. + if (!applyingGeometryInMeasure) scheduleRelayout() + } + (mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params -> if (dockWidthPx == measuredWidthPx) { params.width = FrameLayout.LayoutParams.MATCH_PARENT @@ -980,33 +1027,36 @@ open class ImeKeyboardView( // A configuration pass can briefly report the physical display // width before WindowManager applies the floating window bounds. // Keep the normal keyboard's content inset local to its window. - contentInsetPx = dp(5) - keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(16)) + contentInsetPx = dp(0) + keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(10)) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> // The portrait layout narrows this row to 90% of the full // display for optical centering. A floating window can be // narrower than the display, so that cached width would clip // the first and last keys. Let the row fill its local window. (row.layoutParams as? LinearLayout.LayoutParams)?.let { params -> - params.width = LinearLayout.LayoutParams.MATCH_PARENT - params.gravity = Gravity.NO_GRAVITY + params.width = (dockWidthPx * 0.9f).toInt() + params.gravity = Gravity.CENTER_HORIZONTAL row.layoutParams = params } } expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) - requestLayout() + if (!applyingGeometryInMeasure) { + requestLayout() + scheduleRelayout() + } return } - val minimumInset = dp(5) - val maxWidth = dp(maxContentWidthDp) + val minimumInset = dp(0) + val maxWidth = (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() contentInsetPx = maxOf(minimumInset, (dockWidthPx - maxWidth) / 2) keyboardBody.setPadding( contentInsetPx, dp(6), contentInsetPx, - dp(16), + dp(10), ) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> val rowWidth = ((dockWidthPx - contentInsetPx * 2) * 0.9f).toInt() @@ -1020,7 +1070,53 @@ open class ImeKeyboardView( expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) - requestLayout() + if (!applyingGeometryInMeasure) { + requestLayout() + scheduleRelayout() + } + } + + private var relayoutPosted = false + private var applyingGeometryInMeasure = false + + /** + * updateResponsiveGeometry runs from onSizeChanged, i.e. inside a layout + * pass, and changes paddings and row widths of views that are laid out later + * in that same pass. Their own requestLayout() calls are lost: each view + * clears its force-layout flag when it finishes laying out, so the framework + * sees no pending request and never re-measures them (the landscape keyboard + * kept rows as wide as the whole window inside a clamped content box). + * requestLayout() on the root is not enough either, it marks only the root + * and its ancestors. So once the pass is over, flag the containers that + * were changed. + */ + private fun scheduleRelayout() { + if (relayoutPosted) return + relayoutPosted = true + post { + relayoutPosted = false + keyboardBody.requestLayout() + if (::topZone.isInitialized) topZone.requestLayout() + expandedPanel.requestLayout() + candidateOverlay.requestLayout() + requestLayout() + invalidate() + } + } + + private fun rescaleTopZone(view: View, ratio: Float) { + view.layoutParams?.let { params -> + if (params.width > 0) params.width = kotlin.math.round(params.width * ratio).toInt() + if (params.height > 0) params.height = kotlin.math.round(params.height * ratio).toInt() + if (params is ViewGroup.MarginLayoutParams) { + params.setMargins((params.leftMargin * ratio).toInt(), (params.topMargin * ratio).toInt(), (params.rightMargin * ratio).toInt(), (params.bottomMargin * ratio).toInt()) + } + view.layoutParams = params + } + view.minimumHeight = (view.minimumHeight * ratio).toInt() + view.minimumWidth = (view.minimumWidth * ratio).toInt() + view.setPadding((view.paddingLeft * ratio).toInt(), (view.paddingTop * ratio).toInt(), (view.paddingRight * ratio).toInt(), (view.paddingBottom * ratio).toInt()) + if (view is ViewGroup) for (i in 0 until view.childCount) rescaleTopZone(view.getChildAt(i), ratio) } /** Keep the top zone at one height so composing never relayouts the keyboard. */ @@ -1035,6 +1131,7 @@ open class ImeKeyboardView( onClipboard = { showPanel(Panel.CLIPBOARD) }, onEmoji = { showPanel(Panel.EMOJI) }, onSymbols = { showPanel(Panel.SYMBOLS) }, + onTextEditor = { showPanel(Panel.TEXT_EDITOR) }, onHideKeyboard = ::hideKeyboard, onTools = { showPanel(Panel.TOOLS) }, onExpandCandidates = { @@ -1047,8 +1144,9 @@ open class ImeKeyboardView( updateTopZone(composition.text?.isNotEmpty() == true) listener.onCandidateExpanded(open) }, - onUndoClear = { listener.onUndoClear() }, + onUndoClear = { if (!listener.onUndoClear()) listener.onTextEdit("undo") }, onUndoClearExpired = listener::onUndoClearExpired, + onAssociationDismiss = ::clearAssociationCandidates, ) candidateBarController = CandidateBarController( context = context, @@ -1061,7 +1159,7 @@ open class ImeKeyboardView( keyRowHeightPx = { dp(keyRowHeightDp()) }, createHeader = { panelHeaderFactory.create("候选字词") }, createExpandedCandidate = { candidate -> - key(candidate, false, null, 1f, 15f) { + key(candidate, false, null, 1f, ImeTypographyTokens.CANDIDATE_SP) { listener.onCandidateSelected(candidate) }.apply { allowTwoLineLabel() @@ -1091,7 +1189,7 @@ open class ImeKeyboardView( topZone, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(64), + dp(topZoneHeightDp()), ), ) candidateBarController.syncExpandControl( @@ -1216,10 +1314,13 @@ open class ImeKeyboardView( /** Keep content geometry local when the service changes the window bounds. */ fun setFloatingWindowMode(enabled: Boolean) { + floatingWindowMode = enabled + navigationBottomInsetPx = if (enabled) 0 else ImeBottomInsetPolicy.clampInset(systemBottomInsetPx, dp(32)) floatingKeyboardController.setEnabled(enabled) + applyDynamicHeights() if (enabled) { - contentInsetPx = dp(5) - keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(16)) + contentInsetPx = dp(0) + keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(10)) expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) @@ -1333,6 +1434,7 @@ open class ImeKeyboardView( if (state.composition.isEmpty()) { pinyinBuffer.clear() lastNineDigits = "" + lockedNineTail = null lastNineCandidates = emptyList() lastNineSegmentPrefix = "" lastNinePinyinPaths = emptyList() @@ -1370,8 +1472,10 @@ open class ImeKeyboardView( private fun syncEnterKeyPresentation(imeOptions: Int?) { val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return - val label = enterKeyPresentationFor(options).label + val composing = composition.text?.isNotEmpty() == true + val label = if (composing) "确定" else if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.DIGITS) "↵" else enterKeyPresentationFor(options).label enter.setMainText(label) + enter.applyMainTextScale(skinFontScale()) enter.contentDescription = label } @@ -1421,20 +1525,21 @@ open class ImeKeyboardView( tag = "association-candidate" isClickable = true isFocusable = true - setPadding(dp(8), 0, dp(8), 0) + setPadding(dp(12), 0, dp(12), 0) setOnClickListener { feedback(); listener.onAssociationSelected(candidate) } }, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, dp(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { marginEnd = dp(5) }, + ), ) } + topZone.showAssociations(candidates.isNotEmpty()) applyAssociationTheme() } fun clearAssociationCandidates() { - if (::topZone.isInitialized) associationRow.removeAllViews() + if (::topZone.isInitialized) { associationRow.removeAllViews(); topZone.showAssociations(false) } } fun setTheme(newTheme: ImeTheme) { @@ -1475,7 +1580,7 @@ open class ImeKeyboardView( } private fun setKeyboardHeightPercent(percent: Int) { - val bounded = percent.coerceIn(92, 120) + val bounded = percent.coerceIn(80, 120) if (keyboardHeightPercent == bounded) return keyboardHeightPercent = bounded ImeSettingsRepository.saveKeyboardHeightPercent(context, bounded) @@ -1485,6 +1590,7 @@ open class ImeKeyboardView( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, fontScale = appliedFontScale, heightPercent = keyboardHeightPercent, + availableWidthDp = resources.configuration.screenWidthDp, ) applyDynamicHeights() if (!standalonePanel && panel == Panel.NONE) { @@ -1541,6 +1647,7 @@ open class ImeKeyboardView( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE, fontScale = appliedFontScale, heightPercent = keyboardHeightPercent, + availableWidthDp = resources.configuration.screenWidthDp, ) applyDynamicHeights() if (!standalonePanel && panel == Panel.NONE) { @@ -1647,6 +1754,10 @@ open class ImeKeyboardView( internal fun findTestTarget(query: String): View? { findViewWithTag(query)?.let { return it } + // Replay scripts address letters independently of the visible shift/case. + if (query.length == 1 && query[0].lowercaseChar() in 'a'..'z') { + findViewWithTag("key:${query.lowercase()}")?.let { return it } + } fun deep(view: View): View? { val description = view.contentDescription?.toString() if (view.isClickable && (description == query || description?.substringBefore(',') == query)) return view @@ -1681,8 +1792,8 @@ open class ImeKeyboardView( anchor.getLocationOnScreen(location) val rawX = location[0] + anchor.width / 2f val rawY = location[1] + anchor.height / 2f - backspaceGestureController.begin(anchor, pointerId = 0, rawX, rawY) { } - backspaceGestureController.update(rawX, rawY - dp(48)) + backspaceGestureController.begin(anchor, pointerId = 0, rawX, rawY) + backspaceGestureController.update(rawX, rawY - dp(BackspaceGestureController.CLEAR_ARM_DP + 8)) backspaceGestureController.finish(commit = true) return true } @@ -1707,8 +1818,10 @@ open class ImeKeyboardView( } internal fun normalizedBoundsReport(): String { - val out = StringBuilder() + val origin = IntArray(2).also(::getLocationOnScreen) + val out = StringBuilder("window=${origin[0]},${origin[1]},$width,$height\n") fun deep(view: View) { + if (view.visibility != View.VISIBLE) return if (view.tag is String || (view.isClickable && !view.contentDescription.isNullOrEmpty())) { val normalized = NormalizedBounds.fromView(view, this) out.append( @@ -1747,7 +1860,7 @@ open class ImeKeyboardView( contentInsetPx, dp(6), contentInsetPx, - dp(16), + dp(10), ) val state = when { inlineVoicePresenter.active -> ImeTopZoneState.VOICE_INLINE @@ -1755,6 +1868,12 @@ open class ImeKeyboardView( composing -> ImeTopZoneState.COMPOSING else -> ImeTopZoneState.IDLE } + syncEnterKeyPresentation((context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions) + findViewWithTag("key-enter")?.let(::applyThemeToSubtree) + findViewWithTag("key-retype")?.apply { + isEnabled = composing + alpha = if (composing) 1f else ImeSurfacePolicy.DISABLED_ALPHA + } topZone.renderState( state = state, showCompositionEditor = (composing || candidateBarController.expandedOpen) && @@ -1852,7 +1971,7 @@ open class ImeKeyboardView( } private fun renderPinyin9() { - pinyin9Renderer.render(enterLabel = enterKeyLabel(false)) + pinyin9Renderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵") } private fun requireNineKeySymbolRailController(): NineKeySymbolRailController { @@ -1861,7 +1980,11 @@ open class ImeKeyboardView( composition = composition, onCommit = listener::onCharacter, onFeedback = ::feedback, + cellHeightDp = ::keyRowHeightDp, + toPx = ::dp, onRailChanged = ::applyThemeToSubtree, + onChooseReading = ::chooseNineKeyReading, + fixedPrefix = ::nineKeyFixedPrefix, ).also { nineKeySymbolRailController = it } } @@ -1870,7 +1993,7 @@ open class ImeKeyboardView( ?.currentInputEditorInfo numericKeyboardRenderer.render( editorKind = EditorInfoAdapter.kind(info), - enterLabel = enterKeyLabel(false, "换行"), + enterLabel = "↵", ) } @@ -1904,10 +2027,28 @@ open class ImeKeyboardView( MotionEvent.ACTION_POINTER_UP -> if ( event.getPointerId(event.actionIndex) == backspaceGestureController.pointerId ) { - backspaceGestureController.finish(commit = true) + val index = event.actionIndex + backspaceGestureController.finish( + commit = true, + rawX = event.rawX + event.getX(index) - event.x, + rawY = event.rawY + event.getY(index) - event.y, + ) + } + MotionEvent.ACTION_UP -> { + // The release position is part of the gesture: a quick flick + // can cross the threshold on the UP itself. + val index = event.findPointerIndex(backspaceGestureController.pointerId) + .takeIf { it >= 0 } ?: 0 + backspaceGestureController.finish( + commit = true, + rawX = event.rawX + event.getX(index) - event.x, + rawY = event.rawY + event.getY(index) - event.y, + ) + } + MotionEvent.ACTION_CANCEL -> { + Log.d("OpenIme", "bs touch CANCEL while gesture active (system or parent took the touch)") + backspaceGestureController.finish(commit = false) } - MotionEvent.ACTION_UP -> backspaceGestureController.finish(commit = true) - MotionEvent.ACTION_CANCEL -> backspaceGestureController.finish(commit = false) } } if (spaceVoiceGestureController.active) { @@ -2012,6 +2153,7 @@ open class ImeKeyboardView( Panel.TEXT_EDITOR -> textEditorPanelController.render() Panel.SETTINGS -> settingsPanelController.renderSettings() Panel.FUZZY_SETTINGS -> settingsPanelController.renderFuzzySettings() + Panel.SKIN_SETTINGS -> settingsPanelController.renderSettings(skinOnly = true) else -> closePanelToKeyboard() } applyTheme() @@ -2074,9 +2216,9 @@ open class ImeKeyboardView( private fun sectionTitle(textValue: String): TextView = TextView(context).apply { text = textValue - textSize = ImeTypographyTokens.TITLE_SP + textSize = ImeTypographyTokens.SMALL_SP includeFontPadding = false - setPadding(dp(4), dp(2), 0, dp(8)) + setPadding(dp(16), dp(4), 0, dp(8)) tag = "panel-section-title" } @@ -2183,28 +2325,40 @@ open class ImeKeyboardView( return } if (mode == KeyboardMode.PINYIN_9) { + sealLockedNineTail() val current = composition.text.toString() val rawStart = composition.selectionStart.takeIf { it >= 0 }?.coerceIn(0, current.length) ?: current.length val rawEnd = composition.selectionEnd.takeIf { it >= 0 }?.coerceIn(0, current.length) ?: rawStart val selStart = minOf(rawStart, rawEnd) val selEnd = maxOf(rawStart, rawEnd) - val lastSpace = current.lastIndexOf(' ') - if (selStart > lastSpace) { - val prefix = if (lastSpace >= 0) current.substring(0, lastSpace + 1) else "" - val suffix = if (lastSpace >= 0) current.substring(lastSpace + 1) else current + val (prefix, suffix) = splitNineKeyText(current) + if (selStart >= prefix.length) { val suffixStart = (selStart - prefix.length).coerceIn(0, suffix.length) val suffixEnd = (selEnd - prefix.length).coerceIn(0, suffix.length) val isAtEnd = (selStart == selEnd && selStart == current.length && prefix == lastNineSegmentPrefix && lastNineDigits.isNotEmpty()) val suffixDigits = if (isAtEnd) { lastNineDigits - } else if (lastNineDigits.isNotEmpty() && lastNineDigits.length == suffix.length && prefix == lastNineSegmentPrefix) { + } else if (lastNineDigits.isNotEmpty() && + lastNineDigits.length == nineKeyLetterCount(suffix, suffix.length) && + prefix == lastNineSegmentPrefix + ) { lastNineDigits } else { - CandidatePipeline.nineKeyDigitsFor(suffix) ?: lastNineDigits + nineKeyDigitsOfTail(suffix) ?: lastNineDigits + } + // The tail may carry decoder dividers; cursor columns are text + // positions, digits are letter positions. + val insertPos = if (isAtEnd) { + suffixDigits.length + } else { + nineKeyLetterCount(suffix, suffixStart).coerceAtMost(suffixDigits.length) + } + val deleteEnd = if (isAtEnd) { + suffixDigits.length + } else { + nineKeyLetterCount(suffix, suffixEnd).coerceIn(insertPos, suffixDigits.length) } - val insertPos = if (isAtEnd) suffixDigits.length else suffixStart - val deleteEnd = if (isAtEnd) suffixDigits.length else suffixEnd val newDigits = (suffixDigits.substring(0, insertPos) + num + suffixDigits.substring(deleteEnd)).take(64) lastNineSegmentPrefix = prefix val newCursor = if (isAtEnd) null else (prefix.length + suffixStart + 1) @@ -2235,13 +2389,16 @@ open class ImeKeyboardView( digits: String, preferredSuffix: String? = null, cursorPosition: Int? = null, + lockPreferred: Boolean = false, ) { + lockedNineTail = if (lockPreferred) preferredSuffix?.lowercase()?.takeIf { it.isNotEmpty() } else null val resolveStartedAt = SystemClock.elapsedRealtimeNanos() val resolution = requireCandidateProvider().resolveNineKey( digits = digits, segmentPrefix = lastNineSegmentPrefix, preferredSuffix = preferredSuffix, fuzzy = fuzzyEnabled, + lockPreferred = lockPreferred, ) NineKeyPerformanceTrace.recordResolve( digitLength = digits.length, @@ -2273,17 +2430,194 @@ open class ImeKeyboardView( ) } + /** + * Split the pre-edit text into the part the user fixed (a tapped syllable + * or the segment key) and the still-open tail. Spaces the local decoder + * puts between guessed syllables belong to the tail: treating them as fixed + * boundaries froze guesses such as "woyi a m" and fed them to Rime. + */ + private fun splitNineKeyText(text: String): Pair { + val fixed = lastNineSegmentPrefix + if (fixed.isEmpty()) return "" to text + if (text.startsWith(fixed)) return fixed to text.substring(fixed.length) + val boundary = text.indexOfLast(::nineKeyIsDivider) + return if (boundary >= 0) { + text.substring(0, boundary + 1) to text.substring(boundary + 1) + } else { + "" to text + } + } + + /** Release the last fixed syllable of [prefix] back into open digits (keeping its spelling shown). */ + private fun unlockLastNineKeySyllable(prefix: String): Boolean { + val trimmed = prefix.trimEnd(' ', '\'') + val cut = trimmed.indexOfLast(::nineKeyIsDivider) + val last = trimmed.substring(cut + 1) + val digits = NineKeyLocalDecoder.digitsForPinyin(last) ?: return false + lastNineSegmentPrefix = if (cut >= 0) trimmed.substring(0, cut + 1) else "" + publishNineKeyDigits(digits, preferredSuffix = last) + return true + } + + private fun nineKeyIsDivider(ch: Char): Boolean = ch == ' ' || ch == '\'' + + /** + * More digits are coming after a syllable the user fixed: seal it with a + * boundary so it stays fixed, and let the digits start a new open tail. + */ + private fun sealLockedNineTail() { + val locked = lockedNineTail ?: return + lockedNineTail = null + val current = composition.text.toString() + val atEnd = composition.selectionStart.let { it < 0 || it == current.length } && + composition.selectionEnd.let { it < 0 || it == current.length } + val (prefix, tail) = splitNineKeyText(current) + if (!atEnd || tail != locked) return + lastNineSegmentPrefix = "$prefix$tail'" + lastNineDigits = "" + setCompositionText(lastNineSegmentPrefix, lastNineSegmentPrefix.length) + } + + /** Letters (not dividers) in the first [end] characters of [text]. */ + private fun nineKeyLetterCount(text: String, end: Int): Int = + (0 until end.coerceIn(0, text.length)).count { !nineKeyIsDivider(text[it]) } + + /** T9 digits of an open tail, ignoring decoder-inserted dividers. */ + private fun nineKeyDigitsOfTail(tail: String): String? = + CandidatePipeline.nineKeyDigitsFor(tail.filterNot(::nineKeyIsDivider)) + + /** + * Rime ranked [topCandidate] first for the current digits. Show that + * word's own pinyin so the pre-edit text and the candidates agree, instead + * of the local decoder's independent guess. Display-only: digits, the + * fixed prefix and the native query are unchanged. + */ + internal fun alignNineKeyPreview(expected: String, topCandidate: String) { + if (mode != KeyboardMode.PINYIN_9 || composition.text.toString() != expected) return + if (lockedNineTail != null) return + val selection = composition.selectionStart + if (selection >= 0 && selection != expected.length) return // user is editing mid-text + val (prefix, tail) = splitNineKeyText(expected) + val digits = lastNineDigits + if (digits.isEmpty() || digits.length != nineKeyLetterCount(tail, tail.length)) return + // The word also covers the fixed syllables in front; only its tail + // characters spell the still-open digits. + val fixedSyllables = prefix.split(' ', '\'').count { it.isNotEmpty() } + val skipped = if (fixedSyllables == 0) 0 else { + if (topCandidate.codePointCount(0, topCandidate.length) <= fixedSyllables) return + topCandidate.offsetByCodePoints(0, fixedSyllables) + } + val syllables = candidateProvider + ?.nineKeyReadingFor(digits, topCandidate.substring(skipped)) + ?.takeIf { it.isNotEmpty() } + ?: return + val aligned = prefix + syllables.joinToString("'") + if (aligned == expected) return + setCompositionText(aligned, aligned.length) + pinyinBuffer.clear() + pinyinBuffer.append(aligned) + candidateBarController.render( + candidates = currentCandidates, + compositionPreview = aligned, + showCompositionWhenEmpty = composeZone.visibility == View.VISIBLE, + ) + listener.onNineKeyCompositionChanged( + composition = aligned, + digitBuffer = digits, + pinyinPaths = lastNinePinyinPaths, + candidates = currentCandidates, + ) + } + + /** + * A candidate spelling only the start of the input was committed; keep + * typing on what is left. Nine-key leftovers can start with letters the + * user had already fixed (they stay fixed) followed by open digits. + */ + internal fun continueCompositionAfterPartial(remaining: String) { + when (mode) { + KeyboardMode.PINYIN_9 -> { + val letters = remaining.takeWhile { it in 'a'..'z' || it == '\'' } + val digits = remaining.substring(letters.length).filter { it in '2'..'9' } + val fixed = letters.trim('\'') + val prefix = if (fixed.isEmpty()) "" else "$fixed'" + lastNineSegmentPrefix = prefix + lastNineDigits = "" + lastNinePinyinPaths = emptyList() + if (digits.isNotEmpty()) { + publishNineKeyDigits(digits) + } else if (prefix.isNotEmpty()) { + publishComposition(prefix, candidatesForComposition(prefix), prefix.length) + } + } + KeyboardMode.PINYIN_26 -> { + val text = remaining + if (text.isNotBlank()) { + publishComposition(text, candidatesForComposition(text), text.length) + } + } + else -> Unit + } + } + + /** The prefix the user has explicitly fixed, for the Pinyin rail. */ + internal fun nineKeyFixedPrefix(): String = + lastNineSegmentPrefix.takeIf { composition.text.toString().startsWith(it) }.orEmpty() + + /** + * The user tapped a reading in the left Pinyin rail. A tap fixes exactly + * what the item shows: a whole reading (`ni'gao`) fixes every syllable, + * a first syllable (`zhong`) fixes that one and the list moves on to the + * next position. The candidates are re-queried with the fixed letters so + * every word agrees with the choice. + * + * The last syllable of a whole reading stays the open tail (shown, and sent + * to Rime as letters) rather than being sealed with a boundary; typing more + * digits then seals it ([lockedNineTail]), so a fixed syllable is never lost. + */ + internal fun chooseNineKeyReading(reading: NineKeyReading) { + if (mode != KeyboardMode.PINYIN_9) return + val syllables = reading.syllables.map { it.lowercase().trim() }.filter { it.isNotEmpty() } + if (syllables.isEmpty()) return + val (prefix, tail) = splitNineKeyText(composition.text.toString()) + val digits = lastNineDigits.ifEmpty { nineKeyDigitsOfTail(tail).orEmpty() } + val spelled = StringBuilder() + 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 + } + val rest = digits.substring(spelled.length) + if (rest.isEmpty()) { + val last = syllables.last() + lastNineSegmentPrefix = prefix + syllables.dropLast(1).joinToString("") { "$it'" } + publishNineKeyDigits( + NineKeyLocalDecoder.digitsForPinyin(last).orEmpty(), + preferredSuffix = last, + lockPreferred = true, + ) + } else { + lastNineSegmentPrefix = prefix + syllables.joinToString("") { "$it'" } + publishNineKeyDigits(rest) + } + } + /** Insert an editable syllable boundary without committing the text. */ private fun onPinyinSegment() { if (mode != KeyboardMode.PINYIN_26 && mode != KeyboardMode.PINYIN_9) return if (insertIntoInlineEditor(" ")) return val current = composition.text.toString() - if (current.isBlank() || current.endsWith(' ')) return + if (current.isBlank() || nineKeyIsDivider(current.last())) return if (mode == KeyboardMode.PINYIN_9) { lastNineDigits = "" + lockedNineTail = null lastNinePinyinPaths = emptyList() } - val (next, selection) = replaceCompositionSelection(" ") + val (next, selection) = replaceCompositionSelection(if (mode == KeyboardMode.PINYIN_9) "'" else " ") publishComposition(next, candidatesForComposition(next), selection) if (mode == KeyboardMode.PINYIN_9) lastNineSegmentPrefix = next } @@ -2382,31 +2716,45 @@ open class ImeKeyboardView( val start = minOf(rawStart, rawEnd) val end = maxOf(rawStart, rawEnd) - val lastSpace = current.lastIndexOf(' ') - if (start > lastSpace) { - val prefix = if (lastSpace >= 0) current.substring(0, lastSpace + 1) else "" - val suffix = if (lastSpace >= 0) current.substring(lastSpace + 1) else current + val (prefix, suffix) = splitNineKeyText(current) + if (start >= prefix.length) { + // Nothing left to delete after the fixed syllables: undo + // the last fix (tapped syllable or the segment key) and + // hand its digits back, rather than eating its letters. + if (suffix.isEmpty() && prefix.isNotEmpty() && start == end && start == current.length && + unlockLastNineKeySyllable(prefix) + ) { + return true + } val suffixStart = (start - prefix.length).coerceIn(0, suffix.length) val suffixEnd = (end - prefix.length).coerceIn(0, suffix.length) - val suffixDigits = if (lastNineDigits.isNotEmpty() && lastNineDigits.length == suffix.length && prefix == lastNineSegmentPrefix) { + val suffixDigits = if (lastNineDigits.isNotEmpty() && + lastNineDigits.length == nineKeyLetterCount(suffix, suffix.length) && + prefix == lastNineSegmentPrefix + ) { lastNineDigits } else { - CandidatePipeline.nineKeyDigitsFor(suffix) + nineKeyDigitsOfTail(suffix) } if (suffixDigits != null && suffixDigits.isNotEmpty()) { + // Decoder dividers make text columns differ from digit + // columns; delete by letter position. + val digitStart = nineKeyLetterCount(suffix, suffixStart).coerceAtMost(suffixDigits.length) + val digitEnd = nineKeyLetterCount(suffix, suffixEnd).coerceIn(digitStart, suffixDigits.length) + val letters = suffix.filterNot(::nineKeyIsDivider) val (nextDigits, newCursor, expectedSuffix) = if (suffixStart == suffixEnd) { - if (suffixStart == 0) { + if (digitStart == 0) { Triple(null, null, null) } else { - val deleteIdx = suffixStart - 1 - val remDigits = suffixDigits.removeRange(deleteIdx, suffixStart) - val remSuffix = if (suffix.length >= suffixStart) suffix.removeRange(deleteIdx, suffixStart) else null - Triple(remDigits, prefix.length + deleteIdx, remSuffix) + val deleteIdx = digitStart - 1 + val remDigits = suffixDigits.removeRange(deleteIdx, digitStart) + val remSuffix = if (letters.length >= digitStart) letters.removeRange(deleteIdx, digitStart) else null + Triple(remDigits, prefix.length + (suffixStart - 1).coerceAtLeast(0), remSuffix) } } else { - val remDigits = suffixDigits.removeRange(suffixStart, suffixEnd) - val remSuffix = if (suffix.length >= suffixEnd) suffix.removeRange(suffixStart, suffixEnd) else null + val remDigits = suffixDigits.removeRange(digitStart, digitEnd) + val remSuffix = if (letters.length >= digitEnd) letters.removeRange(digitStart, digitEnd) else null Triple(remDigits, prefix.length + suffixStart, remSuffix) } @@ -2540,7 +2888,7 @@ open class ImeKeyboardView( } /** Key main-text size is scaled around the 17sp default from the skin font slider. */ - private fun skinFontScale(): Float = skinFontSize / 17f.coerceAtLeast(1f) + private fun skinFontScale(): Float = skinFontSize / 21f * referenceScale private fun key( text: String, @@ -2562,6 +2910,7 @@ open class ImeKeyboardView( ImeTypographyTokens.KEY_LETTER_SP }, fitMainText = func || text.length > 2, + toPx = ::dp, ).apply { tag = "key:$text" setTag(MARK_FUNCTION_KEY, func) @@ -2638,6 +2987,39 @@ open class ImeKeyboardView( keyPopupController.hide() } + // --- physical keyboard: the same entry points a tap on the soft key reaches --- + + /** Pinyin typing from a physical keyboard needs the plain 26-key surface: no panel, no voice. */ + internal fun hardwareAccepts(): Boolean = + mode == KeyboardMode.PINYIN_26 && panel == Panel.NONE && !standalonePanel && + !voicePanelController.active && !voiceGestureSession + + internal fun hardwareIsComposing(): Boolean = composition.text.isNotEmpty() + internal fun hardwareCandidateCount(): Int = currentCandidates.size + internal fun hardwareLetter(char: Char) = onKeyTapped(char.toString()) + internal fun hardwareBackspace() = performBackspaceOnce() + internal fun hardwareSpace() = commitFirstCandidateOrSpace() + internal fun hardwareApostrophe() = onPinyinSegment() + internal fun hardwareCancelComposition() = publishComposition("", emptyList()) + + internal fun hardwareSelectCandidate(index: Int): Boolean { + val candidate = currentCandidates.getOrNull(index) ?: return false + listener.onCandidateSelected(candidate) + return true + } + + /** + * The key preview is a permanent child that only toggles visibility, so + * tests cannot detect it by counting children. + */ + internal fun isKeyPopupShown(): Boolean = keyPopupController.isShowing + + /** + * dp -> px exactly as the gesture thresholds compute it, i.e. including the + * reference scale, so tests can drive gestures in the controllers' units. + */ + internal fun scaledPx(dp: Int): Int = dp(dp) + private fun currentThemeTokens(): ImeTheme.Tokens = theme.tokens( appearance, @@ -2651,11 +3033,12 @@ open class ImeKeyboardView( mainDock.setBackgroundColor(t.keyboardBackground) keyboardBody.setBackgroundColor(t.keyboardBackground) topZone.setBackgroundColor(t.toolbarBackground) - expandedPanel.setBackgroundColor(t.expandedBackground) + expandedPanel.setBackgroundColor(if (standalonePanel) t.toolbarBackground else t.keyboardBackground) candidateOverlay.setBackgroundColor(t.expandedBackground) themeApplier.apply(this, t) - composition.setTextColor(t.keySecondaryText) - topZone.candidateExpandButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText) + topZone.applyTokens(t) + composition.setTextColor(ImeSurfacePolicy.selectedText(t)) + topZone.candidateExpandButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keyText) topZone.candidateEmojiButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText) floatingKeyboardController.applyTheme(t) inlineVoicePresenter.refreshPalette() @@ -2690,23 +3073,16 @@ open class ImeKeyboardView( return ImeFocusRingPolicy.resolve(background, AccentPalette.parse(skinPrimaryColor)) } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + private fun dp(value: Int): Int = kotlin.math.round(value * resources.displayMetrics.density * referenceScale).toInt() private fun gridCellParams( heightDp: Int, columns: Int, gapDp: Int, ): LinearLayout.LayoutParams { - val available = (width - contentInsetPx * 2 - dp(20)).coerceAtLeast(0) - val gap = dp(gapDp) - val cellWidth = if (width > 0) { - ((available - gap * (columns - 1)) / columns).coerceAtLeast(dp(1)) - } else { - 0 - } - return if (width > 0) { - LinearLayout.LayoutParams(cellWidth, dp(heightDp)).apply { marginEnd = gap } - } else { - LinearLayout.LayoutParams(0, dp(heightDp), 1f).apply { marginEnd = gap } + require(columns > 0) + return LinearLayout.LayoutParams(0, dp(heightDp), 1f).apply { + marginStart = dp(gapDp) / 2 + marginEnd = dp(gapDp) / 2 } } diff --git a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt index 221efa85..222f2e5b 100644 --- a/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/ImePanelRenderer.kt @@ -3,6 +3,10 @@ package llc.slacker.openime import android.content.Context import android.content.Intent import android.graphics.Typeface +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Color +import android.widget.FrameLayout import android.os.Build import android.view.Gravity import android.view.View @@ -66,22 +70,9 @@ internal class ImePanelRenderer( addHeader("切换键盘") val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(10), toPx(12), toPx(10)) + setPadding(toPx(8), toPx(12), toPx(8), toPx(10)) tag = "keyboard-select-panel" } - body.addView( - TextView(context).apply { - text = "选择输入布局" - textSize = ImeTypographyTokens.PANEL_BODY_SP - setPadding(toPx(4), 0, 0, toPx(8)) - tag = "panel-section-title" - }, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(28), - ), - ) - val modes = listOf( KeyboardMode.PINYIN_26 to "拼音 26 键", KeyboardMode.PINYIN_9 to "拼音 9 键", @@ -92,28 +83,21 @@ internal class ImePanelRenderer( val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { (modeValue, label) -> row.addView( - createKey(label, true, ImeTypographyTokens.BODY_SP) { onModeSelected(modeValue) }.apply { - val selected = currentMode() == modeValue - tag = if (selected) "tab-active" else "keyboard-choice" - contentDescription = "$label,${if (selected) "已选中" else "未选中"}" - if (Build.VERSION.SDK_INT >= 30) { - stateDescription = if (selected) "已选中" else "未选中" - } - }, - LinearLayout.LayoutParams(0, toPx(50), 1f).apply { - marginEnd = toPx(7) + keyboardChoice(modeValue, label), + LinearLayout.LayoutParams(0, toPx(96), 1f).apply { + marginEnd = toPx(4); marginStart = toPx(4) }, ) } if (chunk.size == 1) { - row.addView(View(context), LinearLayout.LayoutParams(0, toPx(50), 1f)) + row.addView(View(context), LinearLayout.LayoutParams(0, toPx(96), 1f)) } body.addView( row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(50), - ).apply { bottomMargin = toPx(7) }, + toPx(96), + ).apply { bottomMargin = toPx(8) }, ) } expandedPanel.addView( @@ -129,20 +113,18 @@ internal class ImePanelRenderer( addHeader("工具") val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(10), toPx(10), toPx(10), toPx(10)) + setPadding(toPx(12), toPx(12), toPx(4), 0) tag = "tools-panel" } val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - val handwritingAvailable = - HandwritingFeaturePolicy.entryEnabled(UnavailableHandwritingProvider) val cards = listOf( - ToolEntry("表情", Panel.EMOJI, R.drawable.ic_emoji), ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_clipboard, enabled = !isPasswordField()), - ToolEntry("手写输入", Panel.HANDWRITING, R.drawable.ic_handwriting, enabled = handwritingAvailable), + ToolEntry("表情", Panel.EMOJI, R.drawable.ic_emoji), ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_symbols), - ToolEntry("切换键盘", Panel.KEYBOARD_SELECT, R.drawable.ic_grid), - ToolEntry("文本编辑", Panel.TEXT_EDITOR, R.drawable.ic_keyboard), - ToolEntry("浮动键盘", iconRes = R.drawable.ic_game, action = onEnableFloatingKeyboard), + ToolEntry("语音输入", Panel.VOICE, R.drawable.ic_mic), + ToolEntry("切换键盘", Panel.KEYBOARD_SELECT, R.drawable.ic_keyboard), + ToolEntry("文本编辑", Panel.TEXT_EDITOR, R.drawable.ic_text_cursor), + ToolEntry("浮动键盘", iconRes = R.drawable.ic_floating, action = onEnableFloatingKeyboard), ToolEntry("设置", Panel.SETTINGS, R.drawable.ic_settings), ).filter { it.enabled } @@ -242,7 +224,7 @@ internal class ImePanelRenderer( LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(7) }, + ).apply { bottomMargin = toPx(8) }, ) var undoButton: ImeKeyView? = null @@ -290,7 +272,7 @@ internal class ImePanelRenderer( LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(140), - ).apply { bottomMargin = toPx(7) }, + ).apply { bottomMargin = toPx(8) }, ) val actions = LinearLayout(context).apply { @@ -334,7 +316,7 @@ internal class ImePanelRenderer( addHeader("表情") val body = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(12), toPx(12), toPx(12)) + setPadding(0, toPx(8), 0, 0) tag = "emoji-panel" } expandedPanel.addView( @@ -351,6 +333,7 @@ internal class ImePanelRenderer( labels: List, selected: String, onSelected: (String) -> Unit, + heightDp: Int = ImeGeometryTokens.TOUCH_TARGET_DP, ): HorizontalScrollView = HorizontalScrollView(context).apply { isHorizontalScrollBarEnabled = false isFillViewport = false @@ -362,19 +345,25 @@ internal class ImePanelRenderer( } var selectedView: View? = null labels.forEach { label -> - val chip = filterChip(label, label == selected) { onSelected(label) } + val chip = filterChip(label, label == selected) { onSelected(label) }.apply { + minimumHeight = toPx(heightDp) + if (heightDp == 32) { + minWidth = toPx(if (label == selected) 56 else 52) + if (label == selected) typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + } if (label == selected) selectedView = chip row.addView( chip, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(heightDp), ).apply { marginEnd = toPx(6) }, ) } addView( row, - ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP)), + ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, toPx(heightDp)), ) setOnScrollChangeListener { _, scrollX, _, _, _ -> chipScrollPositions[scrollKey] = scrollX @@ -427,22 +416,23 @@ internal class ImePanelRenderer( val categories = listOf( "常用", "中文", "英文", "数学", "序号", "特殊", "网络颜文字", "单位", "编程", "自定义", ) - val tabs = panelChipScroll(categories, symbolCategory) { category -> - if (category != symbolCategory) { - symbolCategory = category - renderSymbolContent(body, notifyRebuilt = true) - } + body.orientation = LinearLayout.HORIZONTAL + body.setPadding(toPx(8), toPx(5), toPx(4), 0) + val categoryColumn = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + categories.forEach { category -> + categoryColumn.addView(filterChip(category, category == symbolCategory) { + if (category != symbolCategory) { + symbolCategory = category + renderSymbolContent(body, notifyRebuilt = true) + } + }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(44))) } - body.addView( - tabs, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(8) }, - ) - + body.addView(panelVerticalScroll(categoryColumn, "symbol-categories"), + LinearLayout.LayoutParams(toPx(74), LinearLayout.LayoutParams.MATCH_PARENT).apply { marginEnd = toPx(5) }) + val content = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + body.addView(content, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) if (symbolCategory == "自定义") { - body.addView( + content.addView( createPanelButton("管理自定义符号", ImeTypographyTokens.BODY_SP, true).apply { contentDescription = "管理自定义符号" setOnClickListener { @@ -476,33 +466,33 @@ internal class ImePanelRenderer( ), ) } - items.chunked(6).forEach { chunk -> + items.chunked(5).forEach { chunk -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } chunk.forEach { symbol -> row.addView( createKey( symbol, false, - ImeTypographyTokens.BODY_SP, + ImeTypographyTokens.KEY_LETTER_SP, ) { onSymbolSelected(symbol) }, - gridCellParams(48, 6, 6), + gridCellParams(54, 5, 0), ) } - repeat(6 - chunk.size) { - row.addView(View(context), gridCellParams(48, 6, 6)) + repeat(5 - chunk.size) { + row.addView(View(context), gridCellParams(54, 5, 0)) } grid.addView( row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(6) }, + toPx(54), + ), ) } val scroll = panelVerticalScroll(grid, "symbols-scroll") rememberPanelVerticalScroll(scroll, "symbols:$symbolCategory") - body.addView( + content.addView( scroll, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, @@ -519,26 +509,37 @@ internal class ImePanelRenderer( notifyRebuilt: Boolean, ) { body.removeAllViews() - val categories = listOf("最近") + ImeData.emojiByCategory.keys.toList() - val tabs = panelChipScroll(categories, emojiCategory) { category -> + val categories = listOf("全部") + ImeData.emojiByCategory.keys.toList() + fun displayCategory(category: String): String = when (category) { + "人物/手势" -> "手势" + "动物/自然" -> "动物" + "食物/饮品" -> "食物" + else -> category + } + val tabs = panelChipScroll(categories.map(::displayCategory), displayCategory(emojiCategory), { label -> + val category = categories.first { displayCategory(it) == label } if (category != emojiCategory) { emojiCategory = category renderEmojiContent(body, notifyRebuilt = true) } - } + }, heightDp = 32) body.addView( tabs, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(10) }, + toPx(32), + ).apply { leftMargin = toPx(12); rightMargin = toPx(12); bottomMargin = toPx(4) }, ) - val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } - val items = if (emojiCategory == "最近") { - EmojiRecentRepository.load(context) + val grid = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(toPx(4), 0, toPx(4), 0) + } + val items = if (emojiCategory == "全部") { + (EmojiRecentRepository.load(context) + ImeData.emojiByCategory.values.flatten()).distinct() } else { - ImeData.emojiByCategory[emojiCategory].orEmpty() + val catalog = ImeData.emojiByCategory[emojiCategory].orEmpty() + if (emojiCategory == "笑脸") (ImeData.referenceSmileys + catalog).distinct() else catalog } if (items.isEmpty() && emojiCategory == "最近") { grid.addView( @@ -559,15 +560,16 @@ internal class ImePanelRenderer( chunk.forEach { emoji -> row.addView( createEmojiCell(emoji), - gridCellParams(48, 8, 4), + gridCellParams(42, 8, 0), ) } + repeat(8 - chunk.size) { row.addView(View(context), gridCellParams(42, 8, 0)) } grid.addView( row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(4) }, + toPx(42), + ), ) } @@ -601,7 +603,7 @@ internal class ImePanelRenderer( onTap: () -> Unit, ): TextView = TextView(context).apply { text = label - textSize = ImeTypographyTokens.CAPTION_SP + textSize = ImeTypographyTokens.BODY_SP gravity = Gravity.CENTER includeFontPadding = false minWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) @@ -647,14 +649,15 @@ internal class ImePanelRenderer( scaleType = ImageView.ScaleType.CENTER_INSIDE contentDescription = null }, - LinearLayout.LayoutParams(toPx(20), toPx(20)).apply { - bottomMargin = toPx(6) + LinearLayout.LayoutParams(toPx(24), toPx(24)).apply { + bottomMargin = toPx(10) }, ) card.addView( TextView(context).apply { text = label - textSize = ImeTypographyTokens.CAPTION_SP + typeface = android.graphics.Typeface.DEFAULT + textSize = ImeTypographyTokens.BODY_SP gravity = Gravity.CENTER includeFontPadding = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO @@ -667,8 +670,53 @@ internal class ImePanelRenderer( return card } + private fun keyboardChoice(mode: KeyboardMode, label: String): View { + val selected = currentMode() == mode + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(toPx(14), toPx(12), toPx(14), toPx(8)) + tag = if (selected) "keyboard-choice-selected" else "keyboard-choice" + contentDescription = "$label,${if (selected) "已选中" else "未选中"}" + isClickable = true + isFocusable = true + setOnClickListener { onFeedback(); onModeSelected(mode) } + addView(object : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + override fun onDraw(canvas: Canvas) { + val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES + val tokens = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark, AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context))) + val h = height / 4f + paint.color = tokens.functionKeyBackground + for (i in 0..3) { + val y = i * h + val w = if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.DIGITS) width * 0.19f else width * (0.12f + 0.03f * i) + canvas.drawRoundRect(0f, y, w, y + h * 0.72f, toPx(2).toFloat(), toPx(2).toFloat(), paint) + if (mode != KeyboardMode.DIGITS || i == 0 || i == 3) { + paint.color = if (selected && i >= 2) tokens.primary else tokens.functionKeyBackground + canvas.drawRoundRect(width * 0.81f, y, width.toFloat(), y + h * 0.72f, toPx(2).toFloat(), toPx(2).toFloat(), paint) + paint.color = tokens.functionKeyBackground + } + } + } + }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(TextView(context).apply { text = label; textSize = ImeTypographyTokens.BODY_SP; typeface = Typeface.DEFAULT_BOLD }, LinearLayout.LayoutParams(0, toPx(28), 1f)) + // A vector mark, not a text glyph: it is tinted and sized like every other icon. + addView(ImageView(context).apply { + setImageResource(if (selected) R.drawable.ic_check else R.drawable.ic_radio_off) + tag = if (selected) "keyboard-radio-selected" else "keyboard-radio-off" + scaleType = ImageView.ScaleType.FIT_CENTER + setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(toPx(24), toPx(24))) + }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(28))) + } + } + private fun symbolItems(category: String): List = when (category) { - "中文" -> ImeData.symbols["中文标点"].orEmpty() + "中文" -> listOf(",", "。", "、", ";", ":", "?", "!", "…", "—", "~", "·", "「", "」", "『", "』", "(", ")", "《", "》", "【", "】", "“", "”", "‘", "’") "英文" -> ImeData.symbols["英文标点"].orEmpty() "数学" -> listOf( ImeData.symbols["数学运算"].orEmpty(), diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index edece6d3..955501a9 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -10,6 +10,11 @@ import android.widget.FrameLayout import android.window.OnBackInvokedCallback class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { + companion object { const val EXTRA_EDIT_ACCENT = "edit_accent" } + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(SetupUi.appearanceContext(newBase)) + } + private lateinit var keyboardView: ImeKeyboardView private lateinit var host: FrameLayout private var backCallback: OnBackInvokedCallback? = null @@ -63,6 +68,9 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { ) setContentView(host) refreshWindowChrome() + if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_EDIT_ACCENT, false)) { + host.post { keyboardView.editAccentColor() } + } if (Build.VERSION.SDK_INT >= 33) { backCallback = OnBackInvokedCallback { handleBack() } onBackInvokedDispatcher.registerOnBackInvokedCallback( @@ -168,8 +176,8 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { } override fun onAppearanceChanged(appearance: ImeAppearance) { ImeSettingsRepository.saveAppearance(this, appearance) - refreshWindowChrome() refreshLiveIme() + recreate() } override fun onShiftStateChanged(state: ShiftState) = Unit override fun onCandidateExpanded(open: Boolean) = Unit diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt index d407ca7f..74004077 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsRepository.kt @@ -65,21 +65,21 @@ object ImeSettingsRepository { fun loadKeyboardHeightPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getInt(KEY_KEYBOARD_HEIGHT, 100) - .coerceIn(92, 120) + .coerceIn(80, 120) fun saveKeyboardHeightPercent(context: Context, percent: Int) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(92, 120)).apply() + .edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(80, 120)).apply() } fun loadFloatingWidthPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_FLOATING_WIDTH, 88) - .coerceIn(72, 96) + .getInt(KEY_FLOATING_WIDTH, 100) + .coerceIn(72, 100) fun saveFloatingWidthPercent(context: Context, percent: Int) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit().putInt(KEY_FLOATING_WIDTH, percent.coerceIn(72, 96)).apply() + .edit().putInt(KEY_FLOATING_WIDTH, percent.coerceIn(72, 100)).apply() } fun loadFloatingOpacityPercent(context: Context): Int = @@ -127,7 +127,7 @@ object ImeSettingsRepository { fun loadSound(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getBoolean(KEY_SOUND, true) + .getBoolean(KEY_SOUND, false) fun saveSound(context: Context, enabled: Boolean) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -145,7 +145,7 @@ object ImeSettingsRepository { fun loadPopup(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getBoolean(KEY_POPUP, false) + .getBoolean(KEY_POPUP, true) fun savePopup(context: Context, enabled: Boolean) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -163,7 +163,7 @@ object ImeSettingsRepository { fun loadSkinOpacity(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_SKIN_OPACITY, 95) + .getInt(KEY_SKIN_OPACITY, 100) fun loadSkinRadius(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -171,7 +171,7 @@ object ImeSettingsRepository { fun loadSkinFont(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_SKIN_FONT, 17) + .getInt(KEY_SKIN_FONT, 21) fun loadSkinColor(context: Context): String = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) diff --git a/app/src/main/java/llc/slacker/openime/ImeState.kt b/app/src/main/java/llc/slacker/openime/ImeState.kt index ccbd2aab..98f443f2 100644 --- a/app/src/main/java/llc/slacker/openime/ImeState.kt +++ b/app/src/main/java/llc/slacker/openime/ImeState.kt @@ -21,6 +21,7 @@ enum class Panel { TEXT_EDITOR, SETTINGS, FUZZY_SETTINGS, + SKIN_SETTINGS, CANDIDATE_EXPANDED, } @@ -47,10 +48,10 @@ data class ImeState( val selectedPinyin9Filter: String = "", val theme: ImeTheme = ImeTheme.IOS, val appearance: ImeAppearance = ImeAppearance.SYSTEM, - val soundEnabled: Boolean = true, + val soundEnabled: Boolean = false, val hapticEnabled: Boolean = true, // Default off, matching ImeSettingsRepository.loadPopup and the View field. - val popupEnabled: Boolean = false, + val popupEnabled: Boolean = true, val fuzzyPinyinEnabled: Boolean = false, val editorInfo: EditorInfo? = null, val passwordField: Boolean = false, @@ -58,9 +59,9 @@ data class ImeState( // First real emoji category tab (see ImeData.emojiByCategory order). val emojiCategory: String = "笑脸", val voiceState: VoiceUiState = VoiceUiState(), - val skinOpacity: Int = 95, + val skinOpacity: Int = 100, val skinRadius: Int = 8, - val skinFontSize: Int = 17, + val skinFontSize: Int = 21, val skinPrimaryColor: String = AccentPalette.DEFAULT, ) diff --git a/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt index a009fd97..4e54cd9c 100644 --- a/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/ImeThemeApplier.kt @@ -23,6 +23,7 @@ internal class ImeThemeApplier( private val toPx: (Int) -> Int, private val statefulRounded: (Int, Int, Int) -> android.graphics.drawable.StateListDrawable, private val keyMainTextScale: () -> Float, + private val referenceScale: () -> Float, private val skinRadiusPx: () -> Int, private val skinOpacity: () -> Int, private val skinPrimaryColor: () -> String, @@ -63,6 +64,13 @@ internal class ImeThemeApplier( } private fun applyKey(view: ImeKeyView, t: ImeTheme.Tokens) { + if (view.tag == "candidate-grid" || view.tag == "candidate-grid-first") { + val selected = view.tag == "candidate-grid-first" + val fill = if (selected) ImeSurfacePolicy.selectedSurface(t) else t.expandedBackground + view.background = ImeDrawableFactory.rounded(fill, 0, ImeSurfacePolicy.divider(t), toPx(1).coerceAtLeast(1)) + view.setColors(if (selected) t.primary else t.keyText) + return + } val side = isSideKey(view) val label = view.contentDescription?.toString().orEmpty() val function = isFunctionKey(view) @@ -70,12 +78,8 @@ internal class ImeThemeApplier( isWhiteKey(view) || (!function && DIGITS_ONLY.matches(label)) ) - val primary = !side && ( - view.tag == "tab-active" || - view.tag == "key-shift-caps" || - view.tag == "key-shift-active" || - view.tag == "key-enter" - ) + val primary = view.tag == "tab-active" || + (view.tag == "key-enter" && (!side || label == "确定")) val color = when { primary -> t.primary @@ -109,7 +113,7 @@ internal class ImeThemeApplier( view.setColors(onPrimary, onPrimary, onPrimary) } white -> - view.setColors(t.lightKeyText, t.lightKeyText, t.lightKeyText) + view.setColors(t.lightKeyText, t.keySecondaryText, t.lightKeyText) side -> view.setColors(t.sideKeyText, t.sideKeyText, t.sideKeyText) function -> @@ -126,7 +130,7 @@ internal class ImeThemeApplier( private fun applyLinearLayout(view: LinearLayout, t: ImeTheme.Tokens) { when (view.tag) { "candidate-first-row" -> { - val selected = ImeSurfacePolicy.selectedSurface(t) + val selected = t.keyBackground view.background = statefulRounded( selected, ImeSurfacePolicy.pressedSurface(selected, t), @@ -147,20 +151,25 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) } - "nine-symbol-scroll-content", - "digits-symbol-scroll-content", - -> { - view.background = ImeDrawableFactory.rounded( - t.sideKeyBackground, - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), - ) + "nine-symbol-scroll-content", "digits-symbol-scroll-content" -> view.background = null + "keyboard-choice", "keyboard-choice-selected" -> { + val selected = view.tag == "keyboard-choice-selected" + view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16), + if (selected) t.primary else Color.TRANSPARENT, if (selected) toPx(2) else 0) + view.invalidate() } + "segmented-track" -> view.background = ImeDrawableFactory.rounded(t.functionKeyBackground, toPx(12)) + "segmented-track-tall" -> view.background = paintedWithinTarget( + ImeDrawableFactory.rounded(t.functionKeyBackground, toPx(12)), + SEGMENT_PAINTED_DP, + ) + "phrase-card" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(14)) "setting-group" -> { view.background = ImeDrawableFactory.rounded( t.toolCardBackground, toPx(ImeGeometryTokens.CARD_RADIUS_DP), ImeSurfacePolicy.divider(t), - toPx(1).coerceAtLeast(1), + 0, ) } "clip-card" -> { @@ -168,25 +177,13 @@ internal class ImeThemeApplier( t.toolCardBackground, toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ImeSurfacePolicy.divider(t), - toPx(1).coerceAtLeast(1), - ) - } - "panel-head" -> { - view.background = ImeDrawableFactory.rounded( - t.panelHeadBackground, - toPx(ImeGeometryTokens.CARD_RADIUS_DP), - ImeSurfacePolicy.divider(t), - toPx(1).coerceAtLeast(1), + 0, ) } + "panel-head" -> view.setBackgroundColor(t.panelHeadBackground) "toolbar-row" -> view.setBackgroundColor(t.toolbarBackground) "compose-zone" -> view.setBackgroundColor(t.candidateBackground) - "candidate-field" -> { - view.background = ImeDrawableFactory.rounded( - t.candidateBackground, - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), - ) - } + "candidate-field" -> view.background = null else -> { if ( (view.tag as? String)?.startsWith("tool:") == true && @@ -195,7 +192,7 @@ internal class ImeThemeApplier( view.background = statefulRounded( t.toolCardBackground, ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + toPx(ImeGeometryTokens.CARD_RADIUS_DP), ) } } @@ -215,16 +212,51 @@ internal class ImeThemeApplier( } private fun applyScrollView(view: ScrollView, t: ImeTheme.Tokens) { - if (view.tag == "nine-punct-stack" || view.tag == "digits-symbol-scroll") { - view.background = ImeDrawableFactory.rounded( - t.sideKeyBackground, - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + 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. + // 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)), + toPx(ImeGeometryTokens.KEY_GAP_DP) / 2, ) + } else if (view.tag == "nine-punct-stack" || view.tag == "digits-symbol-scroll") { + view.background = null } } private fun applyImageView(view: ImageView, t: ImeTheme.Tokens) { + val iconSize = when { + 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 + hasAncestorTag(view, "tools-panel") -> 24 + (view.parent as? View)?.tag == "toolbar-row" -> 22 + else -> 0 + } + if (iconSize > 0) { + fun fitIcon() { + val drawable = view.drawable ?: return + if (view.width <= 0 || view.height <= 0) return + val size = toPx(iconSize).toFloat() + val scale = size / drawable.intrinsicWidth.coerceAtLeast(1) + view.imageMatrix = android.graphics.Matrix().apply { + setScale(scale, scale) + postTranslate((view.width - size) / 2f, (view.height - size) / 2f) + } + } + view.setPadding(0, 0, 0, 0) + view.scaleType = ImageView.ScaleType.MATRIX + if (view.getTag(REFERENCE_ICON_SIZE) == null) { + view.setTag(REFERENCE_ICON_SIZE, iconSize) + view.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> fitIcon() } + } + view.post { fitIcon() } + } + when { + (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) + } view.tag == "candidate-emoji" || view.tag == "candidate-expand" -> { view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) view.background = statefulRounded( @@ -240,8 +272,13 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } - view.tag == "key-panel-back" -> { + view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + view.tag == "keyboard-radio-selected" -> + view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.selectedText(t)) + view.tag == "keyboard-radio-off" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + view.tag == "key-panel-back" -> { + view.imageTintList = ColorStateList.valueOf(t.keyText) view.background = statefulRounded( t.panelHeadBackground, ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), @@ -252,7 +289,7 @@ internal class ImeThemeApplier( view.parent is LinearLayout && (view.parent as LinearLayout).tag == "toolbar-row" ) || hasAncestorTag(view, "tools-panel") -> { - view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) + view.imageTintList = ColorStateList.valueOf(if (hasAncestorTag(view, "tools-panel")) ImeSurfacePolicy.selectedText(t) else t.keyText) if (view.isClickable) { view.background = statefulRounded( Color.TRANSPARENT, @@ -265,13 +302,26 @@ internal class ImeThemeApplier( } private fun applySeekBar(view: SeekBar, t: ImeTheme.Tokens) { + view.progressDrawable = view.context.getDrawable(android.R.drawable.progress_horizontal)?.mutate() view.progressTintList = ColorStateList.valueOf(t.primary) - view.thumbTintList = ColorStateList.valueOf(t.primary) - view.progressBackgroundTintList = - ColorStateList.valueOf(t.panelHeadBackground) + view.progressBackgroundTintList = ColorStateList.valueOf(t.functionKeyBackground) + view.secondaryProgressTintList = ColorStateList.valueOf(t.functionKeyBackground) + if (android.os.Build.VERSION.SDK_INT >= 29) { + view.maxHeight = toPx(4) + view.minHeight = toPx(4) + } + view.thumbTintList = null + view.thumb = ImeDrawableFactory.rounded(Color.WHITE, toPx(99), t.border, toPx(1)).apply { + setSize(toPx(20), toPx(20)) + } + view.splitTrack = false } private fun applyTextView(view: TextView, t: ImeTheme.Tokens) { + if (view.parent !is ImeKeyView) { + val baseSize = (view.getTag(REFERENCE_TEXT_SIZE) as? Float) ?: (view.textSize / view.resources.displayMetrics.scaledDensity).also { view.setTag(REFERENCE_TEXT_SIZE, it) } + view.textSize = baseSize * referenceScale() + } val tag = view.tag as? String if (view.parent !is ImeKeyView) { view.setTextColor(t.keyText) @@ -325,14 +375,14 @@ internal class ImeThemeApplier( view.background = statefulRounded( selected, ImeSurfacePolicy.pressedSurface(selected, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + toPx(if (hasAncestorTag(view, "emoji-panel")) 99 else ImeGeometryTokens.CONTROL_RADIUS_DP), ) } tag == "panel-tab" -> { view.setTextColor(t.keySecondaryText) view.background = statefulRounded( - t.panelHeadBackground, + Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) @@ -362,38 +412,74 @@ internal class ImeThemeApplier( tag == "clipboard-retention-action" -> { view.setTextColor(t.keyText) - view.background = statefulRounded( - t.panelHeadBackground, - ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + view.background = paintedWithinTarget( + statefulRounded( + t.panelHeadBackground, + ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), + toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + ), + RETENTION_PAINTED_DP, ) } - tag == "clipboard-retention-destructive" || - tag == "clipboard-clear-confirm" || - tag?.startsWith("phrase-delete:") == true -> { - val destructiveSurface = ImeSurfacePolicy.destructiveSurface(t) - view.setTextColor(ImeSurfacePolicy.destructiveText(t)) - view.background = statefulRounded( - destructiveSurface, - ImeSurfacePolicy.pressedSurface(destructiveSurface, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + tag == "clipboard-retention-destructive" || tag == "clipboard-clear-confirm" -> { + val destructiveText = ImeSurfacePolicy.destructiveLabel(t) + view.setTextColor(destructiveText) + view.background = paintedWithinTarget( + ImeDrawableFactory.rounded( + t.panelHeadBackground, toPx(8), destructiveText, toPx(1), + ), + RETENTION_PAINTED_DP, ) } + tag?.startsWith("phrase-delete:") == true -> { + val destructiveText = ImeSurfacePolicy.destructiveLabel(t) + view.setTextColor(destructiveText) + view.background = ImeDrawableFactory.rounded( + t.panelHeadBackground, toPx(8), destructiveText, toPx(1), + ) + } + + tag == "segment-selected" -> { + view.setTextColor(t.keyText) + view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(10), t.border, toPx(1)) + view.typeface = android.graphics.Typeface.DEFAULT_BOLD + } + tag == "segment-option" -> { view.setTextColor(t.keySecondaryText); view.background = null } + tag == "segment-selected-tall" -> { + view.setTextColor(t.keyText) + view.background = paintedWithinTarget( + ImeDrawableFactory.rounded(t.toolCardBackground, toPx(10), t.border, toPx(1)), + SEGMENT_PAINTED_DP, + ) + view.typeface = android.graphics.Typeface.DEFAULT_BOLD + } + tag == "segment-option-tall" -> { view.setTextColor(t.keySecondaryText); view.background = null } + tag == "keyboard-radio-selected" -> view.setTextColor(t.primary) + tag == "fuzzy-rules" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(16)) + tag == "textedit-spacer" -> view.background = null + tag == "textedit-center" -> { + view.setTextColor(t.primary) + view.background = ImeDrawableFactory.rounded(ImeSurfacePolicy.selectedSurface(t), toPx(12)) + } + tag?.startsWith("clip-pin:") == true || tag?.startsWith("phrase-edit:") == true || tag?.startsWith("phrase-delete:") == true -> { + view.setTextColor(t.keySecondaryText); view.background = null + view.compoundDrawableTintList = ColorStateList.valueOf(if (view.isSelected) t.primary else t.keySecondaryText) + } tag?.startsWith("punct:") == true || tag?.startsWith("digit-symbol:") == true -> { view.setTextColor(t.keyText) view.background = statefulRounded( - Color.TRANSPARENT, + t.sideKeyBackground, ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } tag == "nine-pinyin-path-selected" -> { - val selected = ImeSurfacePolicy.selectedSurface(t) - view.setTextColor(ImeSurfacePolicy.selectedText(t)) + val selected = ImeDrawableFactory.blend(t.primary, t.sideKeyBackground, 0.22f) + view.setTextColor(ImeSurfacePolicy.accentTextOn(t.primary, selected)) view.background = statefulRounded( selected, ImeSurfacePolicy.pressedSurface(selected, t), @@ -410,33 +496,7 @@ internal class ImeThemeApplier( ) } - tag == "accent-custom" -> { - val customSelected = AccentPalette.presets.none { - AccentPalette.normalize(it.first) == - AccentPalette.normalize(skinPrimaryColor()) - } - view.setTextColor( - if (customSelected) { - ImeDrawableFactory.contrastText(t.primary) - } else { - t.keyText - }, - ) - view.background = if (customSelected) { - statefulRounded( - t.primary, - ImeSurfacePolicy.primaryPressed(t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), - ) - } else { - statefulRounded( - t.panelHeadBackground, - ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), - ) - } - } - + tag == "accent-custom" -> { view.setTextColor(t.keyText); view.background = null } tag?.startsWith("accent-selected-mark:") == true -> { val hex = tag.substringAfter(':') view.setTextColor( @@ -481,7 +541,7 @@ internal class ImeThemeApplier( tag == "association-candidate" -> { view.setTextColor(t.candidateText) view.background = statefulRounded( - t.toolCardBackground, + Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) @@ -497,7 +557,7 @@ internal class ImeThemeApplier( view.background = statefulRounded( t.primary, ImeSurfacePolicy.primaryPressed(t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } @@ -569,10 +629,23 @@ internal class ImeThemeApplier( ) } "setting-divider" -> - view.setBackgroundColor(ImeSurfacePolicy.divider(t)) + view.setBackgroundColor(ImeDrawableFactory.withAlpha(t.border, if (ImeSurfacePolicy.isDark(t)) 150 else 48)) } } + /** Paint a 34dp pill inside a 48dp touch target. */ + /** + * Paint [drawable] [paintedDp] tall, centred inside a full touch-target + * high view: the design keeps compact controls, the touch target stays 48dp. + */ + private fun paintedWithinTarget( + drawable: android.graphics.drawable.Drawable, + paintedDp: Int, + ): InsetDrawable { + val inset = toPx((ImeGeometryTokens.TOUCH_TARGET_DP - paintedDp) / 2) + return InsetDrawable(drawable, 0, inset, 0, inset) + } + private fun hasAncestorTag(view: View, tag: String): Boolean { var parent = view.parent while (parent is View) { @@ -583,6 +656,11 @@ internal class ImeThemeApplier( } private companion object { + /** Painted heights of controls whose touch target is a full 48dp. */ + const val SEGMENT_PAINTED_DP = 34 + const val RETENTION_PAINTED_DP = 36 + const val REFERENCE_ICON_SIZE = 0x1F000011 + const val REFERENCE_TEXT_SIZE = 0x1F000010 val DIGITS_ONLY = Regex("[0-9]+") } } diff --git a/app/src/main/java/llc/slacker/openime/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/ImeTopZone.kt index a7f5bd70..e8f4b8c8 100644 --- a/app/src/main/java/llc/slacker/openime/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/ImeTopZone.kt @@ -39,16 +39,19 @@ internal class ImeTopZone( onClipboard: () -> Unit, onEmoji: () -> Unit, onSymbols: () -> Unit, + onTextEditor: () -> Unit, onHideKeyboard: () -> Unit, onTools: () -> Unit, onExpandCandidates: () -> Unit, private val onUndoClear: () -> Unit, private val onUndoClearExpired: () -> Unit, + private val onAssociationDismiss: () -> Unit = {}, ) : LinearLayout(context) { val toolbarRow = LinearLayout(context) val composeZone = LinearLayout(context) val composition = EditText(context) val candidateField = LinearLayout(context) + val expandedCaption = TextView(context) val candidateRow = LinearLayout(context) val candidateScroll = HorizontalScrollView(context) val associationRow = LinearLayout(context) @@ -58,10 +61,21 @@ internal class ImeTopZone( val voiceInlineIcon = ImageView(context) val voiceInlineStatus = TextView(context) val voiceInlineWaves = mutableListOf() - val undoClearAction = TextView(context) + val associationBack = ImageView(context) + val undoBanner = LinearLayout(context) + private val undoBannerLabel = TextView(context) + private val undoBannerAction = TextView(context) + private lateinit var toolbarIcons: List + private lateinit var keyboardHide: View + private lateinit var associationScroll: HorizontalScrollView + private var toolbarMode = ToolbarMode.NORMAL + private var associationsShown = false + + /** What the toolbar row shows; icons, associations and the undo banner are exclusive. */ + private enum class ToolbarMode { NORMAL, ASSOCIATION, UNDO } private val hideUndoClearRunnable = Runnable { - if (undoClearAction.visibility == View.VISIBLE) { - undoClearAction.visibility = View.GONE + if (toolbarRow.findViewWithTag("undo-toolbar")?.isActivated == true) { + toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = false onUndoClearExpired() } } @@ -76,24 +90,37 @@ internal class ImeTopZone( tag = "toolbar-row" orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - setPadding(toPx(10), 0, toPx(10), 0) + setPadding(toPx(6), 0, toPx(6), 0) minimumHeight = toPx(ImeGeometryTokens.TOOLBAR_HEIGHT_DP) } - toolbarRow.addView( - toolbarIcon(R.drawable.ic_grid, "切换键盘", "keyboard-selector", onKeyboardSelect), - touchTargetParams(), - ) - toolbarRow.addView( + toolbarIcons = listOf( + toolbarIcon(R.drawable.ic_keyboard, "切换键盘", "keyboard-selector", onKeyboardSelect), toolbarIcon(R.drawable.ic_clipboard, "剪贴板", "clipboard-toolbar", onClipboard), - touchTargetParams(), - ) - toolbarRow.addView( toolbarIcon(R.drawable.ic_emoji, "表情", "toolbar", onEmoji), - touchTargetParams(), + toolbarIcon(R.drawable.ic_text_cursor, "文本编辑", "toolbar", onTextEditor), + toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndoClear() }, + toolbarIcon(R.drawable.ic_grid, "更多", "toolbar", onTools), ) + toolbarIcons.forEach { toolbarRow.addView(it, LinearLayout.LayoutParams(0, toPx(48), 1f)) } + + // Association state: "‹ words… ∨". The back control and the hide + // control are fixed-width; the words take the rest of the row. + associationBack.apply { + tag = "association-back" + contentDescription = "返回工具栏" + setImageResource(R.drawable.ic_arrow_back) + scaleType = ImageView.ScaleType.CENTER_INSIDE + visibility = View.GONE + isClickable = true + isFocusable = true + setOnClickListener { + onFeedback() + onAssociationDismiss() + } + } toolbarRow.addView( - toolbarIcon(R.drawable.ic_symbols, "符号", "toolbar", onSymbols), - touchTargetParams(), + associationBack, + LinearLayout.LayoutParams(toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(48)), ) associationRow.apply { @@ -101,8 +128,9 @@ internal class ImeTopZone( gravity = Gravity.CENTER_VERTICAL tag = "association-row" } - val associationScroll = HorizontalScrollView(context).apply { + associationScroll = HorizontalScrollView(context).apply { tag = "association-scroll" + visibility = View.GONE isHorizontalScrollBarEnabled = false overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS addView( @@ -115,51 +143,54 @@ internal class ImeTopZone( } toolbarRow.addView( associationScroll, - LinearLayout.LayoutParams( - 0, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - 1f, - ).apply { marginStart = toPx(4) }, + LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), ) - undoClearAction.apply { + + keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard) + toolbarRow.addView(keyboardHide, LinearLayout.LayoutParams(0, toPx(48), 1f)) + + // After a clear-all: "已清空 [撤销]" for the undo window. + undoBanner.apply { + tag = "undo-banner" + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + visibility = View.GONE + setPadding(toPx(16), 0, toPx(8), 0) + } + undoBannerLabel.apply { + text = "已清空" + textSize = ImeTypographyTokens.BODY_SP + includeFontPadding = false + gravity = Gravity.CENTER_VERTICAL + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + undoBannerAction.apply { tag = "undo-clear-action" text = "撤销" textSize = ImeTypographyTokens.BODY_SP + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) gravity = Gravity.CENTER includeFontPadding = false - minWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - visibility = View.GONE + minWidth = toPx(72) + setPadding(toPx(16), 0, toPx(16), 0) isClickable = true isFocusable = true contentDescription = "撤销清空" setOnClickListener { removeCallbacks(hideUndoClearRunnable) - visibility = View.GONE + hideUndoClear() onFeedback() onUndoClear() } } - toolbarRow.addView( - undoClearAction, - LinearLayout.LayoutParams( - toPx(64), - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ), - ) - toolbarRow.addView( - toolbarIcon(R.drawable.ic_keyboard_hide, "收起键盘", "keyboard-hide", onHideKeyboard), - touchTargetParams(), - ) - toolbarRow.addView( - toolbarIcon(R.drawable.ic_more, "更多", "toolbar", onTools), - touchTargetParams(), - ) + undoBanner.addView(undoBannerLabel, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + undoBanner.addView(undoBannerAction, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(40))) + toolbarRow.addView(undoBanner, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f)) addView( toolbarRow, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOOLBAR_HEIGHT_DP), + toPx(ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP), ), ) @@ -172,6 +203,8 @@ internal class ImeTopZone( tag = "pinyin-composition-editor" contentDescription = "可编辑拼音预编辑" textSize = ImeTypographyTokens.BODY_SP + letterSpacing = 0.04f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) gravity = Gravity.CENTER_VERTICAL or Gravity.START inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS setSingleLine(true) @@ -208,7 +241,7 @@ internal class ImeTopZone( orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL tag = "candidate-field" - setPadding(toPx(8), 0, toPx(8), 0) + setPadding(toPx(6), 0, toPx(0), 0) } candidateRow.apply { orientation = LinearLayout.HORIZONTAL @@ -224,6 +257,11 @@ internal class ImeTopZone( ), ) } + expandedCaption.apply { + text = "候选字词"; textSize = ImeTypographyTokens.BODY_SP; gravity = Gravity.CENTER_VERTICAL + setPadding(toPx(10), 0, 0, 0); tag = "panel-note"; visibility = View.GONE + } + candidateField.addView(expandedCaption, LinearLayout.LayoutParams(0, toPx(48), 1f)) candidateField.addView( candidateScroll, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), @@ -242,8 +280,6 @@ internal class ImeTopZone( onEmoji() } } - candidateField.addView(candidateEmojiButton, touchTargetParams()) - candidateExpandButton.apply { tag = "candidate-expand" setImageResource(R.drawable.ic_chevron_down) @@ -258,15 +294,6 @@ internal class ImeTopZone( } } candidateField.addView(candidateExpandButton, touchTargetParams()) - candidateField.addView( - toolbarIcon( - R.drawable.ic_keyboard_hide, - "收起键盘", - "keyboard-hide-composing", - onHideKeyboard, - ), - touchTargetParams(), - ) composeZone.addView( candidateField, LinearLayout.LayoutParams( @@ -302,7 +329,7 @@ internal class ImeTopZone( ) voiceInlineStatus.apply { tag = "voice-inline-status" - text = "正在聆听…" + text = "正在聆听" textSize = ImeTypographyTokens.BODY_SP setTextColor(Color.WHITE) includeFontPadding = false @@ -320,7 +347,7 @@ internal class ImeTopZone( orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER } - repeat(6) { index -> + repeat(10) { index -> val bar = View(context).apply { tag = "voice-inline-wave-$index" background = ImeDrawableFactory.rounded( @@ -339,37 +366,86 @@ internal class ImeTopZone( }, ) } - voiceInlineZone.addView( - inlineWave, - LinearLayout.LayoutParams(toPx(42), LinearLayout.LayoutParams.MATCH_PARENT), - ) + voiceInlineZone.addView(inlineWave, 0, + LinearLayout.LayoutParams(toPx(64), LinearLayout.LayoutParams.MATCH_PARENT).apply { marginEnd = toPx(12) }) + voiceInlineIcon.visibility = View.GONE + voiceInlineZone.addView(TextView(context).apply { + text = "↑ 上滑取消" + textSize = ImeTypographyTokens.SMALL_SP + gravity = Gravity.CENTER + tag = "voice-cancel-hint" + includeFontPadding = false + }, LinearLayout.LayoutParams(toPx(88), toPx(30))) addView( voiceInlineZone, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(54), ).apply { setMargins(toPx(8), toPx(8), toPx(8), toPx(8)) }, ) } + fun showAssociations(show: Boolean) { + associationsShown = show + if (toolbarMode != ToolbarMode.UNDO) { + toolbarMode = if (show) ToolbarMode.ASSOCIATION else ToolbarMode.NORMAL + } + refreshToolbar() + } + fun showUndoClear() { removeCallbacks(hideUndoClearRunnable) - undoClearAction.visibility = View.VISIBLE - undoClearAction.bringToFront() + toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = true + toolbarMode = ToolbarMode.UNDO + refreshToolbar() postDelayed(hideUndoClearRunnable, CLEAR_UNDO_VISIBLE_MS) } fun hideUndoClear(discardSnapshot: Boolean = false) { removeCallbacks(hideUndoClearRunnable) - val wasVisible = undoClearAction.visibility == View.VISIBLE - undoClearAction.visibility = View.GONE + val wasVisible = toolbarRow.findViewWithTag("undo-toolbar")?.isActivated == true + toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = false + if (toolbarMode == ToolbarMode.UNDO) { + toolbarMode = if (associationsShown) ToolbarMode.ASSOCIATION else ToolbarMode.NORMAL + refreshToolbar() + } if (discardSnapshot && wasVisible) onUndoClearExpired() } + private fun refreshToolbar() { + val normal = toolbarMode == ToolbarMode.NORMAL + val association = toolbarMode == ToolbarMode.ASSOCIATION + toolbarIcons.forEach { it.visibility = if (normal) View.VISIBLE else View.GONE } + associationBack.visibility = if (association) View.VISIBLE else View.GONE + associationScroll.visibility = if (association) View.VISIBLE else View.GONE + undoBanner.visibility = if (toolbarMode == ToolbarMode.UNDO) View.VISIBLE else View.GONE + keyboardHide.visibility = if (toolbarMode == ToolbarMode.UNDO) View.GONE else View.VISIBLE + (keyboardHide.layoutParams as? LinearLayout.LayoutParams)?.let { params -> + if (association) { + params.width = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + params.weight = 0f + } else { + params.width = 0 + params.weight = 1f + } + keyboardHide.layoutParams = params + } + } + + /** Colour the undo banner from the active tokens (called with every theme pass). */ + fun applyTokens(t: ImeTheme.Tokens) { + undoBannerLabel.setTextColor(t.keyText) + undoBannerAction.setTextColor(ImeSurfacePolicy.selectedText(t)) + undoBannerAction.background = ImeDrawableFactory.rounded( + ImeSurfacePolicy.subtleAccentSurface(t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ) + } + fun setContentInset(contentInsetPx: Int) { - toolbarRow.setPadding(contentInsetPx + toPx(10), 0, contentInsetPx + toPx(10), 0) + toolbarRow.setPadding(contentInsetPx + toPx(6), 0, contentInsetPx + toPx(6), 0) composition.setPadding(contentInsetPx + toPx(14), toPx(3), contentInsetPx + toPx(14), 0) } @@ -379,11 +455,15 @@ internal class ImeTopZone( ) { val composing = state == ImeTopZoneState.COMPOSING || state == ImeTopZoneState.CANDIDATE_EXPANDED + if (composing && toolbarMode == ToolbarMode.UNDO) hideUndoClear() toolbarRow.visibility = if (state == ImeTopZoneState.IDLE) View.VISIBLE else View.GONE composeZone.visibility = if (composing) View.VISIBLE else View.GONE voiceInlineZone.visibility = if (state == ImeTopZoneState.VOICE_INLINE) View.VISIBLE else View.GONE composition.visibility = if (composing && showCompositionEditor) View.VISIBLE else View.GONE candidateField.visibility = if (composing) View.VISIBLE else View.GONE + val expanded = state == ImeTopZoneState.CANDIDATE_EXPANDED + candidateScroll.visibility = if (expanded) View.GONE else View.VISIBLE + expandedCaption.visibility = if (expanded) View.VISIBLE else View.GONE } private fun toolbarIcon( @@ -394,7 +474,7 @@ internal class ImeTopZone( ): ImageView = ImageView(context).apply { contentDescription = description tag = tagValue - minimumWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + minimumWidth = 0 minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) scaleType = ImageView.ScaleType.CENTER_INSIDE setImageResource(iconRes) diff --git a/app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt b/app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt index 8b2cd512..64ddbdc1 100644 --- a/app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt +++ b/app/src/main/java/llc/slacker/openime/InlineVoicePresenter.kt @@ -103,7 +103,7 @@ internal class InlineVoicePresenter( if (!active) return val palette = tokens() val backgroundColor = - if (cancelling || error) palette.destructive else palette.primary + if (cancelling || error) palette.destructive else ImeDrawableFactory.blend(palette.primary, palette.toolbarBackground, 0.16f) if (paletteColor == backgroundColor) return paletteColor = backgroundColor @@ -111,12 +111,16 @@ internal class InlineVoicePresenter( backgroundColor, toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), ) - val foregroundColor = ImeDrawableFactory.contrastText(backgroundColor) + val foregroundColor = palette.keyText + zone().findViewWithTag("voice-cancel-hint")?.apply { + setTextColor(foregroundColor) + background = ImeDrawableFactory.rounded(palette.functionKeyBackground, toPx(99)) + } icon().imageTintList = ColorStateList.valueOf(foregroundColor) status().setTextColor(foregroundColor) waves().forEach { bar -> bar.background = ImeDrawableFactory.rounded( - foregroundColor, + palette.primary, toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } diff --git a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt index 1ff15afb..e584e14f 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -43,6 +43,14 @@ class InputConnectionGateway( private val connection: () -> InputConnection?, private val isPassword: () -> Boolean = { false }, private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L }, + /** + * Editors with no text type (terminals, games, remote desktops: TYPE_NULL). + * Their InputConnection is usually Android's BaseInputConnection in dummy + * mode, which edits a private buffer: deleteSurroundingText "succeeds" and does + * nothing. They only understand key events, so typing and deleting use those. + */ + private val isRawKeys: () -> Boolean = { false }, + private val keyEventsFor: (String) -> Array? = ::keyEventsForText, ) { data class CursorSnapshot( @@ -96,7 +104,28 @@ class InputConnectionGateway( fun commitText(text: String) { if (text.isEmpty()) return invalidateClearUndo() - connection()?.commitText(text, 1) + val ic = connection() ?: return + if (isRawKeys() && typeAsKeyEvents(ic, text)) return + // One Binder transaction carries about 1 MB: committing a huge paste or + // transcript in one call throws TransactionTooLargeException and takes the + // keyboard down with it. Chunk it, never splitting a surrogate pair. + chunksForCommit(text).forEach { chunk -> ic.commitText(chunk, 1) } + } + + /** + * Run [block] as one atomic editor edit. The editor then reports a single + * final selection/composing state; without it a commit followed by a new + * composing span produces an intermediate callback (cursor moved, no + * composing region) that the service reads as the user leaving the span. + */ + fun batchEdit(block: () -> T): T { + val ic = connection() + ic?.beginBatchEdit() + try { + return block() + } finally { + ic?.endBatchEdit() + } } fun setComposingText(text: String) { @@ -110,6 +139,12 @@ class InputConnectionGateway( } } + /** The character before the cursor, or null when unknown (and always in password fields). */ + fun charBeforeCursor(): Char? { + if (isPassword()) return null + return runCatching { connection()?.getTextBeforeCursor(1, 0)?.lastOrNull() }.getOrNull() + } + fun finishComposing() { connection()?.finishComposingText() } @@ -134,14 +169,30 @@ class InputConnectionGateway( @Volatile private var knownSelectionEnd: Int = -1 - fun updateSelection(start: Int, end: Int) { + /** True once the editor itself has reported a selection (onUpdateSelection), not just its start-up values. */ + @Volatile + private var selectionReportedByEditor = false + + fun updateSelection(start: Int, end: Int, reportedByEditor: Boolean = false) { knownSelectionStart = start knownSelectionEnd = end + selectionReportedByEditor = reportedByEditor + } + + private fun typeAsKeyEvents(ic: InputConnection, text: String): Boolean { + if (text.length > RAW_KEY_TEXT_MAX) return false + val events = keyEventsFor(text) ?: return false + events.forEach { ic.sendKeyEvent(it) } + return true } fun deleteBackwards() { invalidateClearUndo() val ic = connection() ?: return + if (isRawKeys()) { + sendKeyDownUp(ic, KeyEvent.KEYCODE_DEL) + return + } if (deleteSelection()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -181,6 +232,10 @@ class InputConnectionGateway( } return false } + // Every call below is a synchronous Binder round trip into the app; a slow + // or stuck app makes each one wait, and Backspace used to make three. When + // the editor has told us the cursor is collapsed there is no selection to ask about. + if (selectionReportedByEditor && knownSelectionStart >= 0 && knownSelectionStart == knownSelectionEnd) return false val selected = runCatching { ic.getSelectedText(0)?.toString().orEmpty() }.getOrDefault("") if (selected.isNotEmpty()) { if (knownSelectionStart >= 0 && knownSelectionEnd >= 0) { @@ -224,6 +279,10 @@ class InputConnectionGateway( fun deleteForwards() { invalidateClearUndo() val ic = connection() ?: return + if (isRawKeys()) { + sendKeyDownUp(ic, KeyEvent.KEYCODE_FORWARD_DEL) + return + } if (deleteSelection()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val deleted = runCatching { ic.deleteSurroundingTextInCodePoints(0, 1) }.getOrDefault(false) @@ -302,14 +361,19 @@ class InputConnectionGateway( } } restoreSelectionAfterFailedClear(ic, originalSelection) - return false + // The editor answered select-all but exposed nothing usable. + return clearThroughSurroundingText(ic) } - val window = extractedWindow(ic) ?: return false - if (!window.isCompleteDocument) return false + // No select-all and no complete ExtractedText: custom, Compose and + // web editors typically expose only before/after-cursor text. + val window = extractedWindow(ic) + if (window == null || !window.isCompleteDocument) { + return clearThroughSurroundingText(ic) + } if (window.text.isEmpty()) return true if (!runCatching { ic.setSelection(0, window.text.length) }.getOrDefault(false)) { - return false + return clearThroughSurroundingText(ic) } val cleared = runCatching { ic.commitText("", 1) }.getOrDefault(false) if (!cleared) { @@ -324,6 +388,65 @@ class InputConnectionGateway( } } + /** + * Clear the document using nothing but before/after-cursor text. + * + * Editors that implement neither select-all nor a complete ExtractedText + * (custom canvas, Compose and web fields) still answer these two queries. + * Everything is captured first, so the clear can be undone, and the + * deletion repeats until the editor itself reports that nothing is left. + * That makes an editor which silently caps its answers safe: it is cleared + * in several rounds instead of being left half full. + * + * An answer as long as the request may be a window of a bigger document, + * so nothing is deleted then (callers get false), exactly as for a partial + * ExtractedText. + */ + private fun clearThroughSurroundingText(ic: InputConnection): Boolean { + val selected = runCatching { ic.getSelectedText(0)?.toString().orEmpty() }.getOrDefault("") + val firstBefore = runCatching { ic.getTextBeforeCursor(SURROUNDING_CHUNK, 0)?.toString() }.getOrNull() + val firstAfter = runCatching { ic.getTextAfterCursor(SURROUNDING_CHUNK, 0)?.toString() }.getOrNull() + if (firstBefore == null || firstAfter == null) return false + if (firstBefore.length >= SURROUNDING_CHUNK || firstAfter.length >= SURROUNDING_CHUNK) return false + if (selected.isEmpty() && firstBefore.isEmpty() && firstAfter.isEmpty()) return true + + if (selected.isNotEmpty() && !runCatching { ic.commitText("", 1) }.getOrDefault(false)) { + return false + } + val beforeParts = ArrayList() // nearest to the cursor first + val afterParts = ArrayList() + var before: String = firstBefore + var after: String = firstAfter + var rounds = 0 + while (before.isNotEmpty() || after.isNotEmpty()) { + val deleted = rounds < MAX_SURROUNDING_ROUNDS && + runCatching { ic.deleteSurroundingText(before.length, after.length) }.getOrDefault(false) + if (!deleted) { + // Put back what was already taken so a failed clear loses nothing. + val restored = beforeParts.asReversed().joinToString("") + selected + afterParts.joinToString("") + if (restored.isNotEmpty()) runCatching { ic.commitText(restored, 1) } + return false + } + rounds++ + beforeParts.add(before) + afterParts.add(after) + before = runCatching { ic.getTextBeforeCursor(SURROUNDING_CHUNK, 0)?.toString().orEmpty() } + .getOrDefault("") + after = runCatching { ic.getTextAfterCursor(SURROUNDING_CHUNK, 0)?.toString().orEmpty() } + .getOrDefault("") + } + ic.finishComposingText() + + val head = beforeParts.asReversed().joinToString("") + val full = head + selected + afterParts.joinToString("") + rememberClearUndo( + text = full, + selection = SelectionSnapshot.Absolute(head.length, head.length + selected.length), + ic = ic, + ) + return true + } + /** * Restore only the most recent successful full-document clear. Any normal * edit invalidates the snapshot, and restoration is rejected unless the @@ -728,5 +851,30 @@ class InputConnectionGateway( private companion object { const val FALLBACK_WINDOW_CHARS = 8_192 const val CLEAR_UNDO_TIMEOUT_MS = 5_000L + const val SURROUNDING_CHUNK = 100_000 + const val MAX_SURROUNDING_ROUNDS = 8 } } + +internal const val COMMIT_CHUNK_CHARS = 32_000 + +/** Pieces of at most [COMMIT_CHUNK_CHARS] UTF-16 units that never end between a surrogate pair. */ +internal fun chunksForCommit(text: String): List { + if (text.length <= COMMIT_CHUNK_CHARS) return listOf(text) + val chunks = ArrayList(text.length / COMMIT_CHUNK_CHARS + 1) + var start = 0 + while (start < text.length) { + var end = minOf(start + COMMIT_CHUNK_CHARS, text.length) + if (end < text.length && Character.isHighSurrogate(text[end - 1])) end-- + chunks += text.substring(start, end) + start = end + } + return chunks +} + +private const val RAW_KEY_TEXT_MAX = 64 + +/** Real key events for [text] when the virtual keyboard can type all of it (ASCII), else null. */ +internal fun keyEventsForText(text: String): Array? = + runCatching { android.view.KeyCharacterMap.load(android.view.KeyCharacterMap.VIRTUAL_KEYBOARD)?.getEvents(text.toCharArray()) } + .getOrNull() diff --git a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt b/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt index 91917932..52c9950e 100644 --- a/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt +++ b/app/src/main/java/llc/slacker/openime/InputMethodSubtypePolicy.kt @@ -23,11 +23,13 @@ internal object InputMethodSubtypePolicy { EditorInfoAdapter.EditorKind.NUMBER, EditorInfoAdapter.EditorKind.DECIMAL, EditorInfoAdapter.EditorKind.PHONE, + EditorInfoAdapter.EditorKind.DATETIME, -> KeyboardMode.DIGITS EditorInfoAdapter.EditorKind.EMAIL, EditorInfoAdapter.EditorKind.URL, EditorInfoAdapter.EditorKind.PASSWORD, + EditorInfoAdapter.EditorKind.RAW_KEYS, -> KeyboardMode.ENGLISH_26 else -> when (language(subtypeLocale)) { diff --git a/app/src/main/java/llc/slacker/openime/KeyPopupController.kt b/app/src/main/java/llc/slacker/openime/KeyPopupController.kt index f3e75237..b205e0d2 100644 --- a/app/src/main/java/llc/slacker/openime/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/KeyPopupController.kt @@ -39,6 +39,27 @@ internal class KeyPopupController( private var popupView: View? = null private var keepAfterKeyUp = false + /** True while a key preview or a long-press choice popup is on screen. */ + val isShowing: Boolean get() = popupView != null + + /** + * The one bubble for every delete-key gesture state (clear / restore, and + * both before and after they arm). Purely visual and never touchable. + */ + private val gestureHintView = TextView(host.context).apply { + visibility = View.GONE + includeFontPadding = false + maxLines = 1 + gravity = Gravity.CENTER + setPadding(dp(16), 0, dp(16), 0) + elevation = dp(3).toFloat() + textSize = ImeTypographyTokens.BODY_SP + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + init { host.addView( previewPopup, @@ -46,6 +67,93 @@ internal class KeyPopupController( gravity = Gravity.TOP or Gravity.START }, ) + host.addView( + gestureHintView, + FrameLayout.LayoutParams(1, 1).apply { + gravity = Gravity.TOP or Gravity.START + }, + ) + } + + /** + * Show (or restyle) the delete-key gesture bubble. It sits beside the key + * on the side with room, never above it: the thumb moves up over the key + * to clear and would hide a bubble placed there. Armed states change only + * the label and fill, so clear and restore read as the same control. + */ + fun showGestureHint(anchor: View, hint: GestureHint) { + if (hint == GestureHint.NONE) { + hideGestureHint() + return + } + val t = tokens() + val clear = hint == GestureHint.CLEAR_PREVIEW || hint == GestureHint.CLEAR_ARMED + val armed = hint == GestureHint.CLEAR_ARMED || hint == GestureHint.UNDO_ARMED + val label = when (hint) { + GestureHint.CLEAR_PREVIEW -> "上滑清空" + GestureHint.CLEAR_ARMED -> "松手清空" + GestureHint.UNDO_PREVIEW -> "下滑撤回" + else -> "松手撤回" + } + val fill = when { + !armed -> ImeDrawableFactory.withAlpha(t.keyText, 0xE0) + clear -> t.destructive + else -> t.primary + } + val textColor = if (armed) contrastText(fill) else t.keyBackground + + val height = dp(GESTURE_HINT_HEIGHT_DP) + gestureHintView.text = label + gestureHintView.setTextColor(textColor) + gestureHintView.background = rounded(fill, height / 2) + val width = (gestureHintView.paint.measureText(label) + dp(32)) + .toInt() + .coerceAtLeast(dp(GESTURE_HINT_MIN_WIDTH_DP)) + + val anchorLocation = IntArray(2) + val hostLocation = IntArray(2) + anchor.getLocationOnScreen(anchorLocation) + host.getLocationOnScreen(hostLocation) + val anchorLeft = anchorLocation[0] - hostLocation[0] + val anchorTop = anchorLocation[1] - hostLocation[1] + val margin = dp(8) + val gap = dp(8) + val inset = contentInsetPx() + val placeLeft = anchorLeft + anchor.width / 2 > host.width / 2 + val rawLeft = if (placeLeft) anchorLeft - gap - width else anchorLeft + anchor.width + gap + val left = rawLeft.coerceIn( + inset + margin, + (host.width - width - inset - margin).coerceAtLeast(inset + margin), + ) + val top = (anchorTop + (anchor.height - height) / 2).coerceAtLeast(dp(4)) + + gestureHintView.layoutParams = FrameLayout.LayoutParams(width, height).apply { + gravity = Gravity.TOP or Gravity.START + leftMargin = left + topMargin = top + } + if (gestureHintView.visibility != View.VISIBLE) { + gestureHintView.visibility = View.VISIBLE + gestureHintView.bringToFront() + gestureHintView.animate().cancel() + gestureHintView.pivotX = width / 2f + gestureHintView.pivotY = height / 2f + gestureHintView.scaleX = 0.9f + gestureHintView.scaleY = 0.9f + gestureHintView.alpha = 0f + gestureHintView.animate() + .scaleX(1f) + .scaleY(1f) + .alpha(1f) + .setDuration(ImeMotionTokens.POPUP_ENTER_MS) + .setInterpolator(DecelerateInterpolator(1.5f)) + .start() + } + } + + fun hideGestureHint() { + gestureHintView.animate().cancel() + gestureHintView.visibility = View.GONE } fun show(anchor: View, text: String) { @@ -60,10 +168,7 @@ internal class KeyPopupController( val availableWidth = (host.width - contentInsetPx() * 2) .coerceAtLeast(minimumWidth) val popupWidth = desiredWidth.coerceAtMost(availableWidth) - val popupHeight = dp( - if (text == "清空") 36 else ImeGeometryTokens.KEY_POPUP_HEIGHT_DP, - ) - val popupBackground = if (text == "清空") t.destructive else t.keyBackground + val popupHeight = dp(ImeGeometryTokens.KEY_POPUP_HEIGHT_DP) previewPopup.apply { this.text = text textSize = if (text.length > 1) { @@ -71,18 +176,13 @@ internal class KeyPopupController( } else { ImeTypographyTokens.DISPLAY_SP } - setTextColor(if (text == "清空") contrastText(popupBackground) else t.keyText) - background = - if (text == "清空") { - rounded(popupBackground, dp(ImeGeometryTokens.CONTROL_RADIUS_DP)) - } else { - ImeDrawableFactory.rounded( - popupBackground, - dp(ImeGeometryTokens.CONTROL_RADIUS_DP), - ImeSurfacePolicy.divider(t), - dp(1).coerceAtLeast(1), - ) - } + setTextColor(t.keyText) + background = ImeDrawableFactory.rounded( + t.keyBackground, + dp(ImeGeometryTokens.CONTROL_RADIUS_DP), + ImeSurfacePolicy.divider(t), + dp(1).coerceAtLeast(1), + ) } positionAttachedPopup(anchor, previewPopup, popupWidth, popupHeight) @@ -180,6 +280,11 @@ internal class KeyPopupController( keepAfterKeyUp = false } + private companion object { + const val GESTURE_HINT_HEIGHT_DP = 36 + const val GESTURE_HINT_MIN_WIDTH_DP = 88 + } + private fun positionAttachedPopup( anchor: View, popup: View, diff --git a/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt b/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt index 34ea84b0..5fbd4e9e 100644 --- a/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/KeyboardGeometry.kt @@ -54,25 +54,26 @@ internal data class KeyboardLayoutMetrics( val landscape: Boolean, val fontScale: Float, val heightPercent: Int = 100, + val availableWidthDp: Int = 390, ) { val keyRowHeightDp: Int = run { val base = if (landscape) { ImeGeometryTokens.LANDSCAPE_KEY_ROW_HEIGHT_DP } else { - ImeGeometryTokens.TOUCH_TARGET_DP + ImeGeometryTokens.KEY_ROW_HEIGHT_DP } val fontGrow = ((fontScale - 1f).coerceAtLeast(0f) * 12f) .toInt() .coerceAtMost(12) - val scaled = ((base + fontGrow) * heightPercent.coerceIn(92, 120) / 100f).toInt() + val scaled = ((base + fontGrow) * heightPercent.coerceIn(80, 120) / 100f).toInt() scaled.coerceAtLeast(if (landscape) 38 else 44) } val nineGridHeightDp: Int = - keyRowHeightDp * 3 + ImeGeometryTokens.KEY_ROW_GAP_DP * 2 + keyRowHeightDp * 3 val nineBodyHeightDp: Int = - nineGridHeightDp + ImeGeometryTokens.KEY_ROW_GAP_DP + keyRowHeightDp + nineGridHeightDp + keyRowHeightDp val doubleKeyHeightDp: Int = keyRowHeightDp * 2 + ImeGeometryTokens.KEY_ROW_GAP_DP @@ -81,12 +82,10 @@ internal data class KeyboardLayoutMetrics( val imeHeightDp: Int = run { val derived = topZoneHeightDp + - keyRowHeightDp * 4 + - ImeGeometryTokens.KEY_ROW_GAP_DP * 3 + - 22 - val baseMinimum = if (landscape) 264 else 302 + keyRowHeightDp * 4 + 16 + val baseMinimum = if (landscape) 256 else 0 val scaledMinimum = - (baseMinimum * heightPercent.coerceIn(92, 120) / 100f).toInt() + (baseMinimum * heightPercent.coerceIn(80, 120) / 100f).toInt() maxOf(scaledMinimum, derived) } @@ -96,3 +95,16 @@ internal data class KeyboardLayoutMetrics( (imeHeightDp - ImeGeometryTokens.TOUCH_TARGET_DP).coerceAtLeast(0) } + +/** All reference dimensions scale together from the PDF's 390-unit canvas. */ +internal object ImeReferenceSizing { + fun scale(context: android.content.Context, widthPx: Int = 0): Float { + val config = context.resources.configuration + val metrics = context.resources.displayMetrics + val widthDp = if (widthPx > 0) widthPx / metrics.density else config.screenWidthDp.toFloat() + val widthScale = widthDp.coerceAtMost(600f) / 390f + return if (config.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) { + minOf(widthScale, config.screenHeightDp * 0.55f / 256f) + } else widthScale + } +} diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index cf7b58fe..664cbbf5 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -38,7 +38,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can private var keyboardView: ImeKeyboardView? = null private lateinit var gateway: InputConnectionGateway - private lateinit var candidatePipeline: CandidatePipeline + /** + * Starts as an empty pipeline and is replaced by the real one once the lexicon + * and the nine-key decoder are built on a background thread (about 0.3 s on a + * fast host, several times that on a mid-range phone). Building them in + * onCreate froze the main thread at every cold start; until the swap, Rime + * alone supplies candidates. + */ + @Volatile + private var candidatePipeline: CandidatePipeline = CandidatePipeline(CandidateEngine(linkedMapOf())) private lateinit var candidateQueries: CandidateQueryCoordinator private lateinit var rime: RimeEngine private lateinit var voiceLifecycle: VoiceModelLifecycleManager @@ -81,18 +89,57 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 } + /** + * One bad key press must not take the keyboard down: the system would swap + * in another keyboard and the user loses their place. A failure in a typing + * handler is recorded (no typed text), the half-finished composition is + * dropped, and the keyboard carries on. + */ + @Volatile + private var injectedFailureForTest = false + + private inline fun guarded(name: String, block: () -> Unit) { + try { + if (injectedFailureForTest) { + injectedFailureForTest = false + throw IllegalStateException("injected failure for test") + } + block() + } catch (failure: Exception) { + recoverFromHandledFailure(name, failure) + } catch (failure: StackOverflowError) { + recoverFromHandledFailure(name, failure) + } + } + + private fun recoverFromHandledFailure(name: String, failure: Throwable) { + CrashGuard.log("handled failure in $name", failure) + CrashGuard.recordHandled(this, name, failure) + runCatching { + clearImeCompositionState(render = true) + gateway.cancelComposing() + } + } + override fun onCreate() { super.onCreate() + CrashGuard.install(this) + CrashGuard.ingestProcessExitReasons(this) + VoiceMediaMuteController.recoverAfterCrash(this) activeInstance = this UserPhraseRepository.configure(this) VoiceCorrectionRepository.configure(this) voiceLifecycle = VoiceModelLifecycleManager(this) - candidatePipeline = CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) + Thread({ + runCatching { CandidatePipeline(CandidateEngine(PinyinLexicon.load(this))) } + .onSuccess { candidatePipeline = it } + .onFailure { Log.e(TAG, "lexicon/decoder initialisation failed; running on Rime only", it) } + }, "openime-lexicon").apply { isDaemon = true; start() } rime = RimeEngine(this).also { it.start() } candidateQueries = CandidateQueryCoordinator( rime = rime, mainHandler = mainHandler, - fallbackCandidatesFor = candidatePipeline::nineKeyFallbackCandidatesFor, + fallbackCandidatesFor = { candidatePipeline.nineKeyFallbackCandidatesFor(it) }, maxInputLength = MAX_RIME_INPUT_LENGTH, maxNineKeyPaths = MAX_RIME_NINE_KEY_PATHS, maxCandidates = MAX_CANDIDATES, @@ -101,6 +148,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can context = this, connection = { currentInputConnection }, isPassword = { state.passwordField }, + isRawKeys = { EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS }, ) state = ImeState( theme = ImeSettingsRepository.loadTheme(this), @@ -127,13 +175,21 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can segmentPrefix: String, preferredSuffix: String?, fuzzy: Boolean, + lockPreferred: Boolean, ): CandidatePipeline.NineKeyResolution = candidatePipeline.resolveNineKey( digits = digits, segmentPrefix = segmentPrefix, preferredSuffix = preferredSuffix, fuzzy = fuzzy, + lockPreferred = lockPreferred, ) + override fun nineKeyReadingsFor(digits: String, preferred: String?): List = + candidatePipeline.nineKeyReadingsFor(digits, preferred) + + override fun nineKeyReadingFor(digits: String, candidate: String): List? = + candidatePipeline.nineKeyReadingFor(digits, candidate) + override fun nineKeyPathsFor(code: String?): List = candidatePipeline.nineKeyPathsFor(code) @@ -183,6 +239,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return true } + override fun onEvaluateFullscreenMode(): Boolean { + // The framework default turns the whole screen into the IME in landscape: + // the app's editor is hidden behind an unthemed copy of the field (the + // extract view) and the conversation, search results or form the user is + // typing into disappear. Every mainstream keyboard stays a bottom panel + // in every orientation and lets the app resize or pan, so do the same. + return false + } + private fun ensureInputViewAfterFinish() { if (keyboardView != null) return // InputMethodService keeps the old view instance after @@ -357,7 +422,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can floatingWindow.restore() } keyboardView?.refreshAuxiliaryContent() - voiceLifecycle.onStartInputView() + // Safe mode (repeated crashes or freezes): do not preload the voice model. + if (!CrashGuard.isSafeMode(this)) voiceLifecycle.onStartInputView() } /** Re-render panels whose data may have been edited in a full-screen Activity. */ @@ -398,8 +464,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can candidatesStart: Int, candidatesEnd: Int, ) { - gateway.updateSelection(newSelStart, newSelEnd) - refreshTextEditControls() + gateway.updateSelection(newSelStart, newSelEnd, reportedByEditor = true) + guarded("onUpdateSelection") { refreshTextEditControls() } super.onUpdateSelection( oldSelStart, oldSelEnd, @@ -549,6 +615,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) text.isNotBlank() && QuickPhraseRepository.load(this).any { it.text == text } }.getOrDefault(false) + command == "fail-next" -> { + injectedFailureForTest = true + true + } command == "bounds" -> { Log.i(TAG, "BOUNDS\n${keyboardView?.normalizedBoundsReport().orEmpty()}") true @@ -633,7 +703,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) } - override fun onCharacter(char: String) { + override fun onCharacter(char: String) = guarded("onCharacter") { prepareForManualInput() voiceCorrectionTracker.noteReplacementInput() commitPendingComposition() @@ -641,7 +711,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can gateway.commitText(char) } - override fun onBackspace() { + override fun onBackspace() = guarded("onBackspace") { prepareForManualInput() voiceCorrectionTracker.noteBackspace() if (keyboardView?.deleteInlineEditorChar() == true) return @@ -669,7 +739,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } } - override fun onClearAll() { + override fun onClearAll() = guarded("onClearAll") { prepareForManualInput() // Invalidate every pending candidate/Rime path before touching the // editor. Otherwise a late native result can restore the just-cleared @@ -713,6 +783,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return true } + override fun hasClearUndo(): Boolean = ::gateway.isInitialized && gateway.hasClearUndo() + override fun onUndoClearExpired() { gateway.discardClearUndo() keyboardView?.hideClearUndo() @@ -731,7 +803,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can if (floatingWindow.enabled) floatingWindow.reapply() } - override fun onSpace() { + override fun onSpace() = guarded("onSpace") { prepareForManualInput() if (keyboardView?.insertIntoInlineEditor(" ") == true) return if (state.passwordField) { @@ -882,10 +954,13 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ) } - override fun onEnter() { + override fun onEnter() = guarded("onEnter") { prepareForManualInput() if (lastComposition.isNotEmpty()) { - commitFirstCandidate() + // Space picks the first word; Enter ("确定") keeps what was typed, + // as Rime, fcitx, Sogou and Gboard Pinyin do. Collapsing both into + // "commit the first candidate" left no way to enter pinyin as text. + commitRawComposition() return } voiceCorrectionTracker.finalizeIfNeeded() @@ -897,7 +972,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } } - override fun onCompositionChanged(composition: String, candidates: List) { + override fun onCompositionChanged(composition: String, candidates: List) = guarded("onCompositionChanged") { prepareForManualInput() if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput() handleCompositionChanged( @@ -912,7 +987,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can digitBuffer: String, pinyinPaths: List, candidates: List, - ) { + ) = guarded("onNineKeyCompositionChanged") { prepareForManualInput() if (composition.isNotEmpty()) voiceCorrectionTracker.noteReplacementInput() if (state.keyboardMode != KeyboardMode.PINYIN_9 || digitBuffer.isEmpty()) { @@ -931,7 +1006,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can candidates: List, rimeInputs: List, ) { - if (state.passwordField) { + val directCommit = state.passwordField || ( + // Terminals and games need each letter as it is typed; composing English + // there shows nothing until the word ends. Pinyin still composes. + EditorInfoAdapter.kind(state.editorInfo) == EditorInfoAdapter.EditorKind.RAW_KEYS && + state.keyboardMode != KeyboardMode.PINYIN_26 && + state.keyboardMode != KeyboardMode.PINYIN_9 + ) + if (directCommit) { // Password fields never receive composing text, so the view's // buffer is the only holder of pending input and renderState() // empties it on every report. The buffer therefore contains @@ -972,7 +1054,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.renderState(state) } - override fun onCandidateSelected(candidate: String) { + override fun onCandidateSelected(candidate: String) = guarded("onCandidateSelected") { prepareForManualInput() if (state.passwordField) return selectCandidate(candidate) @@ -1065,7 +1147,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can * directly and chain to the next association set so a user can keep * tapping: 你好 -> 呀 -> ! */ - override fun onAssociationSelected(text: String) { + override fun onAssociationSelected(text: String) = guarded("onAssociationSelected") { prepareForManualInput() if (state.passwordField || text.isEmpty()) return commitPendingComposition() @@ -1131,14 +1213,14 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can state = state.copy(panel = if (open) Panel.CANDIDATE_EXPANDED else Panel.NONE) } - override fun onSymbolSelected(symbol: String) { + override fun onSymbolSelected(symbol: String) = guarded("onSymbolSelected") { prepareForManualInput() commitPendingComposition() keyboardView?.clearAssociationCandidates() gateway.commitText(symbol) } - override fun onEmojiSelected(emoji: String) { + override fun onEmojiSelected(emoji: String) = guarded("onEmojiSelected") { prepareForManualInput() commitPendingComposition() keyboardView?.clearAssociationCandidates() @@ -1146,7 +1228,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can gateway.commitText(emoji) } - override fun onTextEdit(action: String) { + override fun onTextEdit(action: String) = guarded("onTextEdit") { if (action in setOf("select-all", "cut", "paste", "left", "right")) prepareForManualInput() when (action) { "select-all" -> if (!gateway.selectAll()) { @@ -1202,9 +1284,79 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can if (keyCode == KeyEvent.KEYCODE_BACK && keyboardView?.closePanelToKeyboard() == true) { return true } + if (event != null && handleHardwareKey(event)) { + hardwareConsumed += keyCode + return true + } return super.onKeyDown(keyCode, event) } + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + // The matching up of a key we consumed must not reach the app on its own. + if (hardwareConsumed.remove(keyCode)) return true + return super.onKeyUp(keyCode, event) + } + + private val hardwareConsumed = HashSet() + + /** + * Pinyin typing from a physical keyboard (tablets, foldables with a keyboard + * cover, Chromebooks, desktop mode, emulators). Returns true when the key was + * ours; anything else, and every shortcut, goes on to the app unchanged. + */ + private fun handleHardwareKey(event: KeyEvent): Boolean { + val view = keyboardView ?: return false + if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false + val kind = EditorInfoAdapter.kind(state.editorInfo) + val pinyinMode = isInputViewShown && view.hardwareAccepts() && !state.passwordField && + EditorInfoAdapter.allowCandidates(kind) && kind != EditorInfoAdapter.EditorKind.RAW_KEYS + if (!pinyinMode) return false + val composing = view.hardwareIsComposing() + val unicode = event.unicodeChar + val needsCharBefore = !composing && unicode in HARDWARE_ASCII_AFTER_DIGIT + val action = HardwareKeyPolicy.decide( + HardwareKey( + keyCode = event.keyCode, + unicode = unicode, + shift = event.isShiftPressed, + ctrl = event.isCtrlPressed, + alt = event.isAltPressed, + meta = event.isMetaPressed, + capsLock = event.isCapsLockOn, + repeat = event.repeatCount > 0, + ), + HardwareContext( + pinyinMode = true, + composing = composing, + candidateCount = view.hardwareCandidateCount(), + charBeforeCursor = if (needsCharBefore) gateway.charBeforeCursor() else null, + ), + ) + var consumed = true + guarded("hardwareKey") { + when (action) { + HardwareKeyAction.PassThrough -> consumed = false + HardwareKeyAction.Consume -> Unit + HardwareKeyAction.FinishCompositionThenPassThrough -> { + commitPendingComposition() + consumed = false + } + is HardwareKeyAction.Letter -> view.hardwareLetter(action.char) + HardwareKeyAction.Backspace -> view.hardwareBackspace() + HardwareKeyAction.Space -> view.hardwareSpace() + HardwareKeyAction.Enter -> onEnter() + HardwareKeyAction.Cancel -> view.hardwareCancelComposition() + HardwareKeyAction.Apostrophe -> view.hardwareApostrophe() + is HardwareKeyAction.SelectCandidate -> consumed = view.hardwareSelectCandidate(action.index) + is HardwareKeyAction.Punctuation -> { + if (action.commitFirstCandidate) commitFirstCandidate() + onCharacter(action.text) + } + } + } + return consumed + } + private fun updateComposition(next: String, candidates: List) { lastComposition = next state = state.copy(composition = next, candidates = candidates) @@ -1306,7 +1458,13 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can emptyList() } val finalCandidates = if (native.isNotEmpty()) { - val nativeText = native.map { it.text } + val nativeText = native.map { it.text }.let { texts -> + if (mode == KeyboardMode.PINYIN_9) { + candidatePipeline.preferExactNineKeyMatches(queryInputs.firstOrNull(), texts) + } else { + texts + } + } val exactQuickPhrases = if (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.ENGLISH_26) { QuickPhraseRepository.candidatesForInputCode( @@ -1364,6 +1522,11 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can nativeReferences = nativeReferences, ) keyboardView?.renderState(state) + if (mode == KeyboardMode.PINYIN_9 && native.isNotEmpty()) { + finalCandidates.firstOrNull()?.let { top -> + keyboardView?.alignNineKeyPreview(composition, top) + } + } } applyWhenCandidateSurfaceIdle() }, @@ -1389,17 +1552,25 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can currentMode = mode, ) ?: return invalidateCandidateQueries() - val reference = entry.nativeReference - val nativeCommit = if ( - reference != null && - rime.isReady && - (mode == KeyboardMode.PINYIN_26 || mode == KeyboardMode.PINYIN_9) - ) { - rime.selectCandidate(reference.input, reference.nativeIndex, allowsPersonalizedLearning()) - } else { - "" - } - finishCandidateCommit(composition, nativeCommit.ifBlank { entry.text }) + commitSnapshotEntry(composition, mode, entry) + } + + /** Commit the pre-edit pinyin as typed (separators dropped); no word is chosen or learned. */ + private fun commitRawComposition() { + val composition = lastComposition + if (composition.isEmpty()) return + val raw = composition + .filterNot { it == ' ' || it == '\'' || it == '|' } + .ifEmpty { composition } + invalidateCandidateQueries() + gateway.commitText(raw) + gateway.finishComposing() + voiceCorrectionTracker.finalizeIfNeeded() + rime.clear() + lastComposition = "" + state = state.copy(composition = "", candidates = emptyList()) + keyboardView?.clearAssociationCandidates() + keyboardView?.renderState(state) } private fun selectCandidate(candidate: String) { @@ -1413,7 +1584,17 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can currentMode = mode, ) ?: return invalidateCandidateQueries() + commitSnapshotEntry(composition, mode, entry) + } + + /** Commit one rendered candidate; a partial match keeps the rest of the input composing. */ + private fun commitSnapshotEntry(composition: String, mode: KeyboardMode, entry: CandidateSnapshotEntry) { val reference = entry.nativeReference + val remaining = partialRemainder(reference, mode) + if (remaining != null) { + finishPartialCandidateCommit(entry.text, remaining) + return + } val nativeCommit = if ( reference != null && rime.isReady && @@ -1426,6 +1607,39 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can finishCandidateCommit(composition, nativeCommit.ifBlank { entry.text }) } + /** + * Input left over when [reference] spells only the start of its input, or + * null when it spells all of it (or the extent is unknown). Previously the + * leftover was discarded, and the native whole-composition commit also + * taught the user dictionary a phrase the user never chose. + */ + private fun partialRemainder(reference: NativeCandidateReference?, mode: KeyboardMode): String? { + val ref = reference ?: return null + if (ref.consumed <= 0 || ref.nativeIndex < 0) return null + if (mode != KeyboardMode.PINYIN_26 && mode != KeyboardMode.PINYIN_9) return null + val normalized = RimeInputNormalizer.normalize(ref.input) + if (ref.consumed >= normalized.length) return null + return normalized.substring(ref.consumed).trim('\'').ifEmpty { null } + } + + private fun finishPartialCandidateCommit(committed: String, remaining: String) { + if (committed.isEmpty()) return + // One atomic edit: commit the word and start the next composing span + // together, so the editor never reports a half-way selection state. + gateway.batchEdit { + gateway.commitText(committed) + gateway.finishComposing() + voiceCorrectionTracker.finalizeIfNeeded() + rime.clear() + lastComposition = "" + state = state.copy(composition = "", candidates = emptyList()) + keyboardView?.clearAssociationCandidates() + // The view republishes the leftover; this calls back into + // handleCompositionChanged, which starts a fresh composing span. + keyboardView?.continueCompositionAfterPartial(remaining) + } + } + private fun finishCandidateCommit(composition: String, committed: String) { if (committed.isEmpty()) return // librime owns normal learning through its userdb. Keep the old local @@ -1537,3 +1751,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can var activeInstance: LocalVoiceImeService? = null } } + +/** Punctuation that stays ASCII right after a digit (3.14, 12:30, 1,000). */ +private val HARDWARE_ASCII_AFTER_DIGIT = setOf(','.code, '.'.code, ':'.code) diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt index 2ecc0b66..0509ffe1 100644 --- a/app/src/main/java/llc/slacker/openime/MainActivity.kt +++ b/app/src/main/java/llc/slacker/openime/MainActivity.kt @@ -11,6 +11,7 @@ import android.text.Editable import android.text.TextWatcher import android.view.HapticFeedbackConstants import android.view.View +import android.view.ViewGroup import android.view.WindowInsets import android.provider.Settings import android.net.Uri @@ -28,8 +29,16 @@ internal fun matchesSelectedInputMethod(defaultInputMethodId: String, packageNam } class MainActivity : Activity() { + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(SetupUi.appearanceContext(newBase)) + } + + private var appliedAppearance = ImeAppearance.SYSTEM + override fun onCreate(savedInstanceState: Bundle?) { + appliedAppearance = ImeSettingsRepository.loadAppearance(this) super.onCreate(savedInstanceState) + CrashGuard.install(this) setContentView(R.layout.activity_main) findViewById(R.id.main_scroll).setOnApplyWindowInsetsListener { view, insets -> if (Build.VERSION.SDK_INT >= 30) { @@ -78,6 +87,7 @@ class MainActivity : Activity() { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } } + setupClick(findViewById(R.id.voice_permission_authorize)) { findViewById(R.id.voice_permission).performClick() } setupClick(findViewById(R.id.voice_permission_skip)) { getPreferences(MODE_PRIVATE) .edit() @@ -118,6 +128,10 @@ class MainActivity : Activity() { override fun onResume() { super.onResume() + if (appliedAppearance != ImeSettingsRepository.loadAppearance(this)) { + recreate() + return + } refreshSetupState() } @@ -130,7 +144,7 @@ class MainActivity : Activity() { val status = imeStatus() val enabled = status.enabled val selected = status.selected - val accent = AccentPalette.parse(ImeSettingsRepository.loadSkinColor(this)) + val accent = SetupUi.accent(this) val prefs = getPreferences(MODE_PRIVATE) val microphoneGranted = checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED @@ -143,7 +157,8 @@ class MainActivity : Activity() { val testInput = findViewById(R.id.test_input) val testDone = testInput.text?.isNotBlank() == true - testInput.background = SetupUi.focusRingBackground(this) + testInput.background = SetupUi.inputBackground(this) + testInput.setPadding(SetupUi.dp(this, 16), 0, SetupUi.dp(this, 16), 0) SetupUi.styleCursor(this, testInput) findViewById(R.id.status).setText( @@ -175,14 +190,14 @@ class MainActivity : Activity() { chevron = findViewById(R.id.choose_ime_chevron), done = selected, active = enabled && !selected, - doneText = getString(R.string.choose_ime_done), + doneText = "已切换到 openIME", activeText = getString(R.string.choose_ime), markText = "2", accent = accent, ) findViewById(R.id.choose_ime).apply { isEnabled = enabled - alpha = if (enabled) 1f else ImeSurfacePolicy.DISABLED_ALPHA + alpha = 1f } val voiceDone = microphoneGranted || microphoneSkipped @@ -208,12 +223,12 @@ class MainActivity : Activity() { active = voiceActive, doneText = voiceDoneText, activeText = voiceActiveText, - markText = "3", + markText = "", accent = accent, ) findViewById(R.id.voice_permission).apply { isEnabled = selected && !microphoneGranted - alpha = if (selected) 1f else ImeSurfacePolicy.DISABLED_ALPHA + alpha = 1f } findViewById(R.id.voice_permission_description).setText( when { @@ -222,6 +237,8 @@ class MainActivity : Activity() { else -> R.string.voice_permission_description }, ) + findViewById(R.id.voice_permission_actions).visibility = if (microphoneGranted || microphoneSkipped) View.GONE else View.VISIBLE + findViewById(R.id.voice_permission_authorize).background = SetupUi.secondaryBackground(this) findViewById(R.id.voice_permission_skip).apply { visibility = if (selected && !microphoneGranted && !microphoneSkipped) { @@ -230,7 +247,7 @@ class MainActivity : Activity() { View.GONE } isEnabled = visibility == View.VISIBLE - background = SetupUi.secondaryBackground(this@MainActivity) + background = null } styleStep( @@ -242,15 +259,15 @@ class MainActivity : Activity() { active = selected && !testDone, doneText = getString(R.string.test_step_done), activeText = getString(R.string.test_step), - markText = "4", + markText = "3", accent = accent, ) findViewById(R.id.test_step).apply { isEnabled = selected - alpha = if (selected) 1f else ImeSurfacePolicy.DISABLED_ALPHA + alpha = 1f } testInput.isEnabled = selected - testInput.alpha = if (selected) 1f else ImeSurfacePolicy.DISABLED_ALPHA + testInput.alpha = 1f val ready = enabled && selected findViewById(R.id.open_app_settings).apply { @@ -263,6 +280,24 @@ class MainActivity : Activity() { if (Build.VERSION.SDK_INT >= 30) { stateDescription = if (ready) "可用" else "需先完成输入法设置" } + hideDecorationFromAccessibility(this) + } + } + + /** + * A setup card is one focusable, described node. Its number, label, + * hint, status pill and chevron are decoration for the same action, so a + * screen reader must not also land on each of them (and, being inside the + * card, they cannot be separate actions anyway). Applied in code because + * the status pill is added at runtime and a layout edit must not be able + * to bring the duplicates back. + */ + private fun hideDecorationFromAccessibility(card: View) { + val group = card as? ViewGroup ?: return + for (index in 0 until group.childCount) { + val child = group.getChildAt(index) + child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + hideDecorationFromAccessibility(child) } } @@ -304,48 +339,61 @@ class MainActivity : Activity() { // A completed setup step remains an action: users may need to revisit // the system picker or input-method settings after initial setup. row.isEnabled = true - row.background = when { - active -> SetupUi.buttonBackground(this, accent) - done -> completedBackground(accent) - else -> SetupUi.secondaryBackground(this) - } + row.background = null label.text = if (done) doneText else activeText - label.setTextColor( - if (active) contrastText(accent) else getColor( - if (done) R.color.setup_body else R.color.setup_title, - ), - ) + label.setTextColor(getColor(if (done || active) R.color.setup_title else R.color.setup_body)) // A completed step is easier to scan as a result than as an old // step number. Keep the number for the current step so the flow still // reads as 1 -> 2 while the completed state reads as a check. mark.text = if (done) "" else markText - mark.setCompoundDrawablesRelativeWithIntrinsicBounds( - if (done) R.drawable.ic_check else 0, - 0, - 0, - 0, - ) + val markIcon = if (row.id == R.id.voice_permission) R.drawable.ic_mic else if (done) R.drawable.ic_check else 0 + val drawable = if (markIcon != 0) getDrawable(markIcon)?.apply { + setBounds(0, 0, SetupUi.dp(this@MainActivity, 16), SetupUi.dp(this@MainActivity, 16)) + } else null + mark.setCompoundDrawablesRelative(drawable, null, null, null) mark.compoundDrawableTintList = ColorStateList.valueOf( - if (done) contrastText(getColor(R.color.setup_ready)) else accent, - ) - mark.setBackgroundResource( - when { - active -> R.drawable.bg_setup_mark_active - done -> R.drawable.bg_setup_mark_done - else -> R.drawable.bg_setup_mark - }, + if (done) contrastText(getColor(R.color.setup_ready)) else getColor(R.color.setup_body), ) + mark.background = android.graphics.drawable.GradientDrawable().apply { + shape = android.graphics.drawable.GradientDrawable.OVAL + setColor(if (done) getColor(R.color.setup_ready) else if (active) accent else getColor(R.color.setup_surface)) + if (!done && !active) setStroke(SetupUi.dp(this@MainActivity, 1), getColor(R.color.setup_input_line)) + } mark.setTextColor( when { - active -> accent + active -> contrastText(accent) done -> contrastText(getColor(R.color.setup_ready)) - else -> accent + else -> getColor(R.color.setup_body) }, ) chevron?.imageTintList = ColorStateList.valueOf( - if (active) contrastText(accent) else getColor(R.color.setup_body), + getColor(R.color.setup_body), ) - chevron?.visibility = if (done) View.GONE else View.VISIBLE + chevron?.visibility = View.GONE + if (row.id == R.id.open_ime_settings || row.id == R.id.choose_ime) { + val layout = row as android.widget.LinearLayout + (layout.getChildAt(1) as? android.widget.LinearLayout)?.getChildAt(1)?.visibility = if (done) View.GONE else View.VISIBLE + val status = (layout.findViewWithTag("setup-result") as? TextView) ?: TextView(this).apply { + tag = "setup-result"; textSize = ImeTypographyTokens.SMALL_SP; setTextColor(getColor(R.color.setup_body)) + layout.addView(this, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)) + } + val enableStep = row.id == R.id.open_ime_settings + status.text = if (done) { if (enableStep) "已启用" else "已切换" } else { if (enableStep) "启用" else "切换" } + status.visibility = View.VISIBLE + status.gravity = android.view.Gravity.CENTER + status.typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + status.layoutParams = android.widget.LinearLayout.LayoutParams( + if (done) android.widget.LinearLayout.LayoutParams.WRAP_CONTENT else SetupUi.dp(this, 66), + if (done) android.widget.LinearLayout.LayoutParams.WRAP_CONTENT else SetupUi.dp(this, 40), + ) + status.background = if (done) null else SetupUi.rounded( + if (active) accent else getColor(R.color.setup_disabled), SetupUi.dp(this, 12).toFloat(), + ) + status.setTextColor(if (done) getColor(R.color.setup_body) else if (active) contrastText(accent) else getColor(R.color.setup_disabled_text)) + + } + if (row.id == R.id.test_step) (row as android.widget.LinearLayout).getChildAt(2)?.visibility = View.GONE + hideDecorationFromAccessibility(row) row.alpha = 1f row.contentDescription = when { done -> doneText diff --git a/app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt index a85e8be9..daa2c26d 100644 --- a/app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/NativeCandidatePipeline.kt @@ -3,6 +3,8 @@ package llc.slacker.openime internal data class NativeCandidateReference( val input: String, val nativeIndex: Int, + /** Normalized-input characters the candidate spells; -1 when unknown. */ + val consumed: Int = -1, ) { companion object { private const val DEFERRED_PREFIX = "\u0000openime-deferred\u0000" @@ -69,7 +71,7 @@ internal class NineKeyFallbackRegistry { } private fun isNineKeyCode(value: String): Boolean = - value.isNotEmpty() && value.all { it in '2'..'9' || it == '\'' } + value.isNotEmpty() && value.all { it in '2'..'9' || it in 'a'..'z' || it == '\'' } } /** Pure merge policy for one or more native Rime query batches. */ @@ -89,7 +91,7 @@ internal object NativeCandidatePipeline { if (!seen.add(entry.text)) continue result += NativeCandidateChoice( text = entry.text, - reference = NativeCandidateReference(input, entry.nativeIndex), + reference = NativeCandidateReference(input, entry.nativeIndex, entry.consumed), ) if (result.size >= limit) return result } diff --git a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt index 97d3b74d..a224ade3 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt @@ -40,13 +40,49 @@ internal class NineKeyLocalDecoder( var bestDescendant: Entry? = null } + /** + * One way to read the start of the open digits as pinyin. [coversAll] tells + * whether the syllable spells every open digit or only the first of them. + */ + data class Reading( + val syllables: List, + val coversAll: Boolean, + /** False for a bare initial (w, x, y, z): a letter to show, not a syllable to fix. */ + val complete: Boolean = true, + ) { + val display: String get() = syllables.joinToString("'") + } + + private data class SyllablePath(val syllables: List, val score: Int) + private val root = TrieNode() + + /** Concatenated pinyin of known words -> how good they are (rewards readings that form words). */ + private val phraseScores = HashMap() + + /** Every digit prefix of every syllable: "an unfinished last syllable" test. */ + private val syllablePrefixes = HashSet() + private val completeSyllables = HashSet() + + /** Han character -> syllables that can read it, most likely first. */ + private val readingsByChar = HashMap>() private var previousDigits = "" private var previousPreview = "" init { - buildEntries().forEach(::insert) + val entries = buildEntries() + entries.forEach(::insert) sortTrie(root) + buildReadings(entries) + entries.forEach { entry -> + if (entry.phrase && entry.pinyin.all { it in 'a'..'z' }) { + phraseScores.merge(entry.pinyin, entryScore(entry), ::maxOf) + } + if (isSyllableEntry(entry)) { + completeSyllables += entry.pinyin + for (length in 1..entry.digits.length) syllablePrefixes += entry.digits.substring(0, length) + } + } } @Synchronized @@ -169,6 +205,229 @@ internal class NineKeyLocalDecoder( ) } + private fun buildReadings(entries: List) { + val byChar = HashMap>() + entries.forEach { entry -> + if (entry.phrase || entry.pinyin.length > MAX_SYLLABLE_LENGTH || + !entry.pinyin.all { it in 'a'..'z' } + ) { + return@forEach + } + entry.candidates.forEach { value -> + if (value.codePointCount(0, value.length) == 1) { + byChar.getOrPut(value) { ArrayList(2) } += entry + } + } + } + byChar.forEach { (char, list) -> + readingsByChar[char] = list + .sortedWith(compareByDescending(::entryScore).thenBy { it.pinyin }) + .map { it.pinyin } + .distinct() + } + } + + /** + * Pinyin of [text] when it exactly spells the whole digit string, one + * syllable per character, or null. Used to make the visible pinyin agree + * with the word Rime ranks first. + */ + @Synchronized + fun readingFor(digits: String, text: String): List? { + val bounded = digits.filter { it in '2'..'9' } + if (bounded.isEmpty() || text.isEmpty()) return null + val chars = ArrayList() + var index = 0 + while (index < text.length) { + val cp = text.codePointAt(index) + chars += String(Character.toChars(cp)) + index += Character.charCount(cp) + } + if (chars.size > bounded.length) return null + + val picked = arrayOfNulls(chars.size) + fun walk(charIndex: Int, digitIndex: Int): Boolean { + if (charIndex == chars.size) return digitIndex == bounded.length + for (reading in readingsByChar[chars[charIndex]].orEmpty()) { + val code = digitsForPinyin(reading) ?: continue + if (!bounded.startsWith(code, digitIndex)) continue + picked[charIndex] = reading + if (walk(charIndex + 1, digitIndex + code.length)) return true + } + return false + } + return if (walk(0, 0)) picked.filterNotNull() else null + } + + /** + * 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 + * to the following character, the way Baidu and rime-t9-shiyin do it, so the + * user chooses the pinyin of one character at a time and never has to pick + * a whole phrase's spelling. A tap fixes exactly what the item shows, and + * choices that would leave digits no syllable can read are never offered. + */ + @Synchronized + fun readingOptions(digits: String, preferred: String?, limit: Int = MAX_SYLLABLE_OPTIONS): List { + val bounded = digits.filter { it in '2'..'9' }.take(MAX_DIGITS) + if (bounded.isEmpty()) return emptyList() + val lead = preferred?.lowercase()?.filter { it in 'a'..'z' }.orEmpty() + + // A lone a / o / e between syllables is almost always an artefact of the + // digit grid (ni'ha'o), not what anyone typed; keep such readings only + // when nothing else exists. + val all = syllablePaths(bounded, READING_BEAM) + val paths = all.filter { path -> path.syllables.none { it.length == 1 } }.ifEmpty { all } + + // First syllables, each only if the rest can still be read. + val firsts = LinkedHashMap() + paths.forEach { path -> firsts.putIfAbsent(path.syllables.first(), path.score) } + syllableOptions(bounded, preferred, limit * 2).forEach { syllable -> + val code = digitsForPinyin(syllable) ?: return@forEach + if (code.length == bounded.length || canRead(bounded.substring(code.length))) { + firsts.putIfAbsent(syllable, Int.MIN_VALUE) + } + } + val ordered = firsts.entries + .sortedWith( + compareByDescending> { lead.startsWith(it.key) && it.key == leadFirst(lead, firsts.keys) } + .thenByDescending { it.value }, + ) + .map { it.key } + return ordered.take(limit).map { syllable -> + Reading( + listOf(syllable), + coversAll = digitsForPinyin(syllable)?.length == bounded.length, + complete = syllable in completeSyllables, + ) + } + } + + /** The longest listed syllable that the preview starts with. */ + private fun leadFirst(lead: String, candidates: Set): String? = + candidates.filter { lead.startsWith(it) }.maxByOrNull { it.length } + + /** Whether [digits] can be read as real syllables, the last one possibly unfinished. */ + private fun canRead(digits: String): Boolean { + if (digits.isEmpty()) return true + val reachable = BooleanArray(digits.length + 1).also { it[0] = true } + for (start in digits.indices) { + if (!reachable[start]) continue + if (digits.substring(start) in syllablePrefixes) return true + var node = root + for (end in start until minOf(digits.length, start + MAX_SYLLABLE_LENGTH)) { + node = node.children[digits[end]] ?: break + if (node.exact.any(::isSyllableEntry)) reachable[end + 1] = true + } + } + return reachable[digits.length] + } + + /** A real syllable: letters only, short, and with a vowel (no `ng`, `m`, `hm` interjections). */ + private fun isSyllableEntry(entry: Entry): Boolean = + !entry.phrase && + entry.pinyin.length <= MAX_SYLLABLE_LENGTH && + entry.pinyin.all { it in 'a'..'z' } && + entry.pinyin.any { it in "aeiouv" } + + /** + * Every way to read [digits] as whole syllables, best first. A beam keeps + * long input cheap; a reading that forms a known word (`nihao`) outranks + * unrelated syllables, so `ni'hao` leads `mi'hao` and `ni'gao`. + */ + private fun syllablePaths(digits: String, beam: Int): List { + val n = digits.length + val states = Array(n + 1) { ArrayList() } + states[0] += SyllablePath(emptyList(), 0) + for (start in 0 until n) { + val from = states[start] + if (from.isEmpty()) continue + var node = root + for (end in start until minOf(n, start + MAX_SYLLABLE_LENGTH)) { + node = node.children[digits[end]] ?: break + val syllables = node.exact.filter(::isSyllableEntry) + if (syllables.isEmpty()) continue + val target = states[end + 1] + for (entry in syllables) { + val own = entryScore(entry) - PART_PENALTY + for (before in from) { + val next = before.syllables + entry.pinyin + target += SyllablePath(next, before.score + own + phraseBonus(next)) + } + } + if (target.size > beam * 2) { + val kept = target + .sortedByDescending { it.score } + .distinctBy { it.syllables } + .take(beam) + target.clear() + target.addAll(kept) + } + } + } + return states[n] + .sortedByDescending { it.score } + .distinctBy { it.syllables } + .take(beam) + } + + /** Bonus for the words the newest syllable completes (longest suffixes of the path). */ + private fun phraseBonus(path: List): Int { + var bonus = 0 + for (length in 2..minOf(MAX_PHRASE_SYLLABLES, path.size)) { + val key = path.takeLast(length).joinToString("") + bonus += phraseScores[key] ?: continue + } + return bonus + } + + /** + * Real Pinyin syllables that can start the open digit tail, for the left + * selection rail. Longer spellings come first (they consume more of the + * typed digits), the syllable the preview currently starts with leads, and + * ties follow corpus frequency. A lone digit also offers its key letters so + * a key such as 9 is never a dead end. + */ + @Synchronized + fun syllableOptions(digits: String, preferred: String?, limit: Int = MAX_SYLLABLE_OPTIONS): List { + val bounded = digits.filter { it in '2'..'9' }.take(MAX_DIGITS) + if (bounded.isEmpty()) return emptyList() + val lead = preferred?.lowercase()?.trim()?.takeIf { it.isNotEmpty() } + data class Option(val pinyin: String, val depth: Int, val score: Int) + val found = ArrayList