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..e7712ab3 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: @@ -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..d2cc5d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,45 @@ # 更新记录 -## 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'hao`),长输入列首音节;点选即锁定,锁定的音节在继续输入时保持不变,退格先解锁。预编辑跟随首选词的读法(`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 触控目标;安装引导页的卡片对读屏软件是一个整体,不再逐个朗读装饰元素。 +- 手写目前只保留笔迹采集界面,识别引擎尚未接入,入口默认隐藏。 + +### 隐私与安全 +- 不声明 `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..9cdffe0d 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 29 / 31 兼容测试同样会在 PR 上运行,红了请先修再合并。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..53d1f3d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/README.md b/README.md index 35d8ee71..28b1f958 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) ## 隐私与安全 @@ -182,5 +192,5 @@ bash scripts/verify_linux.sh emulator-5554 ## 许可证 -主项目许可证尚未单独声明;公开仓库不等同于授予再分发或商业使用许可。第三方 -组件的许可证保留在各自目录中,详见 [docs/LICENSING.md](docs/LICENSING.md)。 +openIME 以 [GPL-3.0-only](LICENSE) 发布。第三方组件的许可证保留在各自目录中, +详见 [docs/LICENSING.md](docs/LICENSING.md) 和 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 09dba908..7311c725 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # 第三方资源与分发核对 -本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。主项目许可证仍由项目所有者决定,本文件不为 openIME 本身授予许可证。 +本文件记录 openIME 实际随源码或 APK 分发的主要第三方代码、数据和模型。openIME 本身的许可证见仓库根目录 `LICENSE`(GPL-3.0-only),本文件只记录第三方组件。 | 组件 | 仓库内位置 | 上游/来源 | 许可证 | 分发核对 | |---|---|---|---|---| @@ -19,7 +19,7 @@ - `app/src/main/assets/licenses/` 下的第三方许可文本必须继续随 APK 打包,包括 Rime Ice、librime、OpenCC、Snappy、sherpa-onnx、Paraformer 模型和 Fluent Emoji。 - 语音 runtime 与模型升级时,重新核对**具体版本/模型**的许可证,不要只沿用本文件旧结论。 - 内置词库来源或固定提交变化时,同步更新本文件、`docs/LICENSING.md` 和 APK 内许可证文件。 -- 主项目 `LICENSE` 在项目所有者决定前保持缺失;README 对主项目许可状态的现有表述保持不变。 +- 主项目 `LICENSE`(GPL-3.0-only)与 README、`docs/LICENSING.md` 保持一致;更换主项目许可证前先核对 Rime Ice 词典的 GPL-3.0-only 义务。 ## 备注 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/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/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..d0719b57 100644 --- a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt +++ b/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt @@ -13,6 +13,10 @@ 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) @@ -20,9 +24,9 @@ class AboutDataActivity : Activity() { 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 +38,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 +60,7 @@ class AboutDataActivity : Activity() { }, LinearLayout.LayoutParams( 0, - dp(ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP), + dp(44), 1f, ).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, ) @@ -69,17 +70,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 +89,10 @@ class AboutDataActivity : Activity() { ), wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, ) + 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 +334,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/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/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..1d01df88 100644 --- a/app/src/main/java/llc/slacker/openime/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/ImeKeyView.kt @@ -29,6 +29,7 @@ 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 @@ -89,6 +90,7 @@ class ImeKeyView( private val mainTextView: TextView? private val secondaryTextView: TextView? private val iconView: ImageView? + private var returnIcon: ImageView? = null init { isClickable = true @@ -166,17 +168,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 +209,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 +224,26 @@ 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) + mainTextView?.apply { + setAutoSizeTextTypeWithDefaults(TextView.AUTO_SIZE_TEXT_TYPE_NONE) + textSize = baseMainTextSize * scale.coerceAtLeast(0.4f) + } + secondaryTextView?.textSize = ImeTypographyTokens.CAPTION_SP * (toPx(100) / (100f * density)) } /** @@ -254,5 +272,5 @@ class ImeKeyView( iconView.setImageResource(value) } - private fun dp(value: Int): Int = (value * density).toInt() + private fun dp(value: Int): Int = toPx(value) } diff --git a/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/ImeKeyboardView.kt index 9f4bf482..cbed6a9b 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. @@ -918,8 +931,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 +957,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 +974,32 @@ 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() + // This runs from onSizeChanged, i.e. in the middle of a layout pass. + // Rows rebuilt there are added after their parent was measured and + // would stay at 0x0 (a blank keyboard on first show) until something + // else happened to request a layout. Ask for a fresh pass. + post { + requestLayout() + invalidate() + } + } + (mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params -> if (dockWidthPx == measuredWidthPx) { params.width = FrameLayout.LayoutParams.MATCH_PARENT @@ -980,16 +1019,16 @@ 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 } } @@ -999,14 +1038,14 @@ open class ImeKeyboardView( requestLayout() 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() @@ -1023,6 +1062,21 @@ open class ImeKeyboardView( requestLayout() } + 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. */ private fun buildTopZone() { topZone = ImeTopZone( @@ -1035,6 +1089,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 +1102,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 +1117,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 +1147,7 @@ open class ImeKeyboardView( topZone, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(64), + dp(topZoneHeightDp()), ), ) candidateBarController.syncExpandControl( @@ -1216,10 +1272,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 +1392,7 @@ open class ImeKeyboardView( if (state.composition.isEmpty()) { pinyinBuffer.clear() lastNineDigits = "" + lockedNineTail = null lastNineCandidates = emptyList() lastNineSegmentPrefix = "" lastNinePinyinPaths = emptyList() @@ -1370,8 +1430,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 +1483,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 +1538,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 +1548,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 +1605,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 +1712,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 +1750,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 +1776,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 +1818,7 @@ open class ImeKeyboardView( contentInsetPx, dp(6), contentInsetPx, - dp(16), + dp(10), ) val state = when { inlineVoicePresenter.active -> ImeTopZoneState.VOICE_INLINE @@ -1755,6 +1826,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 +1929,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 +1938,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 +1951,7 @@ open class ImeKeyboardView( ?.currentInputEditorInfo numericKeyboardRenderer.render( editorKind = EditorInfoAdapter.kind(info), - enterLabel = enterKeyLabel(false, "换行"), + enterLabel = "↵", ) } @@ -1904,10 +1985,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 +2111,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 +2174,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 +2283,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 +2347,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 +2388,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 +2674,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 +2846,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 +2868,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 +2945,18 @@ open class ImeKeyboardView( keyPopupController.hide() } + /** + * 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 +2970,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 +3010,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..a7aec094 100644 --- a/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/InputConnectionGateway.kt @@ -99,6 +99,22 @@ class InputConnectionGateway( connection()?.commitText(text, 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) { if (isPassword()) return if (text.isNotEmpty()) invalidateClearUndo() @@ -302,14 +318,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 +345,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 +808,7 @@ 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 } } 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..b984ad29 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -127,13 +127,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) @@ -713,6 +721,8 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return true } + override fun hasClearUndo(): Boolean = ::gateway.isInitialized && gateway.hasClearUndo() + override fun onUndoClearExpired() { gateway.discardClearUndo() keyboardView?.hideClearUndo() @@ -885,7 +895,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun 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() @@ -1306,7 +1319,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 +1383,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 +1413,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 +1445,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 +1468,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 diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt index 2ecc0b66..bef88017 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,7 +29,14 @@ 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) setContentView(R.layout.activity_main) findViewById(R.id.main_scroll).setOnApplyWindowInsetsListener { view, insets -> @@ -78,6 +86,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 +127,10 @@ class MainActivity : Activity() { override fun onResume() { super.onResume() + if (appliedAppearance != ImeSettingsRepository.loadAppearance(this)) { + recreate() + return + } refreshSetupState() } @@ -130,7 +143,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 +156,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 +189,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 +222,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 +236,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 +246,7 @@ class MainActivity : Activity() { View.GONE } isEnabled = visibility == View.VISIBLE - background = SetupUi.secondaryBackground(this@MainActivity) + background = null } styleStep( @@ -242,15 +258,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 +279,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 +338,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..603880ff 100644 --- a/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/NineKeyLocalDecoder.kt @@ -40,13 +40,50 @@ internal class NineKeyLocalDecoder( var bestDescendant: Entry? = null } + /** + * One way to read the open digits as pinyin. [syllables] are in order; + * [coversAll] tells whether they spell every digit (a whole reading) or only + * the start of them (a first-syllable choice). + */ + 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 +206,242 @@ 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. + * + * Short input lists whole readings (`ni'hao`, `mi'hao`, `ni'gao`), the way + * the design shows them. Once a whole reading no longer fits the rail it + * lists first syllables (`zhong`, `xiong`), and fixing one moves the list on + * to the next position (the Baidu / rime-t9-shiyin behaviour). Either way a + * tap fixes exactly what the item shows. 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 } + val best = paths.firstOrNull() + if (best != null && best.syllables.joinToString("'").length <= WHOLE_READING_MAX_CHARS) { + val ranked = paths.sortedWith( + compareByDescending { it.syllables.joinToString("") == lead } + .thenByDescending { it.score }, + ) + return ranked + .map { Reading(it.syllables, coversAll = true) } + .distinctBy { it.display } + .take(limit) + } + + // Long input: 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