diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index 2121e9d4d..000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: 问题报告 / Bug Report -about: 创建问题报告以帮助我们改进 / Create a bug report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -#### 摘要 / Summary - - -#### 重现步骤 / Steps to Reproduce - - -#### 预期行为 / Expected Behavior - - -#### 日志 / Log - - -#### 截图 / Screenshots - - -#### 设备信息 / Device Infomation - -- 系统版本 / OS Version: -- 应用版本 / App Version: -- 插件版本 / Plugins Version: - -#### 附加信息 / Additional Context - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 000000000..9a79cd21b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,83 @@ +name: 问题报告 / Bug Report +description: 创建问题报告以帮助我们改进 / Create a bug report to help us improve +labels: + - bug + +body: + - type: textarea + id: summary + attributes: + label: 摘要 / Summary + description: 简要描述遇到的问题。 / Briefly describe the bug. + validations: + required: true + + - type: textarea + id: step_to_reproduce + attributes: + label: 重现步骤 / Steps to Reproduce + description: 如何重现该问题。 / How to reproduce the bug. + placeholder: | + 1. 打开某界面 / Open page ... + 2. 点击某菜单 / Click menu ... + 3. 某处出问题 / Something went wrong ... + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: 预期行为 / Expected Behavior + description: 完成上述步骤后应该发生什么。 / What is expected to happen after the steps above. + validations: + required: true + + - type: textarea + id: log + attributes: + label: 日志 / Log + description: 附上日志以帮助定位问题。 / Attach log to help locate the bug. + validations: + required: false + + - type: textarea + id: screenshot + attributes: + label: 截图 / Screenshot + description: 附上截图以帮助解释问题。 / Attach screenshots to help explain the bug. + validations: + required: false + + - type: textarea + id: additional_context + attributes: + label: 附加信息 / Additional Context + description: 与此问题相关的上下文信息,比如在问题出现前做了什么。 / Additional context about the bug, eg. what did you do before the bug appears. + validations: + required: false + + - type: markdown + attributes: + value: | + ### 设备信息 / Device Infomation + + - type: input + id: os_version + attributes: + label: 系统版本 / OS Version + validations: + required: true + + - type: input + id: app_version + attributes: + label: 应用版本 / App Version + validations: + required: true + + - type: input + id: plugins_version + attributes: + label: 插件版本 / Plugins Version + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 99d680b0a..35299216a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,2 +1,14 @@ blank_issues_enabled: false +contact_links: + - name: GitHub 讨论区 / GitHub Discussions + url: https://github.com/fcitx5-android/fcitx5-android/discussions + about: 请在这里提出有关如何使用本输入法的疑问。 / Please ask questions about how to use the input method here. + + - name: Telegram 群组 / Telegram Group + url: https://t.me/fcitx5_android_group + about: 也可以群组中提问或讨论新功能。 / You may also ask questions or discuss new features in the group. + + - name: Matrix 房间 / Matrix Room + url: https://matrix.to/#/#fcitx5-android:mozilla.org + about: Matrix 房间与 Telegram 群组通过桥接机器人互通。 / Matrix Room and Telegram Group are connected through bridge bot. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index 580097f71..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: 功能请求 / Feature Request -about: 为本项目提供新功能建议 / Suggest a new feature for this project -title: '' -labels: enhancement -assignees: '' - ---- - -#### 摘要 / Summary - - -#### 替代方案 / Alternative Solution - - -#### 附加信息 / Additional Context - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 000000000..30f66b8bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,55 @@ +name: 功能请求 / Feature Request +description: 为本项目提供新功能建议 / Suggest a new feature for this project +labels: + - enhancement + +body: + - type: textarea + id: summary + attributes: + label: 摘要 / Summary + description: 新功能应该做什么。 / What the new feature should do. + validations: + required: true + + - type: textarea + id: alternative + attributes: + label: 替代方案 / Alternative Solution + description: 其它可能的解决方案(如果有)。 / Other possible solutions, if any. + validations: + required: false + + - type: textarea + id: additional_context + attributes: + label: 附加信息 / Additional Context + description: 与此功能请求有关的上下文信息或截图。 / Additional context or screenshots about the feature request. + validations: + required: false + + - type: markdown + attributes: + value: | + ### 设备信息 / Device Infomation + + - type: input + id: os_version + attributes: + label: 系统版本 / OS Version + validations: + required: true + + - type: input + id: app_version + attributes: + label: 应用版本 / App Version + validations: + required: true + + - type: input + id: plugins_version + attributes: + label: 插件版本 / Plugins Version + validations: + required: false diff --git a/.github/workflows/fdroid.yml b/.github/workflows/fdroid.yml index ab4ac517f..8d612b32e 100644 --- a/.github/workflows/fdroid.yml +++ b/.github/workflows/fdroid.yml @@ -20,8 +20,8 @@ defaults: jobs: fdroid-build: - runs-on: ubuntu-latest - container: registry.gitlab.com/fdroid/fdroidserver:buildserver-bullseye + runs-on: ubuntu-24.04 + container: registry.gitlab.com/fdroid/fdroidserver:buildserver-bookworm strategy: matrix: abi: diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index fc833b212..01968ac76 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,27 +7,21 @@ on: jobs: develop: - strategy: - matrix: - # Nix doesn't support Android toolchain on aarch64-darwin yet - # https://github.com/NixOS/nixpkgs/issues/303968 -# os: [ubuntu-latest, macOS-latest] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-24.04 steps: - name: Fetch source code uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive - - uses: cachix/install-nix-action@v26 + - uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - - uses: cachix/cachix-action@v14 + - uses: cachix/cachix-action@v16 with: name: fcitx5-android authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Build Debug APK + - name: Build Release APK run: | - nix develop .#noAS --command ./gradlew :app:assembleDebug - nix develop .#noAS --command ./gradlew :assembleDebugPlugins + nix develop .#noAS --command ./gradlew :app:assembleRelease + nix develop .#noAS --command ./gradlew :assembleReleasePlugins diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 96f19566e..43438a178 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,10 +10,7 @@ on: jobs: publish: - runs-on: ubuntu-22.04 - env: - GITHUB_TOKEN: ${{ secrets.PACKAGE_TOKEN }} - GITHUB_ACTOR: android-fcitx5 + runs-on: ubuntu-24.04 steps: - name: Fetch source code uses: actions/checkout@v4 @@ -21,6 +18,11 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Install system dependencies + run: | + sudo apt update + sudo apt install extra-cmake-modules gettext + - name: Setup Java uses: actions/setup-java@v4 with: @@ -29,22 +31,17 @@ jobs: - name: Setup Android environment uses: android-actions/setup-android@v3 - - - name: Install Android NDK - run: | - sdkmanager --install "cmake;3.22.1" - - - name: Install system dependencies - run: | - sudo apt update - sudo apt install extra-cmake-modules gettext + with: + packages: cmake;3.31.6 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: Publish build convention and libs + env: + GITHUB_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + GITHUB_ACTOR: fcitx5-android-bot run: | ./gradlew :build-logic:convention:publish ./gradlew :lib:common:publish ./gradlew :lib:plugin-base:publish - diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a5318369b..dcc86c2e1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -15,17 +15,10 @@ jobs: fail-fast: false matrix: os: - - ubuntu-22.04 + - ubuntu-24.04 - macos-13 - macos-14 - windows-2022 - abi: - - armeabi-v7a - - arm64-v8a - - x86 - - x86_64 - env: - BUILD_ABI: ${{ matrix.abi }} steps: - name: Fetch source code uses: actions/checkout@v4 @@ -33,12 +26,6 @@ jobs: fetch-depth: 0 submodules: recursive - - name: Regenerate symlinks pointing to submodule (Windows) - if: ${{ matrix.os == 'windows-2022' }} - run: | - Remove-Item -Recurse app/src/main/assets/usr/share, plugin/hangul/src/main/assets/usr/share/libhangul, plugin/chewing/src/main/assets/usr/share/libchewing, plugin/jyutping/src/main/assets/usr/share/libime - git checkout -- . - - name: Setup Java uses: actions/setup-java@v4 with: @@ -50,10 +37,10 @@ jobs: - name: Install Android NDK run: | - sdkmanager --install "cmake;3.22.1" + sdkmanager --install "cmake;3.31.6" - name: Install system dependencies (Ubuntu) - if: ${{ matrix.os == 'ubuntu-22.04' }} + if: ${{ startsWith(matrix.os, 'ubuntu') }} run: | sudo apt update sudo apt install extra-cmake-modules gettext @@ -64,25 +51,25 @@ jobs: brew install extra-cmake-modules - name: Install system dependencies (Windows) - if: ${{ matrix.os == 'windows-2022' }} + if: ${{ startsWith(matrix.os, 'windows') }} run: | C:/msys64/usr/bin/pacman -Syu --noconfirm C:/msys64/usr/bin/pacman -S --noconfirm mingw-w64-ucrt-x86_64-gettext mingw-w64-ucrt-x86_64-extra-cmake-modules Add-Content $env:GITHUB_PATH "C:/msys64/ucrt64/bin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - - name: Build Debug APK + - name: Build Release APK run: | - ./gradlew :app:assembleDebug - ./gradlew :assembleDebugPlugins + ./gradlew :app:assembleRelease + ./gradlew :assembleReleasePlugins - name: Upload app uses: actions/upload-artifact@v4 with: - name: app-${{ matrix.os }}-${{ matrix.abi }} - path: app/build/outputs/apk/debug/ + name: app-${{ matrix.os }} + path: app/build/outputs/apk/release/ - name: Pack plugins shell: bash @@ -92,12 +79,12 @@ jobs: do if [ -d "plugin/${i}" ] then - mv "plugin/${i}/build/outputs/apk/debug" "plugins-to-upload/${i}" + mv "plugin/${i}/build/outputs/apk/release" "plugins-to-upload/${i}" fi done - name: Upload plugins uses: actions/upload-artifact@v4 with: - name: plugins-${{ matrix.os }}-${{ matrix.abi }} + name: plugins-${{ matrix.os }} path: plugins-to-upload diff --git a/.gitignore b/.gitignore index 7538326e1..0bd6d8615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,12 @@ # Module :app # Installed data -/app/src/main/assets/usr/share/fcitx5/addon -/app/src/main/assets/usr/share/fcitx5/chttrans -/app/src/main/assets/usr/share/fcitx5/data -/app/src/main/assets/usr/share/fcitx5/default -/app/src/main/assets/usr/share/fcitx5/inputmethod -/app/src/main/assets/usr/share/fcitx5/lua -/app/src/main/assets/usr/share/fcitx5/punctuation -/app/src/main/assets/usr/share/fcitx5/unicode -/app/src/main/assets/usr/share/locale +/app/src/main/assets/usr/ # Generated asset descriptor /app/src/main/assets/descriptor.json # Plugins # Installed data -/plugin/*/src/main/assets/usr/share/fcitx5 -/plugin/*/src/main/assets/usr/share/locale +/plugin/*/src/main/assets/usr/ # Generated asset descriptor /plugin/*/src/main/assets/descriptor.json @@ -108,3 +99,6 @@ lint/tmp/ # Android Profiling *.hprof + +### Kotlin ### +.kotlin/ diff --git a/.gitmodules b/.gitmodules index 67596fc97..2f3d38b6d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,54 +1,58 @@ [submodule "lib/fcitx5/src/main/cpp/fcitx5"] path = lib/fcitx5/src/main/cpp/fcitx5 - url = git@github.com:fcitx/fcitx5.git + url = https://github.com/fcitx/fcitx5.git [submodule "lib/fcitx5/src/main/cpp/prebuilt"] path = lib/fcitx5/src/main/cpp/prebuilt - url = git@github.com:fcitx5-android/prebuilt.git + url = https://github.com/fcitx5-android/prebuilt.git + shallow = true [submodule "lib/fcitx5-lua/src/main/cpp/fcitx5-lua"] path = lib/fcitx5-lua/src/main/cpp/fcitx5-lua - url = git@github.com:fcitx/fcitx5-lua.git + url = https://github.com/fcitx/fcitx5-lua.git [submodule "lib/libime/src/main/cpp/libime"] path = lib/libime/src/main/cpp/libime - url = git@github.com:fcitx/libime.git + url = https://github.com/fcitx/libime.git [submodule "lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons"] path = lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons - url = git@github.com:fcitx/fcitx5-chinese-addons.git + url = https://github.com/fcitx/fcitx5-chinese-addons.git [submodule "plugin/anthy/src/main/cpp/anthy-cmake"] path = plugin/anthy/src/main/cpp/anthy-cmake - url = git@github.com:fcitx5-android/anthy-cmake.git + url = https://github.com/fcitx5-android/anthy-cmake.git [submodule "plugin/anthy/src/main/cpp/fcitx5-anthy"] path = plugin/anthy/src/main/cpp/fcitx5-anthy - url = git@github.com:fcitx/fcitx5-anthy.git + url = https://github.com/fcitx/fcitx5-anthy.git [submodule "plugin/unikey/src/main/cpp/fcitx5-unikey"] path = plugin/unikey/src/main/cpp/fcitx5-unikey - url = git@github.com:fcitx/fcitx5-unikey.git + url = https://github.com/fcitx/fcitx5-unikey.git [submodule "plugin/rime/src/main/cpp/fcitx5-rime"] path = plugin/rime/src/main/cpp/fcitx5-rime - url = git@github.com:fcitx/fcitx5-rime.git + url = https://github.com/fcitx/fcitx5-rime.git [submodule "plugin/rime/src/main/cpp/rime-prelude"] path = plugin/rime/src/main/cpp/rime-prelude - url = git@github.com:rime/rime-prelude.git + url = https://github.com/rime/rime-prelude.git [submodule "plugin/rime/src/main/cpp/rime-essay"] path = plugin/rime/src/main/cpp/rime-essay - url = git@github.com:rime/rime-essay.git + url = https://github.com/rime/rime-essay.git [submodule "plugin/rime/src/main/cpp/rime-luna-pinyin"] path = plugin/rime/src/main/cpp/rime-luna-pinyin - url = git@github.com:rime/rime-luna-pinyin.git + url = https://github.com/rime/rime-luna-pinyin.git [submodule "plugin/rime/src/main/cpp/rime-stroke"] path = plugin/rime/src/main/cpp/rime-stroke - url = git@github.com:rime/rime-stroke.git + url = https://github.com/rime/rime-stroke.git [submodule "plugin/hangul/src/main/cpp/fcitx5-hangul"] path = plugin/hangul/src/main/cpp/fcitx5-hangul - url = git@github.com:fcitx/fcitx5-hangul.git + url = https://github.com/fcitx/fcitx5-hangul.git [submodule "plugin/chewing/src/main/cpp/fcitx5-chewing"] path = plugin/chewing/src/main/cpp/fcitx5-chewing - url = git@github.com:fcitx/fcitx5-chewing.git + url = https://github.com/fcitx/fcitx5-chewing.git [submodule "plugin/sayura/src/main/cpp/fcitx5-sayura"] path = plugin/sayura/src/main/cpp/fcitx5-sayura - url = git@github.com:fcitx/fcitx5-sayura.git + url = https://github.com/fcitx/fcitx5-sayura.git [submodule "plugin/jyutping/src/main/cpp/libime-jyutping"] path = plugin/jyutping/src/main/cpp/libime-jyutping - url = git@github.com:fcitx/libime-jyutping.git + url = https://github.com/fcitx/libime-jyutping.git [submodule "plugin/clipboard-filter/ClearURLsRules"] path = plugin/clipboard-filter/ClearURLsRules - url = git@github.com:ClearURLs/Rules.git + url = https://github.com/ClearURLs/Rules.git +[submodule "plugin/thai/src/main/cpp/fcitx5-libthai"] + path = plugin/thai/src/main/cpp/fcitx5-libthai + url = https://github.com/fcitx/fcitx5-libthai diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index b38416157..cf0517db7 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,40 @@ + + + \ No newline at end of file diff --git a/README.md b/README.md index a628669ef..85f9bba0f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ GitHub: [![release version](https://img.shields.io/github/v/release/fcitx5-andro - Japanese (via [Anthy Plugin](./plugin/anthy)) - Korean (via [Hangul Plugin](./plugin/hangul)) - Sinhala (via [Sayura Plugin](./plugin/sayura)) +- Thai (via [Thai Plugin](./plugin/thai)) - Generic (via [RIME Plugin](./plugin/rime), supports importing custom schemas) ### Implemented Features @@ -35,7 +36,7 @@ GitHub: [![release version](https://img.shields.io/github/v/release/fcitx5-andro - Virtual Keyboard (layout not customizable yet) - Expandable candidate view - Clipboard management (plain text only) -- Theming (custom color scheme and background image) +- Theming (custom color scheme, background image and dynamic color aka monet color after Android 12) - Popup preview on key press - Long press popup keyboard for convenient symbol input - Symbol and Emoji picker @@ -68,7 +69,7 @@ Discuss on Telegram: [@fcitx5_android_group](https://t.me/fcitx5_android_group) ### Dependencies -- Android SDK Platform & Build-Tools 34. +- Android SDK Platform & Build-Tools 35. - Android NDK (Side by side) 25 & CMake 3.22.1, they can be installed using SDK Manager in Android Studio or `sdkmanager` command line. - [KDE/extra-cmake-modules](https://github.com/KDE/extra-cmake-modules) - GNU Gettext >= 0.20 (for `msgfmt` binary; or install `appstream` if you really have to use gettext <= 0.19.) @@ -95,29 +96,6 @@ git clone git@github.com:fcitx5-android/fcitx5-android.git git submodule update --init --recursive ``` -
-On Windows, you may need to regenerate symlinks to submodules. - -Run in PowerShell: - -```powershell -Remove-Item -Recurse app/src/main/assets/usr/share, plugin/hangul/src/main/assets/usr/share/libhangul, plugin/chewing/src/main/assets/usr/share/libchewing, plugin/jyutping/src/main/assets/usr/share/libime -``` - -Or Command Prompt: - -```bat -RD /S /Q app\src\main\assets\usr\share plugin\hangul\src\main\assets\usr\share\libhangul plugin\chewing\src\main\assets\usr\share\libchewing plugin\jyutping\src\main\assets\usr\share\libime -``` - -Then let `git` regenerate symlinks: - -```shell -git checkout -- . -``` - -
- Install `extra-cmake-modules` and `gettext` with your system package manager: ```shell diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e54b4a83f..1f435a29b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,17 +67,18 @@ kotlin { } } -aboutLibraries { - configPath = "app/licenses" -} - fcitxComponent { - installLibraries = listOf( + includeLibs = listOf( "fcitx5", "fcitx5-lua", "libime", "fcitx5-chinese-addons" ) + // exclude (delete immediately after install) tables that nobody would use + excludeFiles = listOf("cangjie", "erbi", "qxm", "wanfeng").map { + "usr/share/fcitx5/inputmethod/$it.conf" + } + installPrebuiltAssets = true } ksp { @@ -116,7 +117,8 @@ dependencies { implementation(libs.androidx.startup) implementation(libs.androidx.viewpager2) implementation(libs.material) - implementation(libs.arrow) + implementation(libs.arrow.core) + implementation(libs.arrow.functions) implementation(libs.imagecropper) implementation(libs.flexbox) implementation(libs.dependency) @@ -138,7 +140,6 @@ dependencies { androidTestImplementation(libs.junit) } -@Suppress("UnstableApiUsage") configurations { all { // remove Baseline Profile Installer or whatever it is... diff --git a/app/licenses/libraries/boost.json b/app/licenses/libraries/boost.json index 7a2285fb6..cebc09b62 100644 --- a/app/licenses/libraries/boost.json +++ b/app/licenses/libraries/boost.json @@ -1,6 +1,6 @@ { "uniqueId": "boostorg/boost", - "artifactVersion": "1.83.0", + "artifactVersion": "1.86.0", "description": "Free peer-reviewed portable C++ source libraries", "name": "boostorg/boost", "website": "https://www.boost.org/", diff --git a/app/licenses/libraries/fcitx5-chinese-addons.json b/app/licenses/libraries/fcitx5-chinese-addons.json index aef4c5662..8200bcbed 100644 --- a/app/licenses/libraries/fcitx5-chinese-addons.json +++ b/app/licenses/libraries/fcitx5-chinese-addons.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5-chinese-addons", - "artifactVersion": "5.1.5", + "artifactVersion": "5.1.7", "description": "Chinese related addon for fcitx5", "name": "fcitx/fcitx5-chinese-addons", "website": "https://github.com/fcitx/fcitx5-chinese-addons", diff --git a/app/licenses/libraries/fcitx5-lua.json b/app/licenses/libraries/fcitx5-lua.json index 69033b46c..e3ff2ca38 100644 --- a/app/licenses/libraries/fcitx5-lua.json +++ b/app/licenses/libraries/fcitx5-lua.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5-lua", - "artifactVersion": "5.0.13", + "artifactVersion": "5.0.14", "description": "Lua support for fcitx5", "name": "fcitx/fcitx5-lua", "website": "https://github.com/fcitx/fcitx5-lua", diff --git a/app/licenses/libraries/fcitx5.json b/app/licenses/libraries/fcitx5.json index 6545963ba..f20f85043 100644 --- a/app/licenses/libraries/fcitx5.json +++ b/app/licenses/libraries/fcitx5.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/fcitx5", - "artifactVersion": "5.1.9", + "artifactVersion": "5.1.12", "description": "Next generation of fcitx", "name": "fcitx/fcitx5", "website": "https://github.com/fcitx/fcitx5", diff --git a/app/licenses/libraries/fmt.json b/app/licenses/libraries/fmt.json index b69becddf..07f2dbdc1 100644 --- a/app/licenses/libraries/fmt.json +++ b/app/licenses/libraries/fmt.json @@ -1,6 +1,6 @@ { "uniqueId": "fmtlib/fmt", - "artifactVersion": "9.1.0", + "artifactVersion": "11.0.2", "description": "Open-source formatting library for C++", "name": "fmtlib/fmt", "website": "https://fmt.dev", diff --git a/app/licenses/libraries/libime.json b/app/licenses/libraries/libime.json index 1522073e9..18507f632 100644 --- a/app/licenses/libraries/libime.json +++ b/app/licenses/libraries/libime.json @@ -1,6 +1,6 @@ { "uniqueId": "fcitx/libime", - "artifactVersion": "1.1.7", + "artifactVersion": "1.1.10", "description": "library to support generic input method implementation", "name": "fcitx/libime", "website": "https://github.com/fcitx/libime", diff --git a/app/licenses/libraries/libintl-lite.json b/app/licenses/libraries/libintl-lite.json index 86daf4cce..e08b58baf 100644 --- a/app/licenses/libraries/libintl-lite.json +++ b/app/licenses/libraries/libintl-lite.json @@ -1,6 +1,6 @@ { "uniqueId": "j-jorge/libintl-lite", - "artifactVersion": "5750d92", + "artifactVersion": "ba15146", "description": "simple (but less powerful) GNU gettext libintl replacement", "name": "j-jorge/libintl-lite", "website": "https://github.com/j-jorge/libintl-lite", diff --git a/app/licenses/libraries/libuv.json b/app/licenses/libraries/libuv.json index af34bce99..65d992b93 100644 --- a/app/licenses/libraries/libuv.json +++ b/app/licenses/libraries/libuv.json @@ -1,6 +1,6 @@ { "uniqueId": "libuv/libuv", - "artifactVersion": "1.47.0", + "artifactVersion": "1.49.2", "description": "Cross-platform asynchronous I/O", "name": "libuv/libuv", "website": "https://libuv.org/", diff --git a/app/licenses/libraries/lua.json b/app/licenses/libraries/lua.json index 386077cda..11c9ef4c7 100644 --- a/app/licenses/libraries/lua.json +++ b/app/licenses/libraries/lua.json @@ -1,6 +1,6 @@ { "uniqueId": "lua/lua", - "artifactVersion": "5.4.6", + "artifactVersion": "5.4.7", "description": "Powerful lightweight programming language designed for extending applications", "name": "lua/lua", "website": "https://www.lua.org/", diff --git a/app/licenses/libraries/opencc.json b/app/licenses/libraries/opencc.json index 5aa3ea77b..ed4109ee0 100644 --- a/app/licenses/libraries/opencc.json +++ b/app/licenses/libraries/opencc.json @@ -1,6 +1,6 @@ { "uniqueId": "BYVoid/OpenCC", - "artifactVersion": "1.1.7", + "artifactVersion": "1.1.9", "description": "opensource project for conversions between Traditional Chinese, Simplified Chinese and Japanese Kanji (Shinjitai).", "name": "BYVoid/OpenCC", "website": "https://opencc.byvoid.com/", diff --git a/app/org.fcitx.fcitx5.android.yml b/app/org.fcitx.fcitx5.android.yml index c99e68d07..7c2ffc2eb 100644 --- a/app/org.fcitx.fcitx5.android.yml +++ b/app/org.fcitx.fcitx5.android.yml @@ -21,10 +21,9 @@ Builds: sudo: - apt-get update - apt-get install -y g++ libtool make automake gettext bzip2 xz-utils zstd pkg-config - cmake extra-cmake-modules ninja-build libfmt-dev libboost-all-dev libfcitx5utils-dev opencc openjdk-17-jdk-headless - ghc cabal-install libghc-shake-dev libghc-aeson-pretty-dev libghc-js-flot-data haskell-js-dgtable-utils - - update-java-alternatives -a - - apt-get install -y -t bullseye-backports fcitx5-modules + cmake extra-cmake-modules ninja-build libfmt-dev libsystemd-dev libboost-all-dev + ghc cabal-install libghc-shake-dev libghc-aeson-pretty-dev libghc-js-flot-data haskell-js-dgtable-utils + python-is-python3 opencc gradle: - yes binary: https://jenkins.fcitx-im.org/job/android/job/fcitx5-android/lastSuccessfulBuild/artifact/out/org.fcitx.fcitx5.android-%v-%abi-release.apk @@ -33,7 +32,7 @@ Builds: rm: - lib/fcitx5/src/main/cpp/prebuilt prebuild: - - sdkmanager 'cmake;3.22.1' + - sdkmanager 'cmake;3.31.6' - sed -i -e '/ImportQualifiedPost/d' $$fcitx5-android-prebuilder$$/src/Main.hs - sed -i -e 's/import \(.*\) qualified as/import qualified \1 as/g' $$fcitx5-android-prebuilder$$/src/*.hs - sed -i -e 's|https://maven.pkg.github.com|https://jitpack.io|g' ../build-logic/convention/build.gradle.kts @@ -44,12 +43,11 @@ Builds: - build-logic/convention/build build: - pushd $$fcitx5-android-prebuilder$$ - - ABI=%abi ANDROID_NDK_ROOT=$$NDK$$ CMAKE_VERSION=3.22.1 ANDROID_PLATFORM=23 - COMP_SPELL_DICT=/usr/lib/x86_64-linux-gnu/fcitx5/libexec/comp-spell-dict - ./build-cabal -j spell-dict fmt libuv libintl-lite boost marisa opencc libime lua chinese-addons-data zstd + - ABI=%abi ANDROID_NDK_ROOT=$$NDK$$ CMAKE_VERSION=3.31.6 ANDROID_PLATFORM=23 + ./build-cabal -j app - popd - mv $$fcitx5-android-prebuilder$$/build ../lib/fcitx5/src/main/cpp/prebuilt - ndk: 25.2.9519653 + ndk: 28.0.13004108 gradleprops: - buildABI=%abi - buildTimestamp=%ts diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a5aecc0d..fcc089b60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,12 +62,9 @@ android:configChanges="orientation|screenSize" android:exported="false" android:label="@string/edit_theme" /> - + android:name=".ui.main.CropImageActivity" + android:exported="false" /> + @@ -102,6 +103,10 @@ + + + + @@ -152,12 +157,22 @@ + + + + + + ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - # fcitx5-lua - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - # fcitx5-chinese-addons - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} - COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + ${CMAKE_LIBRARY_OUTPUT_DIRECTORY} COMMENT "Copying fcitx5 module libraries to :app" ) + +# install prebuilt assets +install(FILES "${PREBUILT_DIR}/spell-dict/en_dict.fscd" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/spell" COMPONENT prebuilt-assets) +install(FILES "${PREBUILT_DIR}/chinese-addons-data/pinyin/chaizi.dict" DESTINATION "${FCITX_INSTALL_PKGDATADIR}/pinyin" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/chinese-addons-data/pinyinhelper" DESTINATION "${FCITX_INSTALL_PKGDATADIR}" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/libime/table" DESTINATION "${FCITX_INSTALL_PKGDATADIR}" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/libime/data/" DESTINATION "${FCITX_INSTALL_DATADIR}/libime" COMPONENT prebuilt-assets) +install(DIRECTORY "${PREBUILT_DIR}/opencc/data/" DESTINATION "${FCITX_INSTALL_DATADIR}/opencc" COMPONENT prebuilt-assets) diff --git a/app/src/main/cpp/androidaddonloader/androidaddonloader.cpp b/app/src/main/cpp/androidaddonloader/androidaddonloader.cpp deleted file mode 100644 index 26ae10387..000000000 --- a/app/src/main/cpp/androidaddonloader/androidaddonloader.cpp +++ /dev/null @@ -1,91 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2016-2016 CSSlayer - * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors - * SPDX-FileComment: Modified from https://github.com/fcitx/fcitx5/blob/5.1.1/src/lib/fcitx/addonloader.cpp - */ -#include "androidaddonloader.h" - -#define FCITX_LIBRARY_SUFFIX ".so" - -namespace fcitx { - -AndroidSharedLibraryLoader::AndroidSharedLibraryLoader(AndroidLibraryDependency dependency) - : dependency_(std::move(dependency)) {} - -AddonInstance *AndroidSharedLibraryLoader::load(const AddonInfo &info, - AddonManager *manager) { - auto iter = registry_.find(info.uniqueName()); - if (iter == registry_.end()) { - std::string libname = info.library(); - Flags flag = LibraryLoadHint::DefaultHint; - if (stringutils::startsWith(libname, "export:")) { - libname = libname.substr(7); - flag |= LibraryLoadHint::ExportExternalSymbolsHint; - } - auto file = libname + FCITX_LIBRARY_SUFFIX; - auto libs = standardPath_.locateAll(StandardPath::Type::Addon, file); - if (libs.empty()) { - FCITX_ERROR() << "Could not locate library " << file - << " for addon " << info.uniqueName() << "."; - } - // ========== Android specific start ========== // - auto deps = dependency_.find(libname); - if (deps != dependency_.end()) { - for (const auto &dep: deps->second) { - auto depFile = dep + FCITX_LIBRARY_SUFFIX; - auto depPaths = standardPath_.locateAll(StandardPath::Type::Addon, depFile); - if (depPaths.empty()) { - FCITX_ERROR() << "Could not locate dependency " << depFile - << " for library " << file << "."; - } else { - for (const auto &depPath: depPaths) { - Library depLib(depPath); - if (!depLib.load()) { - FCITX_ERROR() << "Failed to load dependency " << depPath - << " for library " << file << "."; - } else { - FCITX_INFO() << "Loaded dependency " << depFile - << " for library " << file << "."; - break; - } - } - } - } - } - // ========== Android specific end ========== // - for (const auto &libraryPath: libs) { - Library lib(libraryPath); - if (!lib.load(flag)) { - FCITX_ERROR() - << "Failed to load library for addon " << info.uniqueName() - << " on " << libraryPath << ". Error: " << lib.error(); - continue; - } - try { - registry_.emplace( - info.uniqueName(), - std::make_unique(std::move(lib))); - } catch (const std::exception &e) { - } - break; - } - iter = registry_.find(info.uniqueName()); - } - - if (iter == registry_.end()) { - return nullptr; - } - - try { - return iter->second->factory()->create(manager); - } catch (const std::exception &e) { - FCITX_ERROR() << "Failed to create addon: " << info.uniqueName() << " " - << e.what(); - } catch (...) { - FCITX_ERROR() << "Failed to create addon: " << info.uniqueName(); - } - return nullptr; -} - -} diff --git a/app/src/main/cpp/androidaddonloader/androidaddonloader.h b/app/src/main/cpp/androidaddonloader/androidaddonloader.h deleted file mode 100644 index ff3a444b9..000000000 --- a/app/src/main/cpp/androidaddonloader/androidaddonloader.h +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2016-2016 CSSlayer - * SPDX-FileCopyrightText: Copyright 2023 Fcitx5 for Android Contributors - * SPDX-FileComment: Modified from https://github.com/fcitx/fcitx5/blob/5.1.1/src/lib/fcitx/addonloader_p.h - */ -#ifndef FCITX5_ANDROID_ANDROIDADDONLOADER_H -#define FCITX5_ANDROID_ANDROIDADDONLOADER_H - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace fcitx { - -class AndroidSharedLibraryFactory { -public: - AndroidSharedLibraryFactory(Library lib) : library_(std::move(lib)) { - auto *funcPtr = library_.resolve("fcitx_addon_factory_instance"); - if (!funcPtr) { - throw std::runtime_error(library_.error()); - } - auto func = Library::toFunction(funcPtr); - factory_ = func(); - if (!factory_) { - throw std::runtime_error("Failed to get a factory"); - } - } - - AddonFactory *factory() { return factory_; } - -private: - Library library_; - AddonFactory *factory_; -}; - -typedef std::unordered_map> AndroidLibraryDependency; - -class AndroidSharedLibraryLoader : public AddonLoader { -public: - AndroidSharedLibraryLoader(AndroidLibraryDependency dependency); - ~AndroidSharedLibraryLoader() = default; - std::string type() const override { return "SharedLibrary"; } - - AddonInstance *load(const AddonInfo &info, AddonManager *manager) override; - -private: - StandardPath standardPath_; - std::unordered_map> registry_; - AndroidLibraryDependency dependency_; -}; - -} - -#endif //FCITX5_ANDROID_ANDROIDADDONLOADER_H diff --git a/app/src/main/cpp/androidfrontend/androidfrontend.cpp b/app/src/main/cpp/androidfrontend/androidfrontend.cpp index 37ceff737..620452604 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend.cpp +++ b/app/src/main/cpp/androidfrontend/androidfrontend.cpp @@ -4,6 +4,7 @@ */ #include #include +#include #include #include #include @@ -49,7 +50,8 @@ class AndroidInputContext : public InputContextV2 { const int before = -offset; const int after = offset + static_cast(size); if (before < 0 || after < 0) { - FCITX_WARN() << "Invalid deleteSurrounding request: offset=" << offset << ", size=" << size; + FCITX_WARN() << "Invalid deleteSurrounding request: offset=" << offset << ", size=" + << size; return; } frontend_->deleteSurrounding(before, after); @@ -66,9 +68,12 @@ class AndroidInputContext : public InputContextV2 { filterText(ip.auxUp()), filterText(ip.auxDown()) ); + } + + void updateCandidatesBulk() { std::vector candidates; int size = 0; - const auto &list = ip.candidateList(); + const auto &list = inputPanel().candidateList(); if (list) { const auto &bulk = list->toBulk(); if (bulk) { @@ -96,7 +101,37 @@ class AndroidInputContext : public InputContextV2 { frontend_->updateCandidateList(candidates, size); } - bool selectCandidate(int idx) { + void updateCandidatesPaged() { + const auto &list = inputPanel().candidateList(); + if (!list) { + frontend_->updatePagedCandidate(PagedCandidateEntity::Empty); + return; + } + int cursorIndex = list->cursorIndex(); + CandidateLayoutHint layoutHint = list->layoutHint(); + bool hasPrev = false; + bool hasNext = false; + const auto &pageable = list->toPageable(); + if (pageable) { + hasPrev = pageable->hasPrev(); + hasNext = pageable->hasNext(); + } + int size = list->size(); + std::vector candidates; + candidates.reserve(size); + for (int i = 0; i < size; i++) { + const auto &c = list->candidate(i); + candidates.emplace_back( + filterString(list->label(i)), + filterString(c.text()), + filterString(c.comment()) + ); + } + PagedCandidateEntity paged(candidates, cursorIndex, layoutHint, hasPrev, hasNext); + frontend_->updatePagedCandidate(paged); + } + + bool selectCandidateBulk(int idx) { const auto &list = inputPanel().candidateList(); if (!list) { return false; @@ -115,6 +150,20 @@ class AndroidInputContext : public InputContextV2 { return true; } + bool selectCandidatePaged(int idx) { + const auto &list = inputPanel().candidateList(); + if (!list) { + return false; + } + try { + list->candidate(idx).select(this); + } catch (const std::invalid_argument &e) { + FCITX_WARN() << "selectCandidate index out of range"; + return false; + } + return true; + } + std::vector getCandidates(const int offset, const int limit) { std::vector candidates; const auto &list = inputPanel().candidateList(); @@ -142,6 +191,77 @@ class AndroidInputContext : public InputContextV2 { return candidates; } + std::vector getCandidateAction(const int idx) { + std::vector actions; + const auto &list = inputPanel().candidateList(); + if (list) { + const auto &actionable = list->toActionable(); + if (actionable) { + if (idx >= list->size()) { + const auto &bulk = list->toBulk(); + if (bulk) { + try { + const auto &c = bulk->candidateFromAll(idx); + for (const auto &a: actionable->candidateActions(c)) { + actions.emplace_back(a); + } + } catch (const std::exception &e) { + FCITX_WARN() << "getCandidateAction(" << idx << ") failed:" << e.what(); + } + } + } else { + const auto &c = list->candidate(idx); + for (const auto &a: actionable->candidateActions(c)) { + actions.emplace_back(a); + } + } + } + } + return actions; + } + + void triggerCandidateAction(const int idx, const int actionIdx) { + const auto &list = inputPanel().candidateList(); + if (!list) return; + const auto &actionable = list->toActionable(); + if (!actionable) return; + if (idx >= list->size()) { + const auto &bulk = list->toBulk(); + if (bulk) { + try { + const auto &c = bulk->candidateFromAll(idx); + actionable->triggerAction(c, actionIdx); + } catch (const std::exception &e) { + FCITX_WARN() << "triggerCandidateAction(" << idx << ") failed:" << e.what(); + } + } + } else { + const auto &c = list->candidate(idx); + actionable->triggerAction(c, actionIdx); + } + } + + void offsetCandidatePage(int delta) { + if (delta == 0) { + return; + } + const auto &list = inputPanel().candidateList(); + if (!list) { + return; + } + const auto &pageable = list->toPageable(); + if (!pageable) { + return; + } + if (delta > 0 && pageable->hasNext()) { + pageable->next(); + updateUserInterface(UserInterfaceComponent::InputPanel); + } else if (delta < 0 && pageable->hasPrev()) { + pageable->prev(); + updateUserInterface(UserInterfaceComponent::InputPanel); + } + } + private: AndroidFrontend *frontend_; int uid_; @@ -160,7 +280,8 @@ AndroidFrontend::AndroidFrontend(Instance *instance) focusGroup_("android", instance->inputContextManager()), activeIC_(nullptr), icCache_(), - eventHandlers_() { + eventHandlers_(), + pagingMode_(0) { eventHandlers_.emplace_back(instance_->watchEvent( EventType::InputContextInputMethodActivated, EventWatcherPhase::Default, @@ -176,7 +297,14 @@ AndroidFrontend::AndroidFrontend(Instance *instance) auto &e = static_cast(event); switch (e.component()) { case UserInterfaceComponent::InputPanel: { - if (activeIC_) activeIC_->updateInputPanel(); + if (activeIC_) { + activeIC_->updateInputPanel(); + if (pagingMode_ == 0) { + activeIC_->updateCandidatesBulk(); + } else { + activeIC_->updateCandidatesPaged(); + } + } break; } case UserInterfaceComponent::StatusArea: { @@ -225,29 +353,21 @@ void AndroidFrontend::releaseInputContext(const int uid) { bool AndroidFrontend::selectCandidate(int idx) { if (!activeIC_) return false; - return activeIC_->selectCandidate(idx); -} - -bool AndroidFrontend::forgetCandidate(int idx) { - if (!activeIC_) return false; - // check current engine, only pinyin and table engine support deleting words - auto *entry = instance_->inputMethodEntry(activeIC_); - if (entry->addon() != "pinyin" && entry->addon() != "table") return false; - // do we have candidate list? - auto list = activeIC_->inputPanel().candidateList(); - if (!list) return false; - // Ctrl+7 to activate forget candidate mode - Key key(FcitxKey_7, Flags(KeyState::Ctrl)); - KeyEvent pressEvent(activeIC_, key, false); - auto handled = activeIC_->keyEvent(pressEvent); - if (handled) { - KeyEvent releaseEvent(activeIC_, key, true); - activeIC_->keyEvent(releaseEvent); + if (pagingMode_) { + return activeIC_->selectCandidatePaged(idx); } else { - // something went wrong - return false; + return activeIC_->selectCandidateBulk(idx); } - return activeIC_->selectCandidate(idx); +} + +std::vector AndroidFrontend::getCandidateActions(const int idx) { + if (!activeIC_) return {}; + return activeIC_->getCandidateAction(idx); +} + +void AndroidFrontend::triggerCandidateAction(const int idx, const int actionIdx) { + if (!activeIC_) return; + activeIC_->triggerCandidateAction(idx, actionIdx); } bool AndroidFrontend::isInputPanelEmpty() { @@ -320,6 +440,19 @@ void AndroidFrontend::showToast(const std::string &s) { toastCallback(s); } +void AndroidFrontend::setCandidatePagingMode(const int mode) { + pagingMode_ = mode; +} + +void AndroidFrontend::updatePagedCandidate(const PagedCandidateEntity &paged) { + pagedCandidateCallback(paged); +} + +void AndroidFrontend::offsetCandidatePage(int delta) { + if (!activeIC_) return; + activeIC_->offsetCandidatePage(delta); +} + void AndroidFrontend::setCommitStringCallback(const CommitStringCallback &callback) { commitStringCallback = callback; } @@ -352,6 +485,10 @@ void AndroidFrontend::setToastCallback(const ToastCallback &callback) { toastCallback = callback; } +void AndroidFrontend::setPagedCandidateCallback(const PagedCandidateCallback &callback) { + pagedCandidateCallback = callback; +} + class AndroidFrontendFactory : public AddonFactory { public: AddonInstance *create(AddonManager *manager) override { diff --git a/app/src/main/cpp/androidfrontend/androidfrontend.h b/app/src/main/cpp/androidfrontend/androidfrontend.h index 796973d7c..9b9db800b 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend.h +++ b/app/src/main/cpp/androidfrontend/androidfrontend.h @@ -2,8 +2,9 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ -#ifndef _FCITX5_ANDROID_ANDROIDFRONTEND_H_ -#define _FCITX5_ANDROID_ANDROIDFRONTEND_H_ + +#ifndef FCITX5_ANDROID_ANDROIDFRONTEND_H +#define FCITX5_ANDROID_ANDROIDFRONTEND_H #include #include @@ -18,7 +19,7 @@ class AndroidInputContext; class AndroidFrontend : public AddonInstance { public: - AndroidFrontend(Instance *instance); + explicit AndroidFrontend(Instance *instance); Instance *instance() { return instance_; } @@ -27,6 +28,7 @@ class AndroidFrontend : public AddonInstance { void updateClientPreedit(const Text &clientPreedit); void updateInputPanel(const Text &preedit, const Text &auxUp, const Text &auxDown); void releaseInputContext(const int uid); + void updatePagedCandidate(const PagedCandidateEntity &paged); void keyEvent(const Key &key, bool isRelease, const int timestamp); void forwardKey(const Key &key, bool isRelease); @@ -37,11 +39,15 @@ class AndroidFrontend : public AddonInstance { void focusInputContext(bool focus); void activateInputContext(const int uid, const std::string &pkgName); void deactivateInputContext(const int uid); - InputContext *activeInputContext() const; + [[nodiscard]] InputContext *activeInputContext() const; void setCapabilityFlags(uint64_t flag); std::vector getCandidates(const int offset, const int limit); + std::vector getCandidateActions(const int idx); + void triggerCandidateAction(const int idx, const int actionIdx); void deleteSurrounding(const int before, const int after); void showToast(const std::string &s); + void setCandidatePagingMode(const int mode); + void offsetCandidatePage(int delta); void setCandidateListCallback(const CandidateListCallback &callback); void setCommitStringCallback(const CommitStringCallback &callback); void setPreeditCallback(const ClientPreeditCallback &callback); @@ -51,7 +57,7 @@ class AndroidFrontend : public AddonInstance { void setStatusAreaUpdateCallback(const StatusAreaUpdateCallback &callback); void setDeleteSurroundingCallback(const DeleteSurroundingCallback &callback); void setToastCallback(const ToastCallback &callback); - bool forgetCandidate(int idx); + void setPagedCandidateCallback(const PagedCandidateCallback &callback); private: FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, keyEvent); @@ -65,7 +71,11 @@ class AndroidFrontend : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, deactivateInputContext); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCapabilityFlags); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, getCandidates); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, getCandidateActions); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, triggerCandidateAction); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, showToast); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCandidatePagingMode); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, offsetCandidatePage); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCandidateListCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setCommitStringCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setPreeditCallback); @@ -75,13 +85,14 @@ class AndroidFrontend : public AddonInstance { FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setStatusAreaUpdateCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setDeleteSurroundingCallback); FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setToastCallback); - FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, forgetCandidate); + FCITX_ADDON_EXPORT_FUNCTION(AndroidFrontend, setPagedCandidateCallback); Instance *instance_; FocusGroup focusGroup_; AndroidInputContext *activeIC_; InputContextCache icCache_; std::vector>> eventHandlers_; + int pagingMode_; CandidateListCallback candidateListCallback = [](const std::vector &, const int) {}; CommitStringCallback commitStringCallback = [](const std::string &, const int) {}; @@ -92,7 +103,8 @@ class AndroidFrontend : public AddonInstance { StatusAreaUpdateCallback statusAreaUpdateCallback = [] {}; DeleteSurroundingCallback deleteSurroundingCallback = [](const int, const int) {}; ToastCallback toastCallback = [](const std::string &) {}; + PagedCandidateCallback pagedCandidateCallback = [](const PagedCandidateEntity &) {}; }; } // namespace fcitx -#endif //_FCITX5_ANDROID_ANDROIDFRONTEND_H_ +#endif //FCITX5_ANDROID_ANDROIDFRONTEND_H diff --git a/app/src/main/cpp/androidfrontend/androidfrontend_public.h b/app/src/main/cpp/androidfrontend/androidfrontend_public.h index 76ded712b..69bcbbdd5 100644 --- a/app/src/main/cpp/androidfrontend/androidfrontend_public.h +++ b/app/src/main/cpp/androidfrontend/androidfrontend_public.h @@ -2,13 +2,16 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ -#ifndef _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ -#define _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ +#ifndef FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H +#define FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H #include #include +#include #include +#include "../helper-types.h" + typedef std::function &, const int)> CandidateListCallback; typedef std::function CommitStringCallback; typedef std::function ClientPreeditCallback; @@ -18,6 +21,7 @@ typedef std::function InputMethodChangeCallback; typedef std::function StatusAreaUpdateCallback; typedef std::function DeleteSurroundingCallback; typedef std::function ToastCallback; +typedef std::function PagedCandidateCallback; FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, keyEvent, void(const fcitx::Key &, bool isRelease, const int timestamp)) @@ -52,9 +56,21 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCapabilityFlags, FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, getCandidates, std::vector(const int, const int)) +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, getCandidateActions, + std::vector(const int)) + +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, triggerCandidateAction, + void(const int, const int)) + FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, showToast, void(const std::string &)) +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCandidatePagingMode, + void(const int)) + +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, offsetCandidatePage, + void(int)) + FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setCandidateListCallback, void(const CandidateListCallback &)) @@ -82,7 +98,7 @@ FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setDeleteSurroundingCallback, FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setToastCallback, void(const ToastCallback &)) -FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, forgetCandidate, - bool(int idx)) +FCITX_ADDON_DECLARE_FUNCTION(AndroidFrontend, setPagedCandidateCallback, + void(const PagedCandidateCallback &)) -#endif // _FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H_ +#endif // FCITX5_ANDROID_ANDROIDFRONTEND_PUBLIC_H diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp b/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp index 153108750..a84d55ab7 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.cpp @@ -108,8 +108,7 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev } if (key.isLAZ() || key.isUAZ() || validSym || (!buffer.empty() && key.checkKeyList(FCITX_HYPHEN_APOS))) { - auto text = Key::keySymToUTF8(key.sym()); - if (updateBuffer(inputContext, text)) { + if (updateBuffer(inputContext, event)) { return event.filterAndAccept(); } } @@ -142,16 +141,16 @@ void AndroidKeyboardEngine::keyEvent(const InputMethodEntry &entry, KeyEvent &ev auto cursor = buffer.cursor(); if (cursor > 0) { buffer.setCursor(cursor - 1); + event.filterAndAccept(); + return updateCandidate(entry, inputContext); } - event.filterAndAccept(); - return updateCandidate(entry, inputContext); } else if (key.check(FcitxKey_Right) || key.check(FcitxKey_KP_Right)) { auto cursor = buffer.cursor(); if (cursor < buffer.size()) { buffer.setCursor(buffer.cursor() + 1); + event.filterAndAccept(); + return updateCandidate(entry, inputContext); } - event.filterAndAccept(); - return updateCandidate(entry, inputContext); } } @@ -249,14 +248,22 @@ void AndroidKeyboardEngine::resetState(InputContext *inputContext, bool fromCand void AndroidKeyboardEngine::updateCandidate(const InputMethodEntry &entry, InputContext *inputContext) { inputContext->inputPanel().reset(); auto *state = inputContext->propertyFor(&factory_); + const auto userInput = state->buffer_.userInput(); std::vector> results; if (spell()) { results = spell()->call(entry.languageCode(), SpellProvider::Default, - state->buffer_.userInput(), + userInput, SpellCandidateSize); } auto candidateList = std::make_unique(); + if (results.empty() || results.front().second != userInput) { + // TODO: comply with fcitx5 spell module's delim " _-,./?!%" + // it's fine in androidkeyboard because only "-" won't commit buffer + const auto segments = stringutils::split(userInput, "-"); + const auto label = segments.size() > 1 ? segments.back() : userInput; + candidateList->append(this, Text(label), userInput); + } for (const auto &result: results) { candidateList->append(this, Text(result.first), result.second); } @@ -283,7 +290,7 @@ void AndroidKeyboardEngine::updateUI(InputContext *inputContext) { inputContext->updateUserInterface(UserInterfaceComponent::InputPanel); } -bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const std::string &chr) { +bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const KeyEvent& event) { auto *entry = instance_->inputMethodEntry(inputContext); if (!entry) { return false; @@ -292,6 +299,7 @@ bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const std:: auto *state = inputContext->propertyFor(&factory_); // word hint is disabled, input is password, or language not supported if (!*config_.enableWordHint || + (!*config_.hintOnPhysicalKeyboard && !event.isVirtual()) || (*config_.editorControlledWordHint && inputContext->capabilityFlags().test(CapabilityFlag::NoSpellCheck)) || inputContext->capabilityFlags().test(CapabilityFlag::Password) || !supportHint(entry->languageCode())) { @@ -305,7 +313,7 @@ bool AndroidKeyboardEngine::updateBuffer(InputContext *inputContext, const std:: buffer.type(preedit); } - buffer.type(chr); + buffer.type(Key::keySymToUTF8(event.key().sym())); if (buffer.size() >= MaxBufferSize) { commitBuffer(inputContext); diff --git a/app/src/main/cpp/androidkeyboard/androidkeyboard.h b/app/src/main/cpp/androidkeyboard/androidkeyboard.h index 21fd52da7..b365ac527 100644 --- a/app/src/main/cpp/androidkeyboard/androidkeyboard.h +++ b/app/src/main/cpp/androidkeyboard/androidkeyboard.h @@ -2,8 +2,8 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ -#ifndef _FCITX5_ANDROID_ANDROIDKEYBOARD_H_ -#define _FCITX5_ANDROID_ANDROIDKEYBOARD_H_ +#ifndef FCITX5_ANDROID_ANDROIDKEYBOARD_H +#define FCITX5_ANDROID_ANDROIDKEYBOARD_H #include #include @@ -30,6 +30,8 @@ FCITX_CONFIGURATION( AndroidKeyboardEngineConfig, Option enableWordHint{this, "EnableWordHint", _("Enable word hint"), true}; + Option + hintOnPhysicalKeyboard{this, "WordHintOnPhysicalKeyboard", _("Enable word hint when using physical keyboard"), false}; Option editorControlledWordHint{this, "EditorControlledWordHint", _("Disable word hint based on editor attributes"), true}; Option @@ -59,8 +61,7 @@ class AndroidKeyboardEngine final : public InputMethodEngineV3 { static int constexpr MaxBufferSize = 20; static int constexpr SpellCandidateSize = 20; - AndroidKeyboardEngine(Instance *instance); - ~AndroidKeyboardEngine() = default; + explicit AndroidKeyboardEngine(Instance *instance); Instance *instance() { return instance_; } @@ -96,10 +97,10 @@ class AndroidKeyboardEngine final : public InputMethodEngineV3 { auto factory() { return &factory_; } - // Return true if chr is pushed to buffer. - // Return false if chr will be skipped by buffer, usually this means caller - // need to call commit buffer and forward chr manually. - bool updateBuffer(InputContext *inputContext, const std::string &chr); + // Return true if event is pushed to buffer. + // Return false if event will be skipped by buffer, usually this means caller + // need to call commit buffer and forward event manually. + bool updateBuffer(InputContext *inputContext, const KeyEvent& event); // Commit current buffer, also reset the state. // See also preeditString(). @@ -133,4 +134,4 @@ class AndroidKeyboardEngineFactory : public AddonFactory { } -#endif //_FCITX5_ANDROID_ANDROIDKEYBOARD_H_ +#endif //FCITX5_ANDROID_ANDROIDKEYBOARD_H diff --git a/app/src/main/cpp/androidnotification/androidnotification.h b/app/src/main/cpp/androidnotification/androidnotification.h index 4dbd35ba9..242e2857b 100644 --- a/app/src/main/cpp/androidnotification/androidnotification.h +++ b/app/src/main/cpp/androidnotification/androidnotification.h @@ -22,12 +22,11 @@ namespace fcitx { FCITX_CONFIGURATION(NotificationsConfig, fcitx::Option> hiddenNotifications{ this, "HiddenNotifications", - _("Hidden Notifications")};); + _("Hidden Notifications")};) class Notifications final : public AddonInstance { public: explicit Notifications(Instance *instance); - ~Notifications() override = default; Instance *instance() { return instance_; } diff --git a/app/src/main/cpp/helper-types.h b/app/src/main/cpp/helper-types.h index a0be956c9..1f13c9494 100644 --- a/app/src/main/cpp/helper-types.h +++ b/app/src/main/cpp/helper-types.h @@ -8,6 +8,12 @@ #include #include #include +#include +#include +#include +#include + +#include class InputMethodStatus { public: @@ -83,4 +89,63 @@ class ActionEntity { } }; +class CandidateActionEntity { +public: + int id; + std::string text; + bool isSeparator; + std::string icon; + bool isCheckable; + bool isChecked; + + explicit CandidateActionEntity(const fcitx::CandidateAction &act) : + id(act.id()), + text(act.text()), + isSeparator(act.isSeparator()), + icon(act.icon()), + isCheckable(act.isCheckable()), + isChecked(act.isChecked()) {} +}; + +class CandidateEntity { +public: + std::string label; + std::string text; + std::string comment; + + explicit CandidateEntity(std::string label, std::string text, std::string comment) : + label(std::move(label)), + text(std::move(text)), + comment(std::move(comment)) {} +}; + +class PagedCandidateEntity { +public: + std::vector candidates; + int cursorIndex; + fcitx::CandidateLayoutHint layoutHint; + bool hasPrev; + bool hasNext; + + explicit PagedCandidateEntity(std::vector candidates, + int cursorIndex, + fcitx::CandidateLayoutHint layoutHint, + bool hasPrev, + bool hasNext) : + candidates(std::move(candidates)), + cursorIndex(cursorIndex), + layoutHint(layoutHint), + hasPrev(hasPrev), + hasNext(hasNext) {} + + static PagedCandidateEntity Empty; + +private: + PagedCandidateEntity() : + candidates({}), cursorIndex(-1), layoutHint(fcitx::CandidateLayoutHint::NotSet), + hasPrev(false), hasNext(false) {} +}; + +PagedCandidateEntity PagedCandidateEntity::Empty = PagedCandidateEntity(); + #endif //FCITX5_ANDROID_HELPER_TYPES_H diff --git a/app/src/main/cpp/jni-utils.h b/app/src/main/cpp/jni-utils.h index 1e8bec212..6807b531d 100644 --- a/app/src/main/cpp/jni-utils.h +++ b/app/src/main/cpp/jni-utils.h @@ -77,10 +77,10 @@ class JString { class JEnv { private: - JNIEnv *env; + JNIEnv *env = nullptr; public: - JEnv(JavaVM *jvm) { + explicit JEnv(JavaVM *jvm) { if (jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) == JNI_EDETACHED) { jvm->AttachCurrentThread(&env, nullptr); } @@ -138,7 +138,13 @@ class GlobalRefSingleton { jfieldID PinyinCustomPhraseOrder; jfieldID PinyinCustomPhraseValue; - GlobalRefSingleton(JavaVM *jvm_) : jvm(jvm_) { + jclass CandidateAction; + jmethodID CandidateActionInit; + + jclass Candidate; + jmethodID CandidateInit; + + explicit GlobalRefSingleton(JavaVM *jvm_) : jvm(jvm_) { JNIEnv *env; jvm->AttachCurrentThread(&env, nullptr); @@ -184,9 +190,15 @@ class GlobalRefSingleton { PinyinCustomPhraseKey = env->GetFieldID(PinyinCustomPhrase, "key", "Ljava/lang/String;"); PinyinCustomPhraseOrder = env->GetFieldID(PinyinCustomPhrase, "order", "I"); PinyinCustomPhraseValue = env->GetFieldID(PinyinCustomPhrase, "value", "Ljava/lang/String;"); + + CandidateAction = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/CandidateAction"))); + CandidateActionInit = env->GetMethodID(CandidateAction, "", "(ILjava/lang/String;ZLjava/lang/String;ZZ)V"); + + Candidate = reinterpret_cast(env->NewGlobalRef(env->FindClass("org/fcitx/fcitx5/android/core/FcitxEvent$Candidate"))); + CandidateInit = env->GetMethodID(Candidate, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); } - const JEnv AttachEnv() const { return JEnv(jvm); } + [[nodiscard]] JEnv AttachEnv() const { return JEnv(jvm); } }; extern GlobalRefSingleton *GlobalRef; diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index ca4be07ac..bdeb17191 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ #include @@ -41,7 +41,6 @@ #include "customphrase.h" #include "androidfrontend/androidfrontend_public.h" -#include "androidaddonloader/androidaddonloader.h" #include "jni-utils.h" #include "nativestreambuf.h" #include "helper-types.h" @@ -81,10 +80,9 @@ class Fcitx { return uv_run(get_event_base(), UV_RUN_ONCE); } - void startup(fcitx::AndroidLibraryDependency dependency, - const std::function &setupCallback) { + void startup(const std::function &setupCallback) { p_instance = std::make_unique(0, nullptr); - p_instance->addonManager().registerLoader(std::make_unique(dependency)); + p_instance->addonManager().registerDefaultLoader(nullptr); p_dispatcher = std::make_unique(); p_dispatcher->attach(&p_instance->eventLoop()); p_instance->initialize(); @@ -120,10 +118,6 @@ class Fcitx { return p_frontend->call(idx); } - bool forgetCandidate(int idx) { - return p_frontend->call(idx); - } - bool isInputPanelEmpty() { return p_frontend->call(); } @@ -419,6 +413,26 @@ class Fcitx { return p_frontend->call(offset, limit); } + std::vector getCandidateActions(int idx) { + auto actions = std::vector(); + for (const auto &a: p_frontend->call(idx)) { + actions.emplace_back(a); + } + return actions; + } + + void triggerCandidateAction(int idx, int actionIdx) { + return p_frontend->call(idx, actionIdx); + } + + void setCandidatePagingMode(int mode) { + return p_frontend->call(mode); + } + + void offsetCandidatePage(int delta) { + return p_frontend->call(delta); + } + void save() { p_instance->save(); } @@ -495,9 +509,7 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( jstring appLib, jstring extData, jstring extCache, - jobjectArray extDomains, - jobjectArray libraryNames, - jobjectArray libraryDependencies) { + jobjectArray extDomains) { if (Fcitx::Instance().isRunning()) { FCITX_ERROR() << "Fcitx is already running!"; return; @@ -571,21 +583,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( fcitx::registerDomain(CString(env, domain), locale_dir_char); } - std::unordered_map> depsMap; - const int librarySize = env->GetArrayLength(libraryNames); - for (int i = 0; i < librarySize; i++) { - auto jstringName = JRef(env, env->GetObjectArrayElement(libraryNames, i)); - auto lib = CString(env, jstringName); - auto jobjectArrayDeps = JRef(env, env->GetObjectArrayElement(libraryDependencies, i)); - const int depSize = env->GetArrayLength(jobjectArrayDeps); - std::unordered_set depSet(depSize); - for (int j = 0; j < depSize; j++) { - auto jstringDepName = JRef(env, env->GetObjectArrayElement(jobjectArrayDeps, j)); - depSet.emplace(CString(env, jstringDepName)); - } - depsMap.emplace(lib, depSet); - } - auto candidateListCallback = [](const std::vector &candidates, const int size) { auto env = GlobalRef->AttachEnv(); auto candidatesArray = JRef(env, env->NewObjectArray(static_cast(candidates.size()), GlobalRef->String, nullptr)); @@ -637,15 +634,17 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 5, *vararg); }; auto imChangeCallback = []() { - auto env = GlobalRef->AttachEnv(); - auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->Object, nullptr)); std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); if (!status) return; + auto env = GlobalRef->AttachEnv(); + auto vararg = JRef(env, env->NewObjectArray(1, GlobalRef->Object, nullptr)); auto obj = JRef(env, fcitxInputMethodStatusToJObject(env, *status)); env->SetObjectArrayElement(vararg, 0, obj); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 6, *vararg); }; auto statusAreaUpdateCallback = []() { + std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); + if (!status) return; auto env = GlobalRef->AttachEnv(); auto vararg = JRef(env, env->NewObjectArray(static_cast(2), GlobalRef->Object, nullptr)); const auto actions = Fcitx::Instance().statusAreaActions(); @@ -656,7 +655,6 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( env->SetObjectArrayElement(actionArray, i++, obj); } env->SetObjectArrayElement(vararg, 0, actionArray); - std::unique_ptr status = Fcitx::Instance().inputMethodStatus(); auto statusObj = JRef(env, fcitxInputMethodStatusToJObject(env, *status)); env->SetObjectArrayElement(vararg, 1, statusObj); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 7, *vararg); @@ -670,15 +668,39 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( env->SetObjectArrayElement(vararg, 0, intArray); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 8, *vararg); }; + auto pagedCandidateCallback = [](const PagedCandidateEntity &paged) { + auto env = GlobalRef->AttachEnv(); + const int size = static_cast(paged.candidates.size()); + if (size == 0) { + auto vararg = JRef(env, env->NewObjectArray(0, GlobalRef->Object, nullptr)); + env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 9, *vararg); + return; + } + auto candidatesArray = JRef(env, env->NewObjectArray(size, GlobalRef->Candidate, nullptr)); + for (int i = 0; i < size; ++i) { + env->SetObjectArrayElement(candidatesArray, i, candidateEntityToObject(env, paged.candidates[i])); + } + auto cursorIndex = JRef(env, env->NewObject(GlobalRef->Integer, GlobalRef->IntegerInit, paged.cursorIndex)); + auto layoutHint = JRef(env, env->NewObject(GlobalRef->Integer, GlobalRef->IntegerInit, static_cast(paged.layoutHint))); + auto hasPrev = JRef(env, env->NewObject(GlobalRef->Boolean, GlobalRef->BooleanInit, paged.hasPrev)); + auto hasNext = JRef(env, env->NewObject(GlobalRef->Boolean, GlobalRef->BooleanInit, paged.hasNext)); + auto vararg = JRef(env, env->NewObjectArray(5, GlobalRef->Object, nullptr)); + env->SetObjectArrayElement(vararg, 0, candidatesArray); + env->SetObjectArrayElement(vararg, 1, cursorIndex); + env->SetObjectArrayElement(vararg, 2, layoutHint); + env->SetObjectArrayElement(vararg, 3, hasPrev); + env->SetObjectArrayElement(vararg, 4, hasNext); + env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->HandleFcitxEvent, 9, *vararg); + }; auto toastCallback = [](const std::string &s) { auto env = GlobalRef->AttachEnv(); env->CallStaticVoidMethod(GlobalRef->Fcitx, GlobalRef->ShowToast, *JString(env, s)); }; umask(007); - fcitx::StandardPath::global().syncUmask(); + fcitx::StandardPaths::global().syncUmask(); - Fcitx::Instance().startup(depsMap, [&](auto *androidfrontend) { + Fcitx::Instance().startup([&](auto *androidfrontend) { FCITX_INFO() << "Setting up callback"; readyCallback(); androidfrontend->template call(candidateListCallback); @@ -689,6 +711,7 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_startupFcitx( androidfrontend->template call(imChangeCallback); androidfrontend->template call(statusAreaUpdateCallback); androidfrontend->template call(deleteSurroundingCallback); + androidfrontend->template call(pagedCandidateCallback); androidfrontend->template call(toastCallback); }); FCITX_INFO() << "Finishing startup"; @@ -724,28 +747,31 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_reloadFcitxConfig(JNIEnv *env, jclass c extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxString(JNIEnv *env, jclass clazz, jstring key, jint state, jboolean up, jint timestamp) { +Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxString(JNIEnv *env, jclass clazz, jstring key, jint state, jint code, jboolean up, jint timestamp) { RETURN_IF_NOT_RUNNING fcitx::Key parsedKey{fcitx::Key::keySymFromString(CString(env, key)), - fcitx::KeyStates(static_cast(state))}; + fcitx::KeyStates(static_cast(state)), + code + /* evdev offset */ 8}; Fcitx::Instance().sendKey(parsedKey, up, timestamp); } extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxChar(JNIEnv *env, jclass clazz, jchar c, jint state, jboolean up, jint timestamp) { +Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeyToFcitxChar(JNIEnv *env, jclass clazz, jchar c, jint state, jint code, jboolean up, jint timestamp) { RETURN_IF_NOT_RUNNING const fcitx::Key parsedKey{fcitx::Key::keySymFromString(reinterpret_cast(&c)), - fcitx::KeyStates(static_cast(state))}; + fcitx::KeyStates(static_cast(state)), + code + /* evdev offset */ 8}; Fcitx::Instance().sendKey(parsedKey, up, timestamp); } extern "C" JNIEXPORT void JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeySymToFcitx(JNIEnv *env, jclass clazz, jint sym, jint state, jboolean up, jint timestamp) { +Java_org_fcitx_fcitx5_android_core_Fcitx_sendKeySymToFcitx(JNIEnv *env, jclass clazz, jint sym, jint state, jint code, jboolean up, jint timestamp) { RETURN_IF_NOT_RUNNING fcitx::Key key{fcitx::KeySym(static_cast(sym)), - fcitx::KeyStates(static_cast(state))}; + fcitx::KeyStates(static_cast(state)), + code + /* evdev offset */ 8}; Fcitx::Instance().sendKey(key, up, timestamp); } @@ -753,18 +779,9 @@ extern "C" JNIEXPORT jboolean JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_selectCandidate(JNIEnv *env, jclass clazz, jint idx) { RETURN_VALUE_IF_NOT_RUNNING(false) - FCITX_DEBUG() << "selectCandidate: #" << idx; return Fcitx::Instance().select(idx); } -extern "C" -JNIEXPORT jboolean JNICALL -Java_org_fcitx_fcitx5_android_core_Fcitx_forgetCandidate(JNIEnv *env, jclass clazz, jint idx) { - RETURN_VALUE_IF_NOT_RUNNING(false) - FCITX_DEBUG() << "forgetCandidate: #" << idx; - return Fcitx::Instance().forgetCandidate(idx); -} - extern "C" JNIEXPORT jboolean JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_isInputPanelEmpty(JNIEnv *env, jclass clazz) { @@ -1027,6 +1044,41 @@ Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxCandidates(JNIEnv *env, jclass return array; } +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_getFcitxCandidateActions(JNIEnv *env, jclass clazz, jint idx) { + RETURN_VALUE_IF_NOT_RUNNING(nullptr) + auto actions = Fcitx::Instance().getCandidateActions(idx); + int size = static_cast(actions.size()); + jobjectArray array = env->NewObjectArray(size, GlobalRef->CandidateAction, nullptr); + for (int i = 0; i < size; i++) { + auto obj = JRef(env, fcitxCandidateActionToObject(env, actions[i])); + env->SetObjectArrayElement(array, i, obj); + } + return array; +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_triggerFcitxCandidateAction(JNIEnv *env, jclass clazz, jint idx, jint action_idx) { + RETURN_IF_NOT_RUNNING + Fcitx::Instance().triggerCandidateAction(idx, action_idx); +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_setFcitxCandidatePagingMode(JNIEnv *env, jclass clazz, jint mode) { + RETURN_IF_NOT_RUNNING + Fcitx::Instance().setCandidatePagingMode(mode); +} + +extern "C" +JNIEXPORT void JNICALL +Java_org_fcitx_fcitx5_android_core_Fcitx_offsetFcitxCandidatePage(JNIEnv *env, jclass clazz, jint delta) { + RETURN_IF_NOT_RUNNING + Fcitx::Instance().offsetCandidatePage(delta); +} + extern "C" JNIEXPORT void JNICALL Java_org_fcitx_fcitx5_android_core_Fcitx_loopOnce(JNIEnv *env, jclass clazz) { @@ -1120,7 +1172,7 @@ Java_org_fcitx_fcitx5_android_data_table_TableManager_checkTableDictFormat(JNIEn extern "C" JNIEXPORT jobjectArray JNICALL Java_org_fcitx_fcitx5_android_data_pinyin_CustomPhraseManager_load(JNIEnv *env, jclass clazz) { - auto fp = fcitx::StandardPath::global().open(fcitx::StandardPath::Type::PkgData, "pinyin/customphrase", O_RDONLY); + auto fp = fcitx::StandardPaths::global().open(fcitx::StandardPathsType::PkgData, "pinyin/customphrase"); if (fp.fd() < 0) { FCITX_INFO() << "cannot open pinyin/customphrase"; return nullptr; @@ -1166,8 +1218,8 @@ Java_org_fcitx_fcitx5_android_data_pinyin_CustomPhraseManager_save(JNIEnv *env, *CString(env, phraseValue), static_cast(phraseOrder)); } - fcitx::StandardPath::global().safeSave( - fcitx::StandardPath::Type::PkgData, "pinyin/customphrase", + fcitx::StandardPaths::global().safeSave( + fcitx::StandardPathsType::PkgData, "pinyin/customphrase", [&](int fd) { boost::iostreams::stream_buffer buffer(fd, boost::iostreams::file_descriptor_flags::never_close_handle); diff --git a/app/src/main/cpp/object-conversion.h b/app/src/main/cpp/object-conversion.h index 45c2e2306..aa4155095 100644 --- a/app/src/main/cpp/object-conversion.h +++ b/app/src/main/cpp/object-conversion.h @@ -161,4 +161,25 @@ jobject fcitxTextToJObject(JNIEnv *env, const fcitx::Text &text) { return obj; } +jobject fcitxCandidateActionToObject(JNIEnv *env, const CandidateActionEntity &act) { + auto obj = env->NewObject(GlobalRef->CandidateAction, GlobalRef->CandidateActionInit, + act.id, + *JString(env, act.text), + act.isSeparator, + *JString(env, act.icon), + act.isCheckable, + act.isChecked + ); + return obj; +} + +jobject candidateEntityToObject(JNIEnv *env, const CandidateEntity &c) { + auto obj = env->NewObject(GlobalRef->Candidate, GlobalRef->CandidateInit, + *JString(env, c.label), + *JString(env, c.text), + *JString(env, c.comment) + ); + return obj; +} + #endif //FCITX5_ANDROID_OBJECT_CONVERSION_H diff --git a/app/src/main/cpp/po/de.po b/app/src/main/cpp/po/de.po index b04353cf8..e9737c8d4 100644 --- a/app/src/main/cpp/po/de.po +++ b/app/src/main/cpp/po/de.po @@ -9,7 +9,7 @@ msgstr "" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" "Last-Translator: Ettore Atalan , 2022\n" -"Language-Team: German (https://www.transifex.com/fcitx/teams/12005/de/)\n" +"Language-Team: German (https://app.transifex.com/fcitx/teams/12005/de/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -28,6 +28,12 @@ msgstr "Worthinweis" msgid "Enable word hint" msgstr "Worthinweis aktivieren" +msgid "Enable word hint when using physical keyboard" +msgstr "" + +msgid "Disable word hint based on editor attributes" +msgstr "" + msgid "Word hint page size" msgstr "Seitengröße des Worthinweises" @@ -36,3 +42,9 @@ msgstr "" msgid "Insert space between words" msgstr "" + +msgid "Android Toast & Notification" +msgstr "" + +msgid "Hidden Notifications" +msgstr "" diff --git a/app/src/main/cpp/po/es.po b/app/src/main/cpp/po/es.po index 71d03f71f..d78fede55 100644 --- a/app/src/main/cpp/po/es.po +++ b/app/src/main/cpp/po/es.po @@ -28,6 +28,12 @@ msgstr "" msgid "Enable word hint" msgstr "" +msgid "Enable word hint when using physical keyboard" +msgstr "" + +msgid "Disable word hint based on editor attributes" +msgstr "" + msgid "Word hint page size" msgstr "" @@ -36,3 +42,9 @@ msgstr "" msgid "Insert space between words" msgstr "Insertar espacio entre palabras" + +msgid "Android Toast & Notification" +msgstr "" + +msgid "Hidden Notifications" +msgstr "" diff --git a/app/src/main/cpp/po/fcitx5-android.pot b/app/src/main/cpp/po/fcitx5-android.pot index 31396fa35..0efb63065 100644 --- a/app/src/main/cpp/po/fcitx5-android.pot +++ b/app/src/main/cpp/po/fcitx5-android.pot @@ -23,6 +23,9 @@ msgstr "" msgid "Enable word hint" msgstr "" +msgid "Enable word hint when using physical keyboard" +msgstr "" + msgid "Disable word hint based on editor attributes" msgstr "" diff --git a/app/src/main/cpp/po/ja.po b/app/src/main/cpp/po/ja.po index a61405cf3..d612fdf36 100644 --- a/app/src/main/cpp/po/ja.po +++ b/app/src/main/cpp/po/ja.po @@ -29,6 +29,9 @@ msgstr "単語ヒント" msgid "Enable word hint" msgstr "単語ヒントを有効にする" +msgid "Enable word hint when using physical keyboard" +msgstr "" + msgid "Disable word hint based on editor attributes" msgstr "" diff --git a/app/src/main/cpp/po/ru.po b/app/src/main/cpp/po/ru.po index 4120eda0b..9af294da3 100644 --- a/app/src/main/cpp/po/ru.po +++ b/app/src/main/cpp/po/ru.po @@ -29,6 +29,9 @@ msgstr "Подсказка слова" msgid "Enable word hint" msgstr "Включить подсказку слова" +msgid "Enable word hint when using physical keyboard" +msgstr "Включить подсказки слов при использовании физической клавиатуры" + msgid "Disable word hint based on editor attributes" msgstr "Отключить подсказки слов в зависимости от свойств редактора" diff --git a/app/src/main/cpp/po/zh_CN.po b/app/src/main/cpp/po/zh_CN.po index c86f46a54..5be82e30c 100644 --- a/app/src/main/cpp/po/zh_CN.po +++ b/app/src/main/cpp/po/zh_CN.po @@ -2,6 +2,7 @@ # Translators: # Potato Hatsue, 2022 # rocka, 2024 +# Yiyu Liu, 2024 # msgid "" msgstr "" @@ -9,7 +10,7 @@ msgstr "" "Report-Msgid-Bugs-To: https://github.com/fcitx5-android/fcitx5-android/issues\n" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" -"Last-Translator: rocka, 2024\n" +"Last-Translator: Yiyu Liu, 2024\n" "Language-Team: Chinese (China) (https://app.transifex.com/fcitx/teams/12005/zh_CN/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -29,6 +30,9 @@ msgstr "单词提示" msgid "Enable word hint" msgstr "启用单词提示" +msgid "Enable word hint when using physical keyboard" +msgstr "在使用物理键盘时启用单词提示" + msgid "Disable word hint based on editor attributes" msgstr "根据编辑器属性禁用单词提示" diff --git a/app/src/main/cpp/po/zh_TW.po b/app/src/main/cpp/po/zh_TW.po index ad28b50a8..36cd22166 100644 --- a/app/src/main/cpp/po/zh_TW.po +++ b/app/src/main/cpp/po/zh_TW.po @@ -2,8 +2,7 @@ # Translators: # 黃柏諺 , 2022 # Jia-Bin, 2022 -# rocka, 2022 -# Lau YeeYu, 2024 +# Yiyu Liu, 2024 # msgid "" msgstr "" @@ -11,7 +10,7 @@ msgstr "" "Report-Msgid-Bugs-To: https://github.com/fcitx5-android/fcitx5-android/issues\n" "POT-Creation-Date: 2022-02-14 18:51+0800\n" "PO-Revision-Date: 2022-03-18 22:18+0000\n" -"Last-Translator: Lau YeeYu, 2024\n" +"Last-Translator: Yiyu Liu, 2024\n" "Language-Team: Chinese (Taiwan) (https://app.transifex.com/fcitx/teams/12005/zh_TW/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -26,16 +25,19 @@ msgid "Android Keyboard" msgstr "Android 鍵盤" msgid "Word hint" -msgstr "單字提示" +msgstr "字詞提示" msgid "Enable word hint" -msgstr "啟用單字提示" +msgstr "啟用字詞提示" + +msgid "Enable word hint when using physical keyboard" +msgstr "在使用物理鍵盤時啟用字詞提示" msgid "Disable word hint based on editor attributes" msgstr "依據編輯器屬性禁用字詞提示" msgid "Word hint page size" -msgstr "單字提示頁大小" +msgstr "字詞提示頁大小" msgid "Choose key modifier" msgstr "選詞修飾鍵" diff --git a/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt b/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt index 31c354292..e52553b36 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/FcitxApplication.kt @@ -14,6 +14,7 @@ import android.content.res.Configuration import android.os.Build import android.os.Process import android.util.Log +import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.preference.PreferenceManager import kotlinx.coroutines.CoroutineName @@ -59,6 +60,18 @@ class FcitxApplication : Application() { } } + private val restartFcitxInstanceReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_RESTART_FCITX_INSTANCE) return + if (FcitxDaemon.getFirstConnectionOrNull() != null) { + Timber.i("Received broadcast '${intent.action}', try to restart fcitx instance ...") + FcitxDaemon.restartFcitx() + } else { + Timber.i("Received broadcast '${intent.action}', but there's no fcitx instance") + } + } + } + var isDirectBootMode = false private set @@ -140,12 +153,20 @@ class FcitxApplication : Application() { AppPrefs.getInstance().syncToDeviceEncryptedStorage() ThemeManager.syncToDeviceEncryptedStorage() } + ContextCompat.registerReceiver( + this, + restartFcitxInstanceReceiver, + IntentFilter(ACTION_RESTART_FCITX_INSTANCE), + PERMISSION_TEST_INPUT_METHOD, + null, + ContextCompat.RECEIVER_EXPORTED + ) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - ThemeManager.onSystemDarkModeChange(newConfig.isDarkMode()) - Locales.onLocaleChange(resources.configuration) + ThemeManager.onSystemPlatteChange(newConfig) + Locales.onLocaleChange(newConfig) } companion object { @@ -156,5 +177,21 @@ class FcitxApplication : Application() { fun getLastPid() = lastPid private const val MAX_STACKTRACE_SIZE = 128000 + + const val ACTION_RESTART_FCITX_INSTANCE = + "${BuildConfig.APPLICATION_ID}.action.RESTART_FCITX_INSTANCE" + + /** + * This permission is requested by com.android.shell, makes it possible to restart + * fcitx instance from `adb shell am` command: + * ```sh + * adb shell am broadcast -a org.fcitx.fcitx5.android.action.RESTART_FCITX_INSTANCE + * ``` + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-7.0.0_r1/packages/Shell/AndroidManifest.xml#67 + * + * other candidate: android.permission.TEST_INPUT_METHOD requires Android 14 + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/packages/Shell/AndroidManifest.xml#628 + */ + const val PERMISSION_TEST_INPUT_METHOD = "android.permission.READ_INPUT_STATE" } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt index 800ede315..098f34c06 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/CapabilityFlag.kt @@ -148,6 +148,7 @@ value class CapabilityFlags constructor(val flags: ULong) { } if (equals(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) { flags += CapabilityFlag.Sensitive + flags += CapabilityFlag.NoSpellCheck } if (equals(InputType.TYPE_TEXT_VARIATION_URI)) { flags += CapabilityFlag.Url diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt index 8a16264f8..54aa7a084 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/Fcitx.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core @@ -73,20 +73,31 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override suspend fun save() = withFcitxContext { saveFcitxState() } override suspend fun reloadConfig() = withFcitxContext { reloadFcitxConfig() } - override suspend fun sendKey(key: String, states: UInt, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeyToFcitxString(key, states.toInt(), up, timestamp) } - - override suspend fun sendKey(c: Char, states: UInt, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeyToFcitxChar(c, states.toInt(), up, timestamp) } - - override suspend fun sendKey(sym: Int, states: UInt, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeySymToFcitx(sym, states.toInt(), up, timestamp) } - - override suspend fun sendKey(sym: KeySym, states: KeyStates, up: Boolean, timestamp: Int) = - withFcitxContext { sendKeySymToFcitx(sym.sym, states.toInt(), up, timestamp) } + override suspend fun sendKey( + key: String, + states: UInt, + code: Int, + up: Boolean, + timestamp: Int + ) = + withFcitxContext { sendKeyToFcitxString(key, states.toInt(), code, up, timestamp) } + + override suspend fun sendKey(c: Char, states: UInt, code: Int, up: Boolean, timestamp: Int) = + withFcitxContext { sendKeyToFcitxChar(c, states.toInt(), code, up, timestamp) } + + override suspend fun sendKey(sym: Int, states: UInt, code: Int, up: Boolean, timestamp: Int) = + withFcitxContext { sendKeySymToFcitx(sym, states.toInt(), code, up, timestamp) } + + override suspend fun sendKey( + sym: KeySym, + states: KeyStates, + code: Int, + up: Boolean, + timestamp: Int + ) = + withFcitxContext { sendKeySymToFcitx(sym.sym, states.toInt(), code, up, timestamp) } override suspend fun select(idx: Int): Boolean = withFcitxContext { selectCandidate(idx) } - override suspend fun forget(idx: Int): Boolean = withFcitxContext { forgetCandidate(idx) } override suspend fun isEmpty(): Boolean = withFcitxContext { isInputPanelEmpty() } override suspend fun reset() = withFcitxContext { resetInputContext() } override suspend fun moveCursor(position: Int) = withFcitxContext { repositionCursor(position) } @@ -164,6 +175,18 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { override suspend fun getCandidates(offset: Int, limit: Int): Array = withFcitxContext { getFcitxCandidates(offset, limit) ?: emptyArray() } + override suspend fun getCandidateActions(idx: Int): Array = + withFcitxContext { getFcitxCandidateActions(idx) ?: emptyArray() } + + override suspend fun triggerCandidateAction(idx: Int, actionIdx: Int) = + withFcitxContext { triggerFcitxCandidateAction(idx, actionIdx) } + + override suspend fun setCandidatePagingMode(mode: Int) = + withFcitxContext { setFcitxCandidatePagingMode(mode) } + + override suspend fun offsetCandidatePage(delta: Int) = + withFcitxContext { offsetFcitxCandidatePage(delta) } + init { if (lifecycle.currentState != FcitxLifecycle.State.STOPPED) throw IllegalAccessException("Fcitx5 has already been created!") @@ -209,9 +232,7 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { appLib: String, extData: String, extCache: String, - extDomains: Array, - libraryNames: Array, - libraryDependencies: Array> + extDomains: Array ) @JvmStatic @@ -227,20 +248,23 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { external fun reloadFcitxConfig() @JvmStatic - external fun sendKeyToFcitxString(key: String, state: Int, up: Boolean, timestamp: Int) + external fun sendKeyToFcitxString( + key: String, + state: Int, + code: Int, + up: Boolean, + timestamp: Int + ) @JvmStatic - external fun sendKeyToFcitxChar(c: Char, state: Int, up: Boolean, timestamp: Int) + external fun sendKeyToFcitxChar(c: Char, state: Int, code: Int, up: Boolean, timestamp: Int) @JvmStatic - external fun sendKeySymToFcitx(sym: Int, state: Int, up: Boolean, timestamp: Int) + external fun sendKeySymToFcitx(sym: Int, state: Int, code: Int, up: Boolean, timestamp: Int) @JvmStatic external fun selectCandidate(idx: Int): Boolean - @JvmStatic - external fun forgetCandidate(idx: Int): Boolean - @JvmStatic external fun isInputPanelEmpty(): Boolean @@ -331,6 +355,18 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { @JvmStatic external fun getFcitxCandidates(offset: Int, limit: Int): Array? + @JvmStatic + external fun getFcitxCandidateActions(idx: Int): Array? + + @JvmStatic + external fun triggerFcitxCandidateAction(idx: Int, actionIdx: Int) + + @JvmStatic + external fun setFcitxCandidatePagingMode(mode: Int) + + @JvmStatic + external fun offsetFcitxCandidatePage(delta: Int) + @JvmStatic external fun loopOnce() @@ -386,18 +422,14 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { val plugins = DataManager.getLoadedPlugins() val nativeLibDir = StringBuilder(context.applicationInfo.nativeLibraryDir) val extDomains = arrayListOf() - val libraryNames = arrayListOf() - val libraryDependency = arrayListOf>() plugins.forEach { - nativeLibDir.append(':') - nativeLibDir.append(it.nativeLibraryDir) + if (it.nativeLibraryDir.isNotBlank()) { + nativeLibDir.append(':') + nativeLibDir.append(it.nativeLibraryDir) + } it.domain?.let { d -> extDomains.add(d) } - it.libraryDependency.forEach { (lib, dep) -> - libraryNames.add(lib) - libraryDependency.add(dep.toTypedArray()) - } } Timber.d( """ @@ -415,9 +447,7 @@ class Fcitx(private val context: Context) : FcitxAPI, FcitxLifecycleOwner { nativeLibDir.toString(), (getExternalFilesDir(null) ?: filesDir).absolutePath, (externalCacheDir ?: cacheDir).absolutePath, - extDomains.toTypedArray(), - libraryNames.toTypedArray(), - libraryDependency.toTypedArray() + extDomains.toTypedArray() ) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt index 16a832c43..b6fd8ce8e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxAPI.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.SharedFlow */ interface FcitxAPI { - enum class AddonDep { Required, Optional @@ -43,16 +42,15 @@ interface FcitxAPI { suspend fun reloadConfig() - suspend fun sendKey(key: String, states: UInt = 0u, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(key: String, states: UInt = 0u, code: Int = 0, up: Boolean = false, timestamp: Int = -1) - suspend fun sendKey(c: Char, states: UInt = 0u, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(c: Char, states: UInt = 0u, code: Int = 0, up: Boolean = false, timestamp: Int = -1) - suspend fun sendKey(sym: Int, states: UInt = 0u, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(sym: Int, states: UInt = 0u, code: Int = 0, up: Boolean = false, timestamp: Int = -1) - suspend fun sendKey(sym: KeySym, states: KeyStates, up: Boolean = false, timestamp: Int = -1) + suspend fun sendKey(sym: KeySym, states: KeyStates, code: Int = 0, up: Boolean = false, timestamp: Int = -1) suspend fun select(idx: Int): Boolean - suspend fun forget(idx: Int): Boolean suspend fun isEmpty(): Boolean suspend fun reset() suspend fun moveCursor(position: Int) @@ -101,4 +99,10 @@ interface FcitxAPI { suspend fun getCandidates(offset: Int, limit: Int): Array + suspend fun getCandidateActions(idx: Int): Array + suspend fun triggerCandidateAction(idx: Int, actionIdx: Int) + + suspend fun setCandidatePagingMode(mode: Int) + suspend fun offsetCandidatePage(delta: Int) + } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt index 33e0025e4..c596c8117 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxDispatcher.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core @@ -21,23 +21,18 @@ import kotlin.coroutines.CoroutineContext class FcitxDispatcher(private val controller: FcitxController) : CoroutineDispatcher() { - class WrappedRunnable(private val runnable: Runnable, private val name: String? = null) : - Runnable by runnable { + class WrappedRunnable(private val runnable: Runnable) : Runnable by runnable { private val time = System.currentTimeMillis() - var started = false - private set - - private val delta - get() = System.currentTimeMillis() - time override fun run() { - if (delta > JOB_WAITING_LIMIT) - Timber.w("${toString()} has waited $delta ms to get run since created!") - started = true + val delta = System.currentTimeMillis() - time + if (delta > JOB_WAITING_LIMIT) { + Timber.w("$this has waited $delta ms to get run since created!") + } runnable.run() } - override fun toString(): String = "WrappedRunnable[${name ?: hashCode()}]" + override fun toString(): String = "WrappedRunnable[${hashCode()}]" } // this is fcitx main thread diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt index 3172ba6f1..1c119fab2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/FcitxEvent.kt @@ -6,6 +6,8 @@ package org.fcitx.fcitx5.android.core sealed class FcitxEvent(open val data: T) { + data class Candidate(val label: String, val text: String, val comment: String) + abstract val eventType: EventType data class CandidateListEvent(override val data: Data) : @@ -13,7 +15,7 @@ sealed class FcitxEvent(open val data: T) { override val eventType = EventType.Candidate - data class Data(val total: Int, val candidates: Array) { + data class Data(val total: Int = -1, val candidates: Array = emptyArray()) { override fun toString(): String = "total=$total, candidates=[${candidates.joinToString(limit = 5)}]" @@ -121,11 +123,63 @@ sealed class FcitxEvent(open val data: T) { data class DeleteSurroundingEvent(override val data: Data) : FcitxEvent(data) { - override val eventType = EventType.DeleteSurrounding + override val eventType = EventType.DeleteSurrounding data class Data(val before: Int, val after: Int) } + data class PagedCandidateEvent(override val data: Data) : + FcitxEvent(data) { + + override val eventType = EventType.PagedCandidate + + enum class LayoutHint(value: Int) { + NotSet(0), Vertical(1), Horizontal(2); + + companion object { + private val Types = entries.toTypedArray() + fun of(value: Int) = Types[value] + } + } + + data class Data( + val candidates: Array, + val cursorIndex: Int, + val layoutHint: LayoutHint, + val hasPrev: Boolean, + val hasNext: Boolean + ) { + companion object { + @Suppress("BooleanLiteralArgument") + val Empty = Data(emptyArray(), -1, LayoutHint.NotSet, false, false) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Data + + if (!candidates.contentEquals(other.candidates)) return false + if (cursorIndex != other.cursorIndex) return false + if (layoutHint != other.layoutHint) return false + if (hasPrev != other.hasPrev) return false + if (hasNext != other.hasNext) return false + + return true + } + + override fun hashCode(): Int { + var result = candidates.contentHashCode() + result = 31 * result + cursorIndex + result = 31 * result + layoutHint.hashCode() + result = 31 * result + hasPrev.hashCode() + result = 31 * result + hasNext.hashCode() + return result + } + } + } + data class UnknownEvent(override val data: Array) : FcitxEvent>(data) { override val eventType = EventType.Unknown @@ -156,12 +210,13 @@ sealed class FcitxEvent(open val data: T) { Change, StatusArea, DeleteSurrounding, + PagedCandidate, Unknown } companion object { - private val Types = EventType.values() + private val Types = EventType.entries.toTypedArray() @Suppress("UNCHECKED_CAST") fun create(type: Int, params: Array) = @@ -206,6 +261,19 @@ sealed class FcitxEvent(open val data: T) { EventType.DeleteSurrounding -> (params[0] as IntArray).let { DeleteSurroundingEvent(DeleteSurroundingEvent.Data(it[0], it[1])) } + EventType.PagedCandidate -> if (params.isEmpty()) { + PagedCandidateEvent(PagedCandidateEvent.Data.Empty) + } else { + PagedCandidateEvent( + PagedCandidateEvent.Data( + params[0] as Array, + params[1] as Int, + PagedCandidateEvent.LayoutHint.of(params[2] as Int), + params[3] as Boolean, + params[4] as Boolean + ) + ) + } else -> UnknownEvent(params) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt index 4d4f2da3b..19ab51286 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/KeyState.kt @@ -92,6 +92,8 @@ value class KeyStates(val states: UInt) { companion object { val Empty = KeyStates(0u) + val Virtual = KeyStates(KeyState.Virtual) + fun of(v: Int) = KeyStates(v.toUInt()) fun fromKeyEvent(event: KeyEvent): KeyStates { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt index 00706ed2a..a07d60cbe 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/SubtypeManager.kt @@ -40,7 +40,6 @@ object SubtypeManager { .setSubtypeId(im.uniqueName.hashCode()) .setSubtypeExtraValue(im.uniqueName) .setSubtypeNameOverride(im.displayName) - .setSubtypeLocale(im.languageCode) .setSubtypeMode(MODE_KEYBOARD) .setIsAsciiCapable(im.uniqueName == IM_KEYBOARD) .build() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt index ac863b111..12911945b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/Types.kt @@ -1,11 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable data class InputMethodSubMode(val name: String, val label: String, val icon: String) { constructor() : this("", "", "") @@ -74,6 +75,7 @@ data class InputMethodEntry( } @Parcelize +@Serializable data class RawConfig( val name: String, val comment: String, @@ -158,6 +160,7 @@ data class AddonInfo( val dependencies: Array = arrayOf(), val optionalDependencies: Array = arrayOf(), ) { + @Suppress("UNUSED") // used in JNI constructor( uniqueName: String, name: String, @@ -260,3 +263,12 @@ data class Action( return result } } + +data class CandidateAction( + val id: Int, + val text: String, + val isSeparator: Boolean, + val icon: String, + val isCheckable: Boolean, + val isChecked: Boolean +) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt index fd19acf2c..f134cc767 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/DataManager.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core.data @@ -29,6 +29,11 @@ import kotlin.concurrent.withLock */ object DataManager { + data class PluginSet( + val loaded: Set, + val failed: Map + ) + const val PLUGIN_INTENT = "${BuildConfig.APPLICATION_ID}.plugin.MANIFEST" private val lock = ReentrantLock() @@ -69,6 +74,8 @@ object DataManager { fun getLoadedPlugins(): Set = loadedPlugins fun getFailedPlugins(): Map = failedPlugins + fun getSyncedPluginSet() = PluginSet(loadedPlugins, failedPlugins) + /** * Will be cleared after each sync */ @@ -77,7 +84,7 @@ object DataManager { fun addOnNextSyncedCallback(block: () -> Unit) = callbacks.add(block) - fun detectPlugins(): Pair, Map> { + fun detectPlugins(): PluginSet { val toLoad = mutableSetOf() val preloadFailed = mutableMapOf() @@ -113,36 +120,15 @@ object DataManager { var apiVersion: String? = null var description: String? = null var hasService = false - val libraryDependency = mutableMapOf>() - var library: String? = null - var dependency: ArrayList? = null var text: String? = null while ((eventType != XmlPullParser.END_DOCUMENT)) { when (eventType) { XmlPullParser.TEXT -> text = parser.text - XmlPullParser.START_TAG -> when (parser.name) { - "library" -> { - dependency = arrayListOf() - for (i in 0.. when (parser.name) { "apiVersion" -> apiVersion = text "domain" -> domain = text "description" -> description = text "hasService" -> hasService = text?.lowercase() == "true" - "dependency" -> dependency?.add(text!!) - "library" -> { - if (library != null && dependency != null) { - libraryDependency[library] = dependency - library = null - dependency = null - } - } } } eventType = parser.next() @@ -176,9 +162,8 @@ object DataManager { domain, description, hasService, - info.versionName, - info.applicationInfo.nativeLibraryDir, - libraryDependency + info.versionName ?: "", + info.applicationInfo?.nativeLibraryDir ?: "" ) ) } else { @@ -190,7 +175,7 @@ object DataManager { preloadFailed[packageName] = PluginLoadFailed.PluginDescriptorParseError } } - return toLoad to preloadFailed + return PluginSet(toLoad, preloadFailed) } fun sync() = lock.withLock { @@ -321,4 +306,4 @@ object DataManager { sync() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt index 4583e5886..cec9078ec 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/core/data/PluginDescriptor.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.core.data @@ -32,8 +32,7 @@ data class PluginDescriptor( */ val hasService: Boolean, val versionName: String, - val nativeLibraryDir: String, - val libraryDependency: Map> + val nativeLibraryDir: String ) { val name = packageName.removePrefix(pluginPackagePrefix).removeSuffix(pluginPackageSuffix) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt index f9d71acc6..fef6d17b1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/InputFeedbacks.kt @@ -10,56 +10,56 @@ import android.os.VibrationEffect import android.provider.Settings import android.view.HapticFeedbackConstants import android.view.View +import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.audioManager -import org.fcitx.fcitx5.android.utils.isSystemSettingEnabled +import org.fcitx.fcitx5.android.utils.getSystemSettings import org.fcitx.fcitx5.android.utils.vibrator object InputFeedbacks { - enum class InputFeedbackMode { - Enabled, Disabled, FollowingSystem; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String) = InputFeedbackMode.valueOf(raw) - } + enum class InputFeedbackMode(override val stringRes: Int) : ManagedPreferenceEnum { + FollowingSystem(R.string.following_system_settings), + Enabled(R.string.enabled), + Disabled(R.string.disabled); } private var systemSoundEffects = false private var systemHapticFeedback = false fun syncSystemPrefs() { - systemSoundEffects = isSystemSettingEnabled(Settings.System.SOUND_EFFECTS_ENABLED) + systemSoundEffects = getSystemSettings(Settings.System.SOUND_EFFECTS_ENABLED) == 1 // it says "Replaced by using android.os.VibrationAttributes.USAGE_TOUCH" // but gives no clue about how to use it, and this one still works @Suppress("DEPRECATION") - systemHapticFeedback = isSystemSettingEnabled(Settings.System.HAPTIC_FEEDBACK_ENABLED) + systemHapticFeedback = getSystemSettings(Settings.System.HAPTIC_FEEDBACK_ENABLED) == 1 } - private val soundOnKeyPress by AppPrefs.getInstance().keyboard.soundOnKeyPress - private val soundOnKeyPressVolume by AppPrefs.getInstance().keyboard.soundOnKeyPressVolume - private val hapticOnKeyPress by AppPrefs.getInstance().keyboard.hapticOnKeyPress - private val buttonPressVibrationMilliseconds by AppPrefs.getInstance().keyboard.buttonPressVibrationMilliseconds - private val buttonLongPressVibrationMilliseconds by AppPrefs.getInstance().keyboard.buttonLongPressVibrationMilliseconds - private val buttonPressVibrationAmplitude by AppPrefs.getInstance().keyboard.buttonPressVibrationAmplitude - private val buttonLongPressVibrationAmplitude by AppPrefs.getInstance().keyboard.buttonLongPressVibrationAmplitude + private val keyboardPrefs = AppPrefs.getInstance().keyboard + + private val soundOnKeyPress by keyboardPrefs.soundOnKeyPress + private val soundOnKeyPressVolume by keyboardPrefs.soundOnKeyPressVolume + private val hapticOnKeyPress by keyboardPrefs.hapticOnKeyPress + private val hapticOnKeyUp by keyboardPrefs.hapticOnKeyUp + private val buttonPressVibrationMilliseconds by keyboardPrefs.buttonPressVibrationMilliseconds + private val buttonLongPressVibrationMilliseconds by keyboardPrefs.buttonLongPressVibrationMilliseconds + private val buttonPressVibrationAmplitude by keyboardPrefs.buttonPressVibrationAmplitude + private val buttonLongPressVibrationAmplitude by keyboardPrefs.buttonLongPressVibrationAmplitude private val vibrator = appContext.vibrator private val hasAmplitudeControl = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) && vibrator.hasAmplitudeControl() - private val audioManager = appContext.audioManager - - fun hapticFeedback(view: View, longPress: Boolean = false) { + fun hapticFeedback(view: View, longPress: Boolean = false, keyUp: Boolean = false) { when (hapticOnKeyPress) { InputFeedbackMode.Enabled -> {} InputFeedbackMode.Disabled -> return InputFeedbackMode.FollowingSystem -> if (!systemHapticFeedback) return } - + if (keyUp && !hapticOnKeyUp) return val duration: Long val amplitude: Int val hfc: Int @@ -70,25 +70,36 @@ object InputFeedbacks { } else { duration = buttonPressVibrationMilliseconds.toLong() amplitude = buttonPressVibrationAmplitude - hfc = HapticFeedbackConstants.KEYBOARD_TAP + hfc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && keyUp) { + HapticFeedbackConstants.KEYBOARD_RELEASE + } else { + HapticFeedbackConstants.KEYBOARD_TAP + } } - val useVibrator = duration != 0L - if (useVibrator) { + // there is `VibrationEffect.DEFAULT_AMPLITUDE` but no default duration; + // also `VibrationEffect.createOneShot()` only accepts positive duration. + // so changing amplitude without changing duration makes no sense + if (duration != 0L) { // on Android 13, if system haptic feedback was disabled, `vibrator.vibrate()` won't work // but `view.performHapticFeedback()` with `FLAG_IGNORE_GLOBAL_SETTING` still works if (hasAmplitudeControl && amplitude != 0) { vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude)) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ve = VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(ve) } else { @Suppress("DEPRECATION") vibrator.vibrate(duration) } } else { - // it says "Starting TIRAMISU only privileged apps can ignore user settings for touch feedback" - // but we still seem to be able to use `FLAG_IGNORE_GLOBAL_SETTING` - @Suppress("DEPRECATION") - val flags = - HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + var flags = HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING + if (hapticOnKeyPress == InputFeedbackMode.Enabled) { + // it says "Starting TIRAMISU only privileged apps can ignore user settings for touch feedback" + // but we still seem to be able to use `FLAG_IGNORE_GLOBAL_SETTING` + @Suppress("DEPRECATION") + flags = flags or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + } view.performHapticFeedback(hfc, flags) } } @@ -97,6 +108,8 @@ object InputFeedbacks { Standard, SpaceBar, Delete, Return } + private val audioManager = appContext.audioManager + fun soundEffect(effect: SoundEffect) { when (soundOnKeyPress) { InputFeedbackMode.Enabled -> {} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt index 9afc698aa..deeadf0db 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/RecentlyUsed.kt @@ -1,43 +1,76 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.data +import android.content.Context +import androidx.core.content.edit +import kotlinx.serialization.json.Json import org.fcitx.fcitx5.android.FcitxApplication +import timber.log.Timber -// Not thread-safe -class RecentlyUsed( - val fileName: String, - val capacity: Int -) : LinkedHashMap(0, .75f, true) { +class RecentlyUsed(val type: String, val limit: Int) { companion object { + // for backwords compatibility only const val DIR_NAME = "recently_used" + const val PREFERENCE_NAME = "picker_recently_used" } - private val file = - FcitxApplication.getInstance().directBootAwareContext.filesDir.resolve(DIR_NAME).run { - mkdirs() - resolve(fileName).apply { createNewFile() } - } + private val sharedPreferences = FcitxApplication.getInstance().directBootAwareContext + .getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) - fun load() { - val xs = file.readLines() - xs.forEach { - if (it.isNotBlank()) - put(it, it) - } + private val map = LinkedHashMap(limit).apply { + (migrate() ?: load()).forEach { put(it, true) } } - fun save() { - file.writeText(values.joinToString("\n")) + val items: List get() = map.keys.reversed() + + private fun load(): List { + val rawValue = sharedPreferences.getString(type, "") ?: "" + if (rawValue.isEmpty()) { + return emptyList() + } + return try { + Json.decodeFromString>(rawValue) + } catch (_: Exception) { + sharedPreferences.edit { + remove(type) + } + emptyList() + } } - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = - size > capacity + private fun save() { + sharedPreferences.edit { + putString(type, Json.encodeToString>(map.keys.toList())) + } + } - fun insert(s: String) = put(s, s) + fun insert(item: String) { + map.put(item, true) + save() + } - fun toOrderedList() = values.toList().reversed() + fun migrate(): List? { + val dir = FcitxApplication.getInstance().directBootAwareContext.filesDir.resolve(DIR_NAME) + val file = dir.resolve(type) + if (file.exists()) { + try { + val lines = file.readLines() + file.delete() + if (dir.list()?.isEmpty() == true) { + dir.delete() + } + return lines + } catch (e: Exception) { + Timber.w("Failed to migrate RecentlyUsed(type=$type)") + Timber.w(e) + return null + } + } + return null + } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt index c0c65ec39..39a31ab64 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/UserDataManager.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.data @@ -14,6 +14,7 @@ import org.fcitx.fcitx5.android.utils.Const import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.errorRuntime import org.fcitx.fcitx5.android.utils.extract +import org.fcitx.fcitx5.android.utils.versionCodeCompat import org.fcitx.fcitx5.android.utils.withTempDir import timber.log.Timber import java.io.File @@ -30,7 +31,7 @@ object UserDataManager { @Serializable data class Metadata( val packageName: String, - val versionCode: Int, + val versionCode: Long, val versionName: String, val exportTime: Long ) @@ -64,13 +65,13 @@ object UserDataManager { writeFileTree(dataBasesDir, "databases", zipStream) // external writeFileTree(externalDir, "external", zipStream) - // recently_used - writeFileTree(recentlyUsedDir, "recently_used", zipStream) + // recently_used moved to SharedPreference and shoud not be exported // metadata zipStream.putNextEntry(ZipEntry("metadata.json")) + val pkgInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0) val metadata = Metadata( - BuildConfig.APPLICATION_ID, - BuildConfig.VERSION_CODE, + pkgInfo.packageName, + pkgInfo.versionCodeCompat, Const.versionName, timestamp ) @@ -85,7 +86,6 @@ object UserDataManager { if (exists && isDir) { source.copyRecursively(target, overwrite = true) } else { - source.toString() Timber.w("Cannot import user data: path='${source.path}', exists=$exists, isDir=$isDir") } } @@ -102,6 +102,7 @@ object UserDataManager { copyDir(File(tempDir, "shared_prefs"), sharedPrefsDir) copyDir(File(tempDir, "databases"), dataBasesDir) copyDir(File(tempDir, "external"), externalDir) + // keep importing recently_used for backwords compatibility copyDir(File(tempDir, "recently_used"), recentlyUsedDir) metadata } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt index f669fa51e..92bc85285 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/ClipboardManager.kt @@ -6,8 +6,10 @@ package org.fcitx.fcitx5.android.data.clipboard import android.content.ClipboardManager import android.content.Context +import android.os.Build import androidx.annotation.Keep import androidx.room.Room +import androidx.room.withTransaction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -23,6 +25,7 @@ import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.utils.WeakHashSet import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.clipboardManager +import timber.log.Timber object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { @@ -85,7 +88,7 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, clbDb = Room .databaseBuilder(context, ClipboardDatabase::class.java, "clbdb") // allow wipe the database instead of crashing when downgrade - .fallbackToDestructiveMigrationOnDowngrade() + .fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true) .build() clbDao = clbDb.clipboardDao() enabledListener.onChange(enabledPref.key, enabledPref.getValue()) @@ -144,26 +147,50 @@ object ClipboardManager : ClipboardManager.OnPrimaryClipChangedListener, } } + private var lastClipTimestamp = -1L + private var lastClipHash = 0 + override fun onPrimaryClipChanged() { - clipboardManager.primaryClip - ?.let { ClipboardEntry.fromClipData(it, transformer) } - ?.takeIf { it.text.isNotBlank() } - ?.let { e -> - launch { - mutex.withLock { - clbDao.find(e.text, e.sensitive)?.let { - updateLastEntry(it.copy(timestamp = e.timestamp)) - clbDao.updateTime(it.id, e.timestamp) - return@launch - } - val rowId = clbDao.insert(e) + val clip = clipboardManager.primaryClip ?: return + /** + * skip duplicate ClipData + * https://developer.android.com/reference/android/content/ClipboardManager.OnPrimaryClipChangedListener#onPrimaryClipChanged() + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val timestamp = clip.description.timestamp + if (timestamp == lastClipTimestamp) return + lastClipTimestamp = timestamp + } else { + val timestamp = System.currentTimeMillis() + val hash = clip.hashCode() + if (timestamp - lastClipTimestamp < 100L && hash == lastClipHash) return + lastClipTimestamp = timestamp + lastClipHash = hash + } + launch { + mutex.withLock { + val entry = ClipboardEntry.fromClipData(clip, transformer) ?: return@withLock + if (entry.text.isBlank()) return@withLock + try { + clbDao.find(entry.text, entry.sensitive)?.let { + updateLastEntry(it.copy(timestamp = entry.timestamp)) + clbDao.updateTime(it.id, entry.timestamp) + return@withLock + } + val insertedEntry = clbDb.withTransaction { + val rowId = clbDao.insert(entry) removeOutdated() - updateItemCount() // new entry can be deleted immediately if clipboard limit == 0 - updateLastEntry(clbDao.get(rowId) ?: e) + clbDao.get(rowId) ?: entry } + updateLastEntry(insertedEntry) + updateItemCount() + } catch (exception: Exception) { + Timber.w("Failed to update clipboard database: $exception") + updateLastEntry(entry) } } + } } private suspend fun removeOutdated() { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt index bd7cea896..c98252b75 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/clipboard/db/ClipboardEntry.kt @@ -10,6 +10,7 @@ import android.os.Build import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import org.fcitx.fcitx5.android.utils.timestamp @Entity(tableName = ClipboardEntry.TABLE_NAME) data class ClipboardEntry( @@ -52,6 +53,7 @@ data class ClipboardEntry( } return ClipboardEntry( text = if (transformer != null) transformer(str) else str, + timestamp = clipData.timestamp(), type = desc.getMimeType(0), sensitive = sensitive ) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt index 4a3fe9997..d84a0215b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/AppPrefs.kt @@ -1,22 +1,26 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.data.prefs import android.content.SharedPreferences import android.os.Build +import androidx.annotation.Keep import androidx.annotation.RequiresApi import androidx.core.content.edit import androidx.preference.PreferenceManager import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.InputFeedbacks.InputFeedbackMode -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateStyle +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesMode +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesOrientation +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode import org.fcitx.fcitx5.android.input.keyboard.LangSwitchBehavior import org.fcitx.fcitx5.android.input.keyboard.SpaceLongPressBehavior import org.fcitx.fcitx5.android.input.keyboard.SwipeSymbolDirection import org.fcitx.fcitx5.android.input.picker.PickerWindow +import org.fcitx.fcitx5.android.input.popup.EmojiModifier import org.fcitx.fcitx5.android.utils.DeviceUtil import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.vibrator @@ -34,7 +38,7 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { } inner class Advanced : ManagedPreferenceCategory(R.string.advanced, sharedPreferences) { - val ignoreSystemCursor = switch(R.string.ignore_sys_cursor, "ignore_system_cursor", true) + val ignoreSystemCursor = switch(R.string.ignore_sys_cursor, "ignore_system_cursor", false) val hideKeyConfig = switch(R.string.hide_key_config, "hide_key_config", true) val disableAnimation = switch(R.string.disable_animation, "disable_animation", false) val vivoKeypressWorkaround = switch( @@ -42,26 +46,25 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "vivo_keypress_workaround", DeviceUtil.isVivoOriginOS ) + val ignoreSystemWindowInsets = switch( + R.string.ignore_system_window_insets, "ignore_system_window_insets", false + ) } - inner class Keyboard : ManagedPreferenceCategory(R.string.keyboard, sharedPreferences) { + inner class Keyboard : ManagedPreferenceCategory(R.string.virtual_keyboard, sharedPreferences) { val hapticOnKeyPress = - list( + enumList( R.string.button_haptic_feedback, "haptic_on_keypress", - InputFeedbackMode.FollowingSystem, - InputFeedbackMode, - listOf( - InputFeedbackMode.FollowingSystem, - InputFeedbackMode.Enabled, - InputFeedbackMode.Disabled - ), - listOf( - R.string.following_system_settings, - R.string.enabled, - R.string.disabled - ) + InputFeedbackMode.FollowingSystem ) + val hapticOnKeyUp = switch( + R.string.button_up_haptic_feedback, + "haptic_on_keyup", + false + ) { hapticOnKeyPress.getValue() != InputFeedbackMode.Disabled } + val hapticOnRepeat = switch(R.string.haptic_on_repeat, "haptic_on_repeat", false) + val buttonPressVibrationMilliseconds: ManagedPreference.PInt val buttonLongPressVibrationMilliseconds: ManagedPreference.PInt @@ -108,21 +111,10 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { buttonLongPressVibrationAmplitude = secondary } - val soundOnKeyPress = list( + val soundOnKeyPress = enumList( R.string.button_sound, "sound_on_keypress", - InputFeedbackMode.FollowingSystem, - InputFeedbackMode, - listOf( - InputFeedbackMode.FollowingSystem, - InputFeedbackMode.Enabled, - InputFeedbackMode.Disabled - ), - listOf( - R.string.following_system_settings, - R.string.enabled, - R.string.disabled - ) + InputFeedbackMode.FollowingSystem ) val soundOnKeyPressVolume = int( R.string.button_sound_volume, @@ -152,21 +144,10 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { switch(R.string.show_voice_input_button, "show_voice_input_button", false) val expandKeypressArea = switch(R.string.expand_keypress_area, "expand_keypress_area", false) - val swipeSymbolDirection = list( + val swipeSymbolDirection = enumList( R.string.swipe_symbol_behavior, "swipe_symbol_behavior", - SwipeSymbolDirection.Down, - SwipeSymbolDirection, - listOf( - SwipeSymbolDirection.Up, - SwipeSymbolDirection.Down, - SwipeSymbolDirection.Disabled - ), - listOf( - R.string.swipe_up, - R.string.swipe_down, - R.string.disabled - ) + SwipeSymbolDirection.Down ) val longPressDelay = int( R.string.keyboard_long_press_delay, @@ -177,43 +158,19 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { "ms", 10 ) - val spaceKeyLongPressBehavior = list( + val spaceKeyLongPressBehavior = enumList( R.string.space_long_press_behavior, "space_long_press_behavior", - SpaceLongPressBehavior.None, - SpaceLongPressBehavior, - listOf( - SpaceLongPressBehavior.None, - SpaceLongPressBehavior.Enumerate, - SpaceLongPressBehavior.ToggleActivate, - SpaceLongPressBehavior.ShowPicker - ), - listOf( - R.string.space_behavior_none, - R.string.space_behavior_enumerate, - R.string.space_behavior_activate, - R.string.space_behavior_picker - ) + SpaceLongPressBehavior.None ) val spaceSwipeMoveCursor = switch(R.string.space_swipe_move_cursor, "space_swipe_move_cursor", true) val showLangSwitchKey = switch(R.string.show_lang_switch_key, "show_lang_switch_key", true) - val langSwitchKeyBehavior = list( + val langSwitchKeyBehavior = enumList( R.string.lang_switch_key_behavior, "lang_switch_key_behavior", - LangSwitchBehavior.Enumerate, - LangSwitchBehavior, - listOf( - LangSwitchBehavior.Enumerate, - LangSwitchBehavior.ToggleActivate, - LangSwitchBehavior.NextInputMethodApp - ), - listOf( - R.string.space_behavior_enumerate, - R.string.space_behavior_activate, - R.string.lang_switch_behavior_next_ime_app - ) + LangSwitchBehavior.Enumerate ) { showLangSwitchKey.getValue() } val keyboardHeightPercent: ManagedPreference.PInt @@ -276,35 +233,15 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { keyboardBottomPaddingLandscape = secondary } - val horizontalCandidateStyle = list( + val horizontalCandidateStyle = enumList( R.string.horizontal_candidate_style, "horizontal_candidate_style", - HorizontalCandidateMode.AutoFillWidth, - HorizontalCandidateMode, - listOf( - HorizontalCandidateMode.NeverFillWidth, - HorizontalCandidateMode.AutoFillWidth, - HorizontalCandidateMode.AlwaysFillWidth, - ), - listOf( - R.string.horizontal_candidate_never_fill, - R.string.horizontal_candidate_auto_fill, - R.string.horizontal_candidate_always_fill - ) + HorizontalCandidateMode.AutoFillWidth ) - val expandedCandidateStyle = list( + val expandedCandidateStyle = enumList( R.string.expanded_candidate_style, "expanded_candidate_style", - ExpandedCandidateStyle.Grid, - ExpandedCandidateStyle, - listOf( - ExpandedCandidateStyle.Grid, - ExpandedCandidateStyle.Flexbox - ), - listOf( - R.string.expanded_candidate_style_grid, - R.string.expanded_candidate_style_flexbox - ) + ExpandedCandidateStyle.Grid ) val expandedCandidateGridSpanCount: ManagedPreference.PInt @@ -328,6 +265,60 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { } + inner class Candidates : + ManagedPreferenceCategory(R.string.candidates_window, sharedPreferences) { + val mode = enumList( + R.string.show_candidates_window, + "show_candidates_window", + FloatingCandidatesMode.InputDevice + ) + + val orientation = enumList( + R.string.candidates_orientation, + "candidates_window_orientation", + FloatingCandidatesOrientation.Automatic + ) + + val windowMinWidth = int( + R.string.candidates_window_min_width, + "candidates_window_min_width", + 0, + 0, + 640, + "dp", + 10 + ) + + val windowPadding = + int(R.string.candidates_window_padding, "candidates_window_padding", 4, 0, 32, "dp") + + val fontSize = + int(R.string.candidates_font_size, "candidates_window_font_size", 20, 4, 64, "sp") + + val windowRadius = + int(R.string.candidates_window_radius, "candidates_window_radius", 0, 0, 48, "dp") + + val itemPaddingVertical: ManagedPreference.PInt + val itemPaddingHorizontal: ManagedPreference.PInt + + init { + val (primary, secondary) = twinInt( + R.string.candidates_padding, + R.string.vertical, + "candidates_item_padding_vertical", + 2, + R.string.horizontal, + "candidates_item_padding_horizontal", + 4, + 0, + 64, + "dp" + ) + itemPaddingVertical = primary + itemPaddingHorizontal = secondary + } + } + inner class Clipboard : ManagedPreferenceCategory(R.string.clipboard, sharedPreferences) { val clipboardListening = switch(R.string.clipboard_listening, "clipboard_enable", true) val clipboardHistoryLimit = int( @@ -354,6 +345,14 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { ) { clipboardListening.getValue() } } + inner class Symbols : ManagedPreferenceCategory(R.string.emoji_and_symbols, sharedPreferences) { + val defaultEmojiSkinTone = enumList( + R.string.default_emoji_skin_tone, + "default_emoji_skin_tone", + EmojiModifier.SkinTone.Default, + ) + } + private val providers = mutableListOf() fun registerProvider( @@ -370,13 +369,17 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { val internal = Internal().register() val keyboard = Keyboard().register() + val candidates = Candidates().register() val clipboard = Clipboard().register() + val symbols = Symbols().register() val advanced = Advanced().register() + @Keep private val onSharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null) return@OnSharedPreferenceChangeListener providers.forEach { - it.managedPreferences[key]?.fireChange() + it.fireChange(key) } } @@ -394,11 +397,14 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) { ).forEach { it.putValueTo(this@edit) } - keyboard.managedPreferences.forEach { - it.value.putValueTo(this@edit) - } - clipboard.managedPreferences.forEach { - it.value.putValueTo(this@edit) + listOf( + keyboard, + candidates, + clipboard + ).forEach { category -> + category.managedPreferences.forEach { + it.value.putValueTo(this@edit) + } } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt index 6bc725a7e..ca8367b0c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreference.kt @@ -35,9 +35,7 @@ abstract class ManagedPreference( override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) = setValue(value) - private val listeners by lazy { - WeakHashSet>() - } + private lateinit var listeners: MutableSet> /** * **WARN:** No anonymous listeners, please **KEEP** the reference! @@ -47,15 +45,19 @@ abstract class ManagedPreference( * or simply mark the listener with [@Keep][androidx.annotation.Keep] . */ fun registerOnChangeListener(listener: OnChangeListener) { + if (!::listeners.isInitialized) { + listeners = WeakHashSet() + } listeners.add(listener) } fun unregisterOnChangeListener(listener: OnChangeListener) { + if (!::listeners.isInitialized || listeners.isEmpty()) return listeners.remove(listener) } fun fireChange() { - if (listeners.isEmpty()) return + if (!::listeners.isInitialized || listeners.isEmpty()) return val newValue = getValue() listeners.forEach { it.onChange(key, newValue) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt index 8cd5fdaa2..1c8a84994 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceCategory.kt @@ -49,6 +49,21 @@ abstract class ManagedPreferenceCategory( return pref } + protected inline fun enumList( + @StringRes + title: Int, + key: String, + defaultValue: T, + noinline enableUiOn: (() -> Boolean)? = null + ): ManagedPreference.PStringLike where T : Enum, T : ManagedPreferenceEnum { + val codec = object : ManagedPreference.StringLikeCodec { + override fun decode(raw: String): T = enumValueOf(raw) + } + val entryValues = enumValues().toList() + val entryLabels = entryValues.map { it.stringRes } + return list(title, key, defaultValue, codec, entryValues, entryLabels, enableUiOn) + } + protected fun int( @StringRes title: Int, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceEnum.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceEnum.kt new file mode 100644 index 000000000..a67e8407a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceEnum.kt @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.data.prefs + +import androidx.annotation.StringRes + +interface ManagedPreferenceEnum { + @get:StringRes + val stringRes: Int +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt index 1cf246375..449bdc785 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceProvider.kt @@ -1,13 +1,19 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.data.prefs import androidx.preference.PreferenceScreen +import org.fcitx.fcitx5.android.utils.WeakHashSet abstract class ManagedPreferenceProvider { + fun interface OnChangeListener { + fun onChange(key: String) + } + private val _managedPreferences: MutableMap> = mutableMapOf() private val _managedPreferencesUi: MutableList> = mutableListOf() @@ -22,6 +28,22 @@ abstract class ManagedPreferenceProvider { } + private val onChangeListeners = WeakHashSet() + + fun registerOnChangeListener(listener: OnChangeListener) { + onChangeListeners.add(listener) + } + + fun unregisterOnChangeListener(listener: OnChangeListener) { + onChangeListeners.remove(listener) + } + + fun fireChange(key: String) { + val preference = _managedPreferences[key] ?: return + onChangeListeners.forEach { it.onChange(key) } + preference.fireChange() + } + fun ManagedPreferenceUi<*>.registerUi() { _managedPreferencesUi.add(this) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt index bd62f129d..a4d2bbe03 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/prefs/ManagedPreferenceVisibilityEvaluator.kt @@ -15,14 +15,12 @@ class ManagedPreferenceVisibilityEvaluator( // it would be better to declare the dependency relationship, rather than reevaluating on each value changed @Keep - private val onValueChangeListener = ManagedPreference.OnChangeListener { _, _ -> + private val onValueChangeListener = ManagedPreferenceProvider.OnChangeListener { evaluateVisibility() } init { - provider.managedPreferences.forEach { (_, pref) -> - pref.registerOnChangeListener(onValueChangeListener) - } + provider.registerOnChangeListener(onValueChangeListener) } fun evaluateVisibility() { @@ -40,9 +38,7 @@ class ManagedPreferenceVisibilityEvaluator( } fun destroy() { - provider.managedPreferences.forEach { (_, pref) -> - pref.unregisterOnChangeListener(onValueChangeListener) - } + provider.unregisterOnChangeListener(onValueChangeListener) } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt index 02a1e9a33..b3cd68eea 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/BuiltinQuickPhrase.kt @@ -14,18 +14,13 @@ class BuiltinQuickPhrase( init { ensureFileExists() + evaluateOverride() } - var override: CustomQuickPhrase? = - if (overrideFile.exists()) - CustomQuickPhrase(overrideFile) - else { - val disabledOverride = File(overrideFile.path + ".$DISABLE") - if (disabledOverride.exists()) - CustomQuickPhrase(disabledOverride) - else - null - } + val overrideFilePath: String + get() = overrideFile.absolutePath + + var override: CustomQuickPhrase? = null private set override val isEnabled: Boolean @@ -35,6 +30,7 @@ class BuiltinQuickPhrase( if (override != null) return file.copyTo(overrideFile, overwrite = true) + // Update override override = CustomQuickPhrase(overrideFile) } @@ -66,6 +62,21 @@ class BuiltinQuickPhrase( override = null } + /** + * Make sure [override] is set correctly. + */ + fun evaluateOverride() { + override = if (overrideFile.exists()) + CustomQuickPhrase(overrideFile) + else { + val disabledOverride = File(overrideFile.path + ".$DISABLE") + if (disabledOverride.exists()) + CustomQuickPhrase(disabledOverride) + else + null + } + } + override fun toString(): String { return "BuiltinQuickPhrase(file=$file, overrideFile=$overrideFile, override=$override, isEnabled=$isEnabled)" } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt index 637b7c704..97aa42631 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhrase.kt @@ -4,10 +4,25 @@ */ package org.fcitx.fcitx5.android.data.quickphrase +import android.os.Parcel +import android.os.Parcelable +import kotlinx.serialization.Serializable import java.io.File -import java.io.Serializable -abstract class QuickPhrase : Serializable { +@Serializable(QuickPhraseSerializer::class) +abstract class QuickPhrase : Parcelable { + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(file.absolutePath) + dest.writeByte(if (this is BuiltinQuickPhrase) 1 else 0) + if (this is BuiltinQuickPhrase) { + dest.writeString(overrideFilePath) + } else { + dest.writeString(null) + } + } abstract val file: File diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseSerializer.kt new file mode 100644 index 000000000..5a5690e1e --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseSerializer.kt @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.data.quickphrase + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure +import java.io.File + +object QuickPhraseSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("quickphrase") { + element("path") + element("isBuiltin") + element("override") + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize( + encoder: Encoder, + value: QuickPhrase + ) = encoder.encodeStructure(descriptor) { + encodeStringElement(descriptor, 0, value.file.absolutePath) + encodeBooleanElement(descriptor, 1, value is BuiltinQuickPhrase) + encodeNullableSerializableElement( + descriptor, + 2, + String.serializer(), + value.let { it as? BuiltinQuickPhrase }?.overrideFilePath + ) + + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): QuickPhrase = + decoder.decodeStructure(descriptor) { + var path: String? = null + var isBuiltin = false + var overridePath: String? = null + + while (true) { + when (decodeElementIndex(descriptor)) { + 0 -> path = decodeStringElement(descriptor, 0) + 1 -> isBuiltin = decodeBooleanElement(descriptor, 1) + 2 -> overridePath = decodeNullableSerializableElement( + descriptor, 2, String.serializer() + ) + else -> break + } + } + + val file = File(path ?: throw IllegalStateException("Path cannot be null")) + if (isBuiltin) { + BuiltinQuickPhrase( + file, + File( + overridePath ?: throw IllegalStateException("Override path cannot be null") + ) + ) + } else { + CustomQuickPhrase(file) + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt index de6507e21..ebadfeb16 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/CustomThemeSerializer.kt @@ -55,6 +55,13 @@ object CustomThemeSerializer : JsonTransformingSerializer(Theme.Cu private val strategies: List = // Add migrations here listOf( + MigrationStrategy("2.1") { + JsonObject(it.toMutableMap().apply { + put("candidateTextColor", getValue("keyTextColor")) + put("candidateLabelColor", getValue("keyTextColor")) + put("candidateCommentColor", getValue("altKeyTextColor")) + }) + }, MigrationStrategy("2.0") { JsonObject(it.toMutableMap().apply { if (get("backgroundImage") != null) { @@ -80,7 +87,7 @@ object CustomThemeSerializer : JsonTransformingSerializer(Theme.Cu private const val VERSION = "version" - private const val CURRENT_VERSION = "2.0" + private const val CURRENT_VERSION = "2.1" private const val FALLBACK_VERSION = "1.0" private val knownVersions = strategies.map { it.version } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt index 10c7e0371..d7f2791b2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt @@ -12,9 +12,10 @@ import android.graphics.drawable.Drawable import android.os.Parcelable import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import org.fcitx.fcitx5.android.utils.DarkenColorFilter import org.fcitx.fcitx5.android.utils.RectSerializer +import org.fcitx.fcitx5.android.utils.alpha import org.fcitx.fcitx5.android.utils.appContext -import org.fcitx.fcitx5.android.utils.DarkenColorFilter import java.io.File @Serializable @@ -30,6 +31,11 @@ sealed class Theme : Parcelable { abstract val keyBackgroundColor: Int abstract val keyTextColor: Int + // Color of candidate text + abstract val candidateTextColor: Int + abstract val candidateLabelColor: Int + abstract val candidateCommentColor: Int + abstract val altKeyBackgroundColor: Int abstract val altKeyTextColor: Int @@ -67,6 +73,9 @@ sealed class Theme : Parcelable { override val keyboardColor: Int, override val keyBackgroundColor: Int, override val keyTextColor: Int, + override val candidateTextColor: Int, + override val candidateLabelColor: Int, + override val candidateCommentColor: Int, override val altKeyBackgroundColor: Int, override val altKeyTextColor: Int, override val accentKeyBackgroundColor: Int, @@ -88,6 +97,7 @@ sealed class Theme : Parcelable { val srcFilePath: String, val brightness: Int = 70, val cropRect: @Serializable(RectSerializer::class) Rect?, + val cropRotation: Int = 0 ) : Parcelable { fun toDrawable(): Drawable? { val cropped = File(croppedFilePath) @@ -114,6 +124,9 @@ sealed class Theme : Parcelable { override val keyboardColor: Int, override val keyBackgroundColor: Int, override val keyTextColor: Int, + override val candidateTextColor: Int, + override val candidateLabelColor: Int, + override val candidateCommentColor: Int, override val altKeyBackgroundColor: Int, override val altKeyTextColor: Int, override val accentKeyBackgroundColor: Int, @@ -139,6 +152,9 @@ sealed class Theme : Parcelable { keyboardColor: Number, keyBackgroundColor: Number, keyTextColor: Number, + candidateTextColor: Number, + candidateLabelColor: Number, + candidateCommentColor: Number, altKeyBackgroundColor: Number, altKeyTextColor: Number, accentKeyBackgroundColor: Number, @@ -160,6 +176,9 @@ sealed class Theme : Parcelable { keyboardColor.toInt(), keyBackgroundColor.toInt(), keyTextColor.toInt(), + candidateTextColor.toInt(), + candidateLabelColor.toInt(), + candidateCommentColor.toInt(), altKeyBackgroundColor.toInt(), altKeyTextColor.toInt(), accentKeyBackgroundColor.toInt(), @@ -184,6 +203,9 @@ sealed class Theme : Parcelable { keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -205,6 +227,7 @@ sealed class Theme : Parcelable { originBackgroundImage: String, brightness: Int = 70, cropBackgroundRect: Rect? = null, + cropBackgroundRotation: Int = 0 ) = Custom( name, isDark, @@ -212,13 +235,17 @@ sealed class Theme : Parcelable { croppedBackgroundImage, originBackgroundImage, brightness, - cropBackgroundRect + cropBackgroundRect, + cropBackgroundRotation ), backgroundColor, barColor, keyboardColor, keyBackgroundColor, keyTextColor, + candidateTextColor, + candidateLabelColor, + candidateCommentColor, altKeyBackgroundColor, altKeyTextColor, accentKeyBackgroundColor, @@ -235,4 +262,94 @@ sealed class Theme : Parcelable { ) } -} \ No newline at end of file + @Parcelize + data class Monet( + override val name: String, + override val isDark: Boolean, + override val backgroundColor: Int, + override val barColor: Int, + override val keyboardColor: Int, + override val keyBackgroundColor: Int, + override val keyTextColor: Int, + override val candidateTextColor: Int, + override val candidateLabelColor: Int, + override val candidateCommentColor: Int, + override val altKeyBackgroundColor: Int, + override val altKeyTextColor: Int, + override val accentKeyBackgroundColor: Int, + override val accentKeyTextColor: Int, + override val keyPressHighlightColor: Int, + override val keyShadowColor: Int, + override val popupBackgroundColor: Int, + override val popupTextColor: Int, + override val spaceBarColor: Int, + override val dividerColor: Int, + override val clipboardEntryColor: Int, + override val genericActiveBackgroundColor: Int, + override val genericActiveForegroundColor: Int + ) : Theme() { + constructor( + isDark: Boolean, + surfaceContainer: Int, + surfaceContainerHigh: Int, + surfaceBright: Int, + onSurface: Int, + primary: Int, + onPrimary: Int, + secondaryContainer: Int, + onSurfaceVariant: Int, + ) : this( + name = "Monet" + if (isDark) "Dark" else "Light", + isDark = isDark, + backgroundColor = surfaceContainer, + barColor = surfaceContainerHigh, + keyboardColor = surfaceContainer, + keyBackgroundColor = surfaceBright, + keyTextColor = onSurface, + candidateTextColor = onSurface, + candidateLabelColor = onSurface, + candidateCommentColor = onSurfaceVariant, + altKeyBackgroundColor = secondaryContainer, + altKeyTextColor = onSurfaceVariant, + accentKeyBackgroundColor = primary, + accentKeyTextColor = onPrimary, + keyPressHighlightColor = onSurface.alpha(if (isDark) 0.2f else 0.12f), + keyShadowColor = 0x000000, + popupBackgroundColor = surfaceContainer, + popupTextColor = onSurface, + spaceBarColor = surfaceBright, + dividerColor = surfaceBright, + clipboardEntryColor = surfaceBright, + genericActiveBackgroundColor = primary, + genericActiveForegroundColor = onPrimary + ) + + @OptIn(ExperimentalStdlibApi::class) + fun toCustom() = Custom( + name = name + "#" + this.accentKeyBackgroundColor.toHexString(), // Use primary color as identifier + isDark = isDark, + backgroundImage = null, + backgroundColor = backgroundColor, + barColor = barColor, + keyboardColor = keyboardColor, + keyBackgroundColor = keyBackgroundColor, + keyTextColor = keyTextColor, + candidateTextColor = candidateTextColor, + candidateLabelColor = candidateLabelColor, + candidateCommentColor = candidateCommentColor, + altKeyBackgroundColor = altKeyBackgroundColor, + altKeyTextColor = altKeyTextColor, + accentKeyBackgroundColor = accentKeyBackgroundColor, + accentKeyTextColor = accentKeyTextColor, + keyPressHighlightColor = keyPressHighlightColor, + keyShadowColor = keyShadowColor, + popupBackgroundColor = popupBackgroundColor, + popupTextColor = popupTextColor, + spaceBarColor = spaceBarColor, + dividerColor = dividerColor, + clipboardEntryColor = clipboardEntryColor, + genericActiveBackgroundColor = genericActiveBackgroundColor, + genericActiveForegroundColor = genericActiveForegroundColor + ) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt index ed96be495..93ba4d47f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeManager.kt @@ -11,7 +11,8 @@ import androidx.annotation.RequiresApi import androidx.core.content.edit import androidx.preference.PreferenceManager import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider +import org.fcitx.fcitx5.android.data.theme.ThemeManager.activeTheme import org.fcitx.fcitx5.android.utils.WeakHashSet import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.isDarkMode @@ -36,12 +37,14 @@ object ThemeManager { val DefaultTheme = ThemePreset.PixelDark + private var monetThemes = listOf(ThemeMonet.getLight(), ThemeMonet.getDark()) + private val customThemes: MutableList = ThemeFilesManager.listThemes() fun getTheme(name: String) = customThemes.find { it.name == name } ?: BuiltinThemes.find { it.name == name } - fun getAllThemes() = customThemes + BuiltinThemes + fun getAllThemes() = customThemes + monetThemes + BuiltinThemes fun refreshThemes() { customThemes.clear() @@ -119,7 +122,7 @@ object ThemeManager { } @Keep - private val onThemePrefsChange = ManagedPreference.OnChangeListener { key, _ -> + private val onThemePrefsChange = ManagedPreferenceProvider.OnChangeListener { key -> if (prefs.dayNightModePrefNames.contains(key)) { activeTheme = evaluateActiveTheme() } else { @@ -130,14 +133,15 @@ object ThemeManager { fun init(configuration: Configuration) { isDarkMode = configuration.isDarkMode() // fire all `OnThemeChangedListener`s on theme preferences change - prefs.managedPreferences.values.forEach { - it.registerOnChangeListener(onThemePrefsChange) - } + prefs.registerOnChangeListener(onThemePrefsChange) _activeTheme = evaluateActiveTheme() } - fun onSystemDarkModeChange(isDark: Boolean) { - isDarkMode = isDark + fun onSystemPlatteChange(newConfig: Configuration) { + isDarkMode = newConfig.isDarkMode() + monetThemes = listOf(ThemeMonet.getLight(), ThemeMonet.getDark()) + // `ManagedThemePreference` finds a theme with same name in `getAllThemes()` + // thus `evaluateActiveTheme()` should be called after updating `monetThemes` activeTheme = evaluateActiveTheme() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeMonet.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeMonet.kt new file mode 100644 index 000000000..ef9e32433 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemeMonet.kt @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data.theme + +import android.os.Build +import org.fcitx.fcitx5.android.utils.appContext + +// Ref: +// https://github.com/material-components/material-components-android/blob/master/docs/theming/Color.md +// https://www.figma.com/community/file/809865700885504168/material-3-android-15 +// https://material-foundation.github.io/material-theme-builder/ + +// FIXME: SDK < 34 can only have approximate color values, maybe we can implement our own color algorithm. +// See: https://github.com/XayahSuSuSu/Android-DataBackup/blob/e8b087fb55519c659bebdc46c0217731fe80a0d7/source/core/ui/src/main/kotlin/com/xayah/core/ui/material3/DynamicTonalPalette.kt#L185 + +object ThemeMonet { + fun getLight(): Theme.Monet = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) // Real Monet colors + Theme.Monet( + isDark = false, + surfaceContainer = appContext.getColor(android.R.color.system_surface_container_light), + surfaceContainerHigh = appContext.getColor(android.R.color.system_surface_container_highest_light), + surfaceBright = appContext.getColor(android.R.color.system_surface_bright_light), + onSurface = appContext.getColor(android.R.color.system_on_surface_light), + primary = appContext.getColor(android.R.color.system_primary_light), + onPrimary = appContext.getColor(android.R.color.system_on_primary_light), + secondaryContainer = appContext.getColor(android.R.color.system_secondary_container_light), + onSurfaceVariant = appContext.getColor(android.R.color.system_on_surface_variant_light) + ) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) // Approximate color values + Theme.Monet( + isDark = false, + surfaceContainer = appContext.getColor(android.R.color.system_neutral1_50), // neutral94 + surfaceContainerHigh = appContext.getColor(android.R.color.system_neutral2_100), // neutral92 + surfaceBright = appContext.getColor(android.R.color.system_neutral1_10), // neutral98 + onSurface = appContext.getColor(android.R.color.system_neutral1_900), + primary = appContext.getColor(android.R.color.system_accent1_600), + onPrimary = appContext.getColor(android.R.color.system_accent1_0), + secondaryContainer = appContext.getColor(android.R.color.system_accent2_100), + onSurfaceVariant = appContext.getColor(android.R.color.system_accent2_700) + ) + else // Static MD3 colors, based on #769CDF + Theme.Monet( + isDark = false, + surfaceContainer = 0xffededf4.toInt(), + surfaceContainerHigh = 0xffe7e8ee.toInt(), + surfaceBright = 0xfff9f9ff.toInt(), + onSurface = 0xff191c20.toInt(), + primary = 0xff415f91.toInt(), + onPrimary = 0xffffffff.toInt(), + secondaryContainer = 0xffdae2f9.toInt(), + onSurfaceVariant = 0xff44474e.toInt(), + ) + + fun getDark(): Theme.Monet = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) // Real Monet colors + Theme.Monet( + isDark = true, + surfaceContainer = appContext.getColor(android.R.color.system_surface_container_dark), + surfaceContainerHigh = appContext.getColor(android.R.color.system_surface_container_high_dark), + surfaceBright = appContext.getColor(android.R.color.system_surface_bright_dark), + onSurface = appContext.getColor(android.R.color.system_on_surface_dark), + primary = appContext.getColor(android.R.color.system_primary_dark), + onPrimary = appContext.getColor(android.R.color.system_on_primary_dark), + secondaryContainer = appContext.getColor(android.R.color.system_secondary_container_dark), + onSurfaceVariant = appContext.getColor(android.R.color.system_on_surface_variant_dark) + ) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) // Approximate color values + Theme.Monet( + isDark = true, + surfaceContainer = appContext.getColor(android.R.color.system_neutral1_900), // neutral12 + surfaceContainerHigh = appContext.getColor(android.R.color.system_neutral2_1000), // neutral17 + surfaceBright = appContext.getColor(android.R.color.system_neutral1_800), // neutral24 + onSurface = appContext.getColor(android.R.color.system_neutral1_100), + primary = appContext.getColor(android.R.color.system_accent1_200), + onPrimary = appContext.getColor(android.R.color.system_accent1_800), + secondaryContainer = appContext.getColor(android.R.color.system_accent2_700), + onSurfaceVariant = appContext.getColor(android.R.color.system_accent2_200) + ) + else // Static MD3 colors, based on #769CDF + Theme.Monet( + isDark = true, + surfaceContainer = 0xff1d2024.toInt(), + surfaceContainerHigh = 0xff282a2f.toInt(), + surfaceBright = 0xff37393e.toInt(), + onSurface = 0xffe2e2e9.toInt(), + primary = 0xffaac7ff.toInt(), + onPrimary = 0xff0a305f.toInt(), + secondaryContainer = 0xff3e4759.toInt(), + onSurfaceVariant = 0xffc4c6d0.toInt(), + ) + +} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt index 521b56608..a42ef8d52 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePrefs.kt @@ -6,10 +6,14 @@ package org.fcitx.fcitx5.android.data.theme import android.content.SharedPreferences +import android.os.Build import androidx.annotation.StringRes +import androidx.core.content.edit +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceCategory +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum class ThemePrefs(sharedPreferences: SharedPreferences) : ManagedPreferenceCategory(R.string.theme, sharedPreferences) { @@ -76,56 +80,43 @@ class ThemePrefs(sharedPreferences: SharedPreferences) : val keyRadius = int(R.string.key_radius, "key_radius", 4, 0, 48, "dp") - enum class PunctuationPosition { - Bottom, - TopRight; + val textEditingButtonRadius = + int(R.string.text_editing_button_radius, "text_editing_button_radius", 8, 0, 48, "dp") - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): PunctuationPosition = valueOf(raw) - } + val clipboardEntryRadius = + int(R.string.clipboard_entry_radius, "clipboard_entry_radius", 2, 0, 48, "dp") + + enum class PunctuationPosition(override val stringRes: Int) : ManagedPreferenceEnum { + None(R.string.punctuation_pos_none), + Bottom(R.string.punctuation_pos_bottom), + TopRight(R.string.punctuation_pos_top_right); } - val punctuationPosition = list( + val punctuationPosition = enumList( R.string.punctuation_position, "punctuation_position", - PunctuationPosition.Bottom, - PunctuationPosition, - listOf( - PunctuationPosition.Bottom, - PunctuationPosition.TopRight - ), - listOf( - R.string.punctuation_pos_bottom, - R.string.punctuation_pos_top_right - ) + PunctuationPosition.Bottom ) - enum class NavbarBackground { - None, - ColorOnly, - Full; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): NavbarBackground = valueOf(raw) - } + enum class NavbarBackground(override val stringRes: Int) : ManagedPreferenceEnum { + None(R.string.navbar_bkg_none), + ColorOnly(R.string.navbar_bkg_color_only), + Full(R.string.navbar_bkg_full); } - val navbarBackground = list( + val navbarBackground = enumList( R.string.navbar_background, "navbar_background", - NavbarBackground.ColorOnly, - NavbarBackground, - listOf( - NavbarBackground.None, - NavbarBackground.ColorOnly, - NavbarBackground.Full - ), - listOf( - R.string.navbar_bkg_none, - R.string.navbar_bkg_color_only, - R.string.navbar_bkg_full - ) - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) NavbarBackground.Full else NavbarBackground.ColorOnly, + // 35+ forces edge to edge + enableUiOn = { Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM } + ).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + sharedPreferences.edit { + remove(this@apply.key) + } + } + } /** * When [followSystemDayNightTheme] is disabled, this theme is used. @@ -140,14 +131,14 @@ class ThemePrefs(sharedPreferences: SharedPreferences) : val followSystemDayNightTheme = switch( R.string.follow_system_day_night_theme, "follow_system_dark_mode", - false, + true, summary = R.string.follow_system_day_night_theme_summary ) val lightModeTheme = themePreference( R.string.light_mode_theme, "light_mode_theme", - ThemePreset.PixelLight, + if (BuildConfig.DEBUG) ThemePreset.MaterialLight else ThemePreset.PixelLight, enableUiOn = { followSystemDayNightTheme.getValue() }) @@ -155,7 +146,7 @@ class ThemePrefs(sharedPreferences: SharedPreferences) : val darkModeTheme = themePreference( R.string.dark_mode_theme, "dark_mode_theme", - ThemePreset.PixelDark, + if (BuildConfig.DEBUG) ThemePreset.MaterialDark else ThemePreset.PixelDark, enableUiOn = { followSystemDayNightTheme.getValue() }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt index 429270372..239e22cb8 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/theme/ThemePreset.kt @@ -14,6 +14,9 @@ object ThemePreset { keyboardColor = 0xffeceff1, keyBackgroundColor = 0xfffbfbfc, keyTextColor = 0xff37474f, + candidateTextColor = 0xff37474f, + candidateLabelColor = 0xff37474f, + candidateCommentColor = 0xff7a858a, altKeyBackgroundColor = 0xffdfe2e4, // Google Pinyin's symbol color on alphabet key: #727d82 altKeyTextColor = 0xff7a858a, @@ -38,6 +41,9 @@ object ThemePreset { keyboardColor = 0xff263238, keyBackgroundColor = 0xff404a50, keyTextColor = 0xffd9dbdc, + candidateTextColor = 0xffd9dbdc, + candidateLabelColor = 0xffd9dbdc, + candidateCommentColor = 0xffadb1b3, altKeyBackgroundColor = 0xff313c42, // Google Pinyin's symbol color on alphabet key: #b3b7b9 altKeyTextColor = 0xffadb1b3, @@ -62,6 +68,9 @@ object ThemePreset { keyboardColor = 0xfffafafa, keyBackgroundColor = 0xffffffff, keyTextColor = 0xff212121, + candidateTextColor = 0xff212121, + candidateLabelColor = 0xff212121, + candidateCommentColor = 0xff6e6e6e, altKeyBackgroundColor = 0xffe1e1e1, // Google Pinyin's symbol color on alphabet key: #4e4e4e altKeyTextColor = 0xff6e6e6e, @@ -86,6 +95,9 @@ object ThemePreset { keyboardColor = 0xff2d2d2d, keyBackgroundColor = 0xff464646, keyTextColor = 0xfffafafa, + candidateTextColor = 0xfffafafa, + candidateLabelColor = 0xfffafafa, + candidateCommentColor = 0xffacacac, altKeyBackgroundColor = 0xff373737, // Google Pinyin's symbol color on alphabet key: #d6d6d6 altKeyTextColor = 0xffacacac, @@ -110,6 +122,9 @@ object ThemePreset { keyboardColor = 0xff1565c0, keyBackgroundColor = 0xff3f80cb, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xffa9c6e7, altKeyBackgroundColor = 0xff2771c4, // Google Pinyin's symbol color on alphabet key: #d6d6d6 altKeyTextColor = 0xffa9c6e7, @@ -134,6 +149,9 @@ object ThemePreset { keyboardColor = 0xff000000, keyBackgroundColor = 0xff2e2e2e, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xffa1a1a1, altKeyBackgroundColor = 0xff141414, // Google Pinyin's symbol color on alphabet key: #d9e6f5 altKeyTextColor = 0xffa1a1a1, @@ -158,6 +176,9 @@ object ThemePreset { keyboardColor = 0xffECEFF4, keyBackgroundColor = 0xffECEFF4, keyTextColor = 0xff2E3440, + candidateTextColor = 0xff2E3440, + candidateLabelColor = 0xff2E3440, + candidateCommentColor = 0xff4C566A, altKeyBackgroundColor = 0xffE5E9F0, altKeyTextColor = 0xff434C5E, accentKeyBackgroundColor = 0xff5E81AC, @@ -181,6 +202,9 @@ object ThemePreset { keyboardColor = 0xff2E3440, keyBackgroundColor = 0xff4C566A, keyTextColor = 0xffECEFF4, + candidateTextColor = 0xffECEFF4, + candidateLabelColor = 0xffECEFF4, + candidateCommentColor = 0xffD8DEE9, altKeyBackgroundColor = 0xff3B4252, altKeyTextColor = 0xffD8DEE9, accentKeyBackgroundColor = 0xff88C0D0, @@ -204,6 +228,9 @@ object ThemePreset { keyboardColor = 0xff272822, keyBackgroundColor = 0xff33342c, keyTextColor = 0xffd6d6d6, + candidateTextColor = 0xffd6d6d6, + candidateLabelColor = 0xffd6d6d6, + candidateCommentColor = 0xff797979, altKeyBackgroundColor = 0xff2d2e27, altKeyTextColor = 0xff797979, accentKeyBackgroundColor = 0xffb05279, @@ -230,6 +257,9 @@ object ThemePreset { keyboardColor = 0x00000000, keyBackgroundColor = 0x4bffffff, keyTextColor = 0xffffffff, + candidateTextColor = 0xffffffff, + candidateLabelColor = 0xffffffff, + candidateCommentColor = 0xc9ffffff, altKeyBackgroundColor = 0x0cffffff, altKeyTextColor = 0xc9ffffff, accentKeyBackgroundColor = 0xff5e97f6, @@ -256,6 +286,9 @@ object ThemePreset { keyboardColor = 0x00000000, keyBackgroundColor = 0x4bffffff, keyTextColor = 0xff000000, + candidateTextColor = 0xff000000, + candidateLabelColor = 0xff000000, + candidateCommentColor = 0xb9000000, altKeyBackgroundColor = 0x0cffffff, altKeyTextColor = 0xb9000000, accentKeyBackgroundColor = 0xff5e97f6, @@ -271,4 +304,4 @@ object ThemePreset { genericActiveForegroundColor = 0xffffffff ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/BaseInputView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/BaseInputView.kt new file mode 100644 index 000000000..c43731d39 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/BaseInputView.kt @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.view.WindowInsets +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.daemon.FcitxConnection +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager +import org.fcitx.fcitx5.android.data.theme.ThemePrefs +import org.fcitx.fcitx5.android.utils.navbarFrameHeight +import kotlin.math.max + +abstract class BaseInputView( + val service: FcitxInputMethodService, + val fcitx: FcitxConnection, + val theme: Theme +) : ConstraintLayout(service) { + + protected abstract fun handleFcitxEvent(it: FcitxEvent<*>) + + private var eventHandlerJob: Job? = null + + private fun setupFcitxEventHandler() { + eventHandlerJob = service.lifecycleScope.launch { + fcitx.runImmediately { eventFlow }.collect { + handleFcitxEvent(it) + } + } + } + + var handleEvents = false + set(value) { + field = value + if (field) { + if (eventHandlerJob == null) { + setupFcitxEventHandler() + } + } else { + eventHandlerJob?.cancel() + eventHandlerJob = null + } + } + + private val navbarBackground by ThemeManager.prefs.navbarBackground + + protected fun getNavBarBottomInset(windowInsets: WindowInsets): Int { + if (navbarBackground != ThemePrefs.NavbarBackground.Full) { + return 0 + } + val insets = WindowInsetsCompat.toWindowInsetsCompat(windowInsets) + // use navigation bar insets when available + val navBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + // in case navigation bar insets goes wrong (eg. on LineageOS 21+ with gesture navigation) + // use mandatory system gesture insets + val mandatory = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()) + var insetsBottom = max(navBars.bottom, mandatory.bottom) + if (insetsBottom <= 0) { + // check system gesture insets and fallback to navigation_bar_frame_height just in case + val gesturesBottom = insets.getInsets(WindowInsetsCompat.Type.systemGestures()).bottom + if (gesturesBottom > 0) { + insetsBottom = max(gesturesBottom, context.navbarFrameHeight()) + } + } + return insetsBottom + } + + override fun onDetachedFromWindow() { + handleEvents = false + super.onDetachedFromWindow() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/CandidatesView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/CandidatesView.kt new file mode 100644 index 000000000..c03eb61ff --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/CandidatesView.kt @@ -0,0 +1,238 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024-2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.annotation.SuppressLint +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.ViewTreeObserver.OnPreDrawListener +import android.view.WindowInsets +import android.widget.TextView +import androidx.annotation.Size +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.daemon.FcitxConnection +import org.fcitx.fcitx5.android.daemon.launchOnReady +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.candidates.floating.PagedCandidatesUi +import org.fcitx.fcitx5.android.input.preedit.PreeditUi +import splitties.dimensions.dp +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.withTheme +import splitties.views.dsl.core.wrapContent +import splitties.views.padding +import kotlin.math.roundToInt + +@SuppressLint("ViewConstructor") +class CandidatesView( + service: FcitxInputMethodService, + fcitx: FcitxConnection, + theme: Theme +) : BaseInputView(service, fcitx, theme) { + + private val ctx = context.withTheme(R.style.Theme_InputViewTheme) + + private val candidatesPrefs = AppPrefs.getInstance().candidates + private val orientation by candidatesPrefs.orientation + private val windowMinWidth by candidatesPrefs.windowMinWidth + private val windowPadding by candidatesPrefs.windowPadding + private val windowRadius by candidatesPrefs.windowRadius + private val fontSize by candidatesPrefs.fontSize + private val itemPaddingVertical by candidatesPrefs.itemPaddingVertical + private val itemPaddingHorizontal by candidatesPrefs.itemPaddingHorizontal + + private var inputPanel = FcitxEvent.InputPanelEvent.Data() + private var paged = FcitxEvent.PagedCandidateEvent.Data.Empty + + /** + * horizontal, bottom, top + */ + private val anchorPosition = floatArrayOf(0f, 0f, 0f) + private val parentSize = floatArrayOf(0f, 0f) + + private var shouldUpdatePosition = false + + /** + * layout update may or may not cause [CandidatesView]'s size [onSizeChanged], + * in either case, we should reposition it + */ + private val layoutListener = OnGlobalLayoutListener { + shouldUpdatePosition = true + } + + /** + * [CandidatesView]'s position is calculated based on it's size, + * so we need to recalculate the position after layout, + * and before any actual drawing to avoid flicker + */ + private val preDrawListener = OnPreDrawListener { + if (shouldUpdatePosition) { + updatePosition() + } + true + } + + private val touchEventReceiverWindow = TouchEventReceiverWindow(this) + + private val setupTextView: TextView.() -> Unit = { + textSize = fontSize.toFloat() + val v = dp(itemPaddingVertical) + val h = dp(itemPaddingHorizontal) + setPadding(h, v, h, v) + } + + private val preeditUi = PreeditUi(ctx, theme, setupTextView) + + private val candidatesUi = PagedCandidatesUi( + ctx, theme, setupTextView, + onCandidateClick = { index -> fcitx.launchOnReady { it.select(index) } }, + onPrevPage = { fcitx.launchOnReady { it.offsetCandidatePage(-1) } }, + onNextPage = { fcitx.launchOnReady { it.offsetCandidatePage(1) } } + ) + + private var bottomInsets = 0 + + override fun handleFcitxEvent(it: FcitxEvent<*>) { + when (it) { + is FcitxEvent.InputPanelEvent -> { + inputPanel = it.data + updateUi() + } + is FcitxEvent.PagedCandidateEvent -> { + paged = it.data + updateUi() + } + else -> {} + } + } + + private fun evaluateVisibility(): Boolean { + return inputPanel.preedit.isNotEmpty() || + paged.candidates.isNotEmpty() || + inputPanel.auxUp.isNotEmpty() || + inputPanel.auxDown.isNotEmpty() + } + + private fun updateUi() { + preeditUi.update(inputPanel) + preeditUi.root.visibility = if (preeditUi.visible) VISIBLE else GONE + candidatesUi.update(paged, orientation) + if (evaluateVisibility()) { + visibility = VISIBLE + } else { + // RecyclerView won't update its items when ancestor view is GONE + visibility = INVISIBLE + touchEventReceiverWindow.dismiss() + } + } + + private fun updatePosition() { + if (visibility != VISIBLE) { + // skip unnecessary updates + return + } + val (parentWidth, parentHeight) = parentSize + if (parentWidth <= 0 || parentHeight <= 0) { + // panic, bail + translationX = 0f + translationY = 0f + return + } + val (horizontal, bottom, top) = anchorPosition + val w: Int = width + val h: Int = height + val selfWidth = w.toFloat() + val selfHeight = h.toFloat() + val tX: Float = if (layoutDirection == LAYOUT_DIRECTION_RTL) { + val rtlOffset = parentWidth - horizontal + if (rtlOffset + selfWidth > parentWidth) selfWidth - parentWidth else -rtlOffset + } else { + if (horizontal + selfWidth > parentWidth) parentWidth - selfWidth else horizontal + } + val bottomLimit = parentHeight - bottomInsets + val bottomSpace = bottomLimit - bottom + // move CandidatesView above cursor anchor, only when + val tY: Float = if ( + bottom + selfHeight > bottomLimit // bottom space is not enough + && top > bottomSpace // top space is larger than bottom + ) top - selfHeight else bottom + translationX = tX + translationY = tY + // update touchEventReceiverWindow's position after CandidatesView's + touchEventReceiverWindow.showAt(tX.roundToInt(), tY.roundToInt(), w, h) + shouldUpdatePosition = false + } + + fun updateCursorAnchor(@Size(4) anchor: FloatArray, @Size(2) parent: FloatArray) { + val (horizontal, bottom, _, top) = anchor + val (parentWidth, parentHeight) = parent + anchorPosition[0] = horizontal + anchorPosition[1] = bottom + anchorPosition[2] = top + parentSize[0] = parentWidth + parentSize[1] = parentHeight + updatePosition() + } + + init { + // invisible by default + visibility = INVISIBLE + + minWidth = dp(windowMinWidth) + padding = dp(windowPadding) + background = GradientDrawable().apply { + setColor(theme.backgroundColor) + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(windowRadius).toFloat() + } + clipToOutline = true + outlineProvider = ViewOutlineProvider.BACKGROUND + add(preeditUi.root, lParams(wrapContent, wrapContent) { + topOfParent() + startOfParent() + }) + add(candidatesUi.root, lParams(matchConstraints, wrapContent) { + matchConstraintMinWidth = wrapContent + below(preeditUi.root) + centerHorizontally() + bottomOfParent() + }) + + isFocusable = false + layoutParams = ViewGroup.LayoutParams(wrapContent, wrapContent) + } + + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + bottomInsets = getNavBarBottomInset(insets) + } + return insets + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + candidatesUi.root.viewTreeObserver.addOnGlobalLayoutListener(layoutListener) + viewTreeObserver.addOnPreDrawListener(preDrawListener) + } + + override fun onDetachedFromWindow() { + viewTreeObserver.removeOnPreDrawListener(preDrawListener) + candidatesUi.root.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener) + touchEventReceiverWindow.dismiss() + super.onDetachedFromWindow() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt index 78acd8624..b417f5a64 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/FcitxInputMethodService.kt @@ -1,10 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input import android.annotation.SuppressLint +import android.app.Dialog import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color @@ -20,6 +22,7 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup import android.view.Window +import android.view.WindowManager import android.view.inputmethod.CursorAnchorInfo import android.view.inputmethod.EditorInfo import android.view.inputmethod.InlineSuggestionsRequest @@ -36,7 +39,6 @@ import androidx.autofill.inline.common.ViewStyle import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -46,22 +48,28 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.CapabilityFlags import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.core.FcitxKeyMapping import org.fcitx.fcitx5.android.core.FormattedText import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.core.KeySym +import org.fcitx.fcitx5.android.core.ScancodeMapping import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.FcitxDaemon import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.cursor.CursorRange import org.fcitx.fcitx5.android.input.cursor.CursorTracker import org.fcitx.fcitx5.android.utils.InputMethodUtil import org.fcitx.fcitx5.android.utils.alpha +import org.fcitx.fcitx5.android.utils.forceShowSelf import org.fcitx.fcitx5.android.utils.inputMethodManager +import org.fcitx.fcitx5.android.utils.monitorCursorAnchor +import org.fcitx.fcitx5.android.utils.styledFloat import org.fcitx.fcitx5.android.utils.withBatchEdit import splitties.bitflags.hasFlag import splitties.dimensions.dp @@ -69,7 +77,6 @@ import splitties.resources.styledColor import timber.log.Timber import kotlin.math.max - class FcitxInputMethodService : LifecycleInputMethodService() { private lateinit var fcitx: FcitxConnection @@ -81,7 +88,16 @@ class FcitxInputMethodService : LifecycleInputMethodService() { private lateinit var pkgNameCache: PackageNameCache + private lateinit var decorView: View + private lateinit var contentView: FrameLayout private var inputView: InputView? = null + private var candidatesView: CandidatesView? = null + + private val navbarMgr = NavigationBarManager() + private val inputDeviceMgr = InputDeviceManager onChange@{ + val w = window.window ?: return@onChange + navbarMgr.evaluate(w, useVirtualKeyboard = it) + } private var capabilityFlags = CapabilityFlags.DefaultFlags @@ -102,33 +118,56 @@ class FcitxInputMethodService : LifecycleInputMethodService() { private var highlightColor: Int = 0x66008577 // material_deep_teal_500 with alpha 0.4 - private val ignoreSystemCursor by AppPrefs.getInstance().advanced.ignoreSystemCursor + private val prefs = AppPrefs.getInstance() + private val inlineSuggestions by prefs.keyboard.inlineSuggestions + private val ignoreSystemCursor by prefs.advanced.ignoreSystemCursor - private val inlineSuggestions by AppPrefs.getInstance().keyboard.inlineSuggestions + private val recreateInputViewPrefs: Array> = arrayOf( + prefs.keyboard.expandKeypressArea, + prefs.advanced.disableAnimation, + prefs.advanced.ignoreSystemWindowInsets, + ) - @Keep - private val recreateInputViewListener = ManagedPreference.OnChangeListener { _, _ -> - recreateInputView(ThemeManager.activeTheme) + private fun replaceInputView(theme: Theme): InputView { + val newInputView = InputView(this, fcitx, theme) + setInputView(newInputView) + inputDeviceMgr.setInputView(newInputView) + navbarMgr.setupInputView(newInputView) + inputView = newInputView + return newInputView + } + + private fun replaceCandidateView(theme: Theme): CandidatesView { + val newCandidatesView = CandidatesView(this, fcitx, theme) + // replace CandidatesView manually + contentView.removeView(candidatesView) + // put CandidatesView directly under content view + contentView.addView(newCandidatesView) + inputDeviceMgr.setCandidatesView(newCandidatesView) + navbarMgr.setupInputView(newCandidatesView) + candidatesView = newCandidatesView + return newCandidatesView + } + + private fun replaceInputViews(theme: Theme) { + navbarMgr.evaluate(window.window!!) + replaceInputView(theme) + replaceCandidateView(theme) } @Keep - private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { - recreateInputView(it) + private val recreateInputViewListener = ManagedPreference.OnChangeListener { _, _ -> + replaceInputView(ThemeManager.activeTheme) } - private fun recreateInputView(theme: Theme) { - // InputView should be created first in onCreateInputView - // setInputView should be used to 'replace' current InputView only - InputView(this, fcitx, theme).also { - inputView = it - setInputView(it) - } + @Keep + private val recreateCandidatesViewListener = ManagedPreferenceProvider.OnChangeListener { + replaceCandidateView(ThemeManager.activeTheme) } - private fun postJob(scope: CoroutineScope, block: suspend () -> Unit): Job { - val job = scope.launch(start = CoroutineStart.LAZY) { block() } - jobs.trySend(job) - return job + @Keep + private val onThemeChangeListener = ThemeManager.OnThemeChangeListener { + replaceInputViews(it) } /** @@ -138,8 +177,13 @@ class FcitxInputMethodService : LifecycleInputMethodService() { * subsequent operations can start if the prior operation is not finished (suspended), * [postFcitxJob] ensures that operations are executed sequentially. */ - fun postFcitxJob(block: suspend FcitxAPI.() -> Unit) = - postJob(fcitx.lifecycleScope) { fcitx.runOnReady(block) } + fun postFcitxJob(block: suspend FcitxAPI.() -> Unit): Job { + val job = fcitx.lifecycleScope.launch(start = CoroutineStart.LAZY) { + fcitx.runOnReady(block) + } + jobs.trySend(job) + return job + } override fun onCreate() { fcitx = FcitxDaemon.connect(javaClass.name) @@ -152,10 +196,10 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } } pkgNameCache = PackageNameCache(this) - AppPrefs.getInstance().apply { - keyboard.expandKeypressArea.registerOnChangeListener(recreateInputViewListener) - advanced.disableAnimation.registerOnChangeListener(recreateInputViewListener) + recreateInputViewPrefs.forEach { + it.registerOnChangeListener(recreateInputViewListener) } + prefs.candidates.registerOnChangeListener(recreateCandidatesViewListener) ThemeManager.addOnChangedListener(onThemeChangeListener) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { postFcitxJob { @@ -163,6 +207,8 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } } super.onCreate() + decorView = window.window!!.decorView + contentView = decorView.findViewById(android.R.id.content) } private fun handleFcitxEvent(event: FcitxEvent<*>) { @@ -173,10 +219,16 @@ class FcitxInputMethodService : LifecycleInputMethodService() { is FcitxEvent.KeyEvent -> event.data.let event@{ if (it.states.virtual) { // KeyEvent from virtual keyboard - when (it.unicode) { - '\b'.code -> handleBackspaceKey() - '\r'.code -> handleReturnKey() - else -> commitText(Char(it.unicode).toString()) + when (it.sym.sym) { + FcitxKeyMapping.FcitxKey_BackSpace -> handleBackspaceKey() + FcitxKeyMapping.FcitxKey_Return -> handleReturnKey() + FcitxKeyMapping.FcitxKey_Left -> sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_LEFT) + FcitxKeyMapping.FcitxKey_Right -> sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_RIGHT) + else -> if (it.unicode > 0) { + commitText(Character.toString(it.unicode)) + } else { + Timber.w("Unhandled Virtual KeyEvent: $it") + } } } else { // KeyEvent from physical keyboard (or input method engine forwardKey) @@ -198,7 +250,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } else { // no matching keyCode, commit character once on key down if (!it.up && it.unicode > 0) { - commitText(Char(it.unicode).toString()) + commitText(Character.toString(it.unicode)) } else { Timber.w("Unhandled Fcitx KeyEvent: $it") } @@ -333,7 +385,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { 0, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, + ScancodeMapping.keyCodeToScancode(keyEventCode), KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE ) ) @@ -349,7 +401,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { 0, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, - 0, + ScancodeMapping.keyCodeToScancode(keyEventCode), KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE ) ) @@ -409,31 +461,35 @@ class FcitxInputMethodService : LifecycleInputMethodService() { override fun onWindowShown() { super.onWindowShown() + try { + highlightColor = styledColor(android.R.attr.colorAccent).alpha(0.4f) + } catch (_: Exception) { + Timber.w("Device does not support android.R.attr.colorAccent which it should have.") + } InputFeedbacks.syncSystemPrefs() + // navbar foreground/background color would reset every time window shows + navbarMgr.update(window.window!!) } - override fun onCreateInputView(): View { - // onCreateInputView will be called once, when the input area is first displayed, - // during each onConfigurationChanged period. - // That is, onCreateInputView would be called again, after system dark mode changes, - // or screen orientation changes. - return InputView(this, fcitx, ThemeManager.activeTheme).also { - inputView = it - } + override fun onCreateInputView(): View? { + replaceInputViews(ThemeManager.activeTheme) + // We will call `setInputView` by ourselves. This is fine. + return null } override fun setInputView(view: View) { - try { - highlightColor = view.styledColor(android.R.attr.colorAccent).alpha(0.4f) - } catch (e: Exception) { - Timber.w("Device does not support android.R.attr.colorAccent which it should have.") - } - window.window!!.decorView - .findViewById(android.R.id.inputArea) + super.setInputView(view) + // input method layout has not changed in 11 years: + // https://android.googlesource.com/platform/frameworks/base/+/ae3349e1c34f7aceddc526cd11d9ac44951e97b6/core/res/res/layout/input_method.xml + // expand inputArea to fullscreen + contentView.findViewById(android.R.id.inputArea) .updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } - super.setInputView(view) + /** + * expand InputView to fullscreen, since [android.inputmethodservice.InputMethodService.setInputView] + * would set InputView's height to [ViewGroup.LayoutParams.WRAP_CONTENT] + */ view.updateLayoutParams { height = ViewGroup.LayoutParams.MATCH_PARENT } @@ -443,21 +499,33 @@ class FcitxInputMethodService : LifecycleInputMethodService() { win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) } + private var inputViewLocation = intArrayOf(0, 0) + override fun onComputeInsets(outInsets: Insets) { - val (_, y) = intArrayOf(0, 0).also { inputView?.keyboardView?.getLocationInWindow(it) } - outInsets.apply { - contentTopInsets = y - touchableInsets = Insets.TOUCHABLE_INSETS_CONTENT - touchableRegion.setEmpty() - visibleTopInsets = y + if (inputDeviceMgr.isVirtualKeyboard) { + inputView?.keyboardView?.getLocationInWindow(inputViewLocation) + outInsets.apply { + contentTopInsets = inputViewLocation[1] + visibleTopInsets = inputViewLocation[1] + touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + } + } else { + val n = decorView.findViewById(android.R.id.navigationBarBackground)?.height ?: 0 + val h = decorView.height - n + outInsets.apply { + contentTopInsets = h + visibleTopInsets = h + touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE + } } } - // TODO: candidate view for physical keyboard input - // always show InputView since we do not support physical keyboard input without it yet + // always show InputView since we delegate CandidatesView's visibility to it @SuppressLint("MissingSuperCall") override fun onEvaluateInputViewShown() = true + fun superEvaluateInputViewShown() = super.onEvaluateInputViewShown() + override fun onEvaluateFullscreenMode() = false private fun forwardKeyEvent(event: KeyEvent): Boolean { @@ -471,16 +539,21 @@ class FcitxInputMethodService : LifecycleInputMethodService() { // try send charCode first, allow upper case and lower case character generating different KeySym // skip \t, because it's charCode is different from KeySym // skip \n, because fcitx wants \r for return - if (charCode > 0 && charCode != '\t'.code && charCode != '\n'.code) { + // skip ' ', because it would produce same KeySym regardless of the modifier + if (charCode > 0 && charCode != '\t'.code && charCode != '\n'.code && charCode != ' '.code) { + // drop modifier state when using combination keys to input number/symbol on some phones + // because fcitx doesn't recognize selection key with modifiers (eg. Alt+Q for 1) + // in which case event.getNumber().toInt() == event.getUnicodeChar() + val s = if (event.number.code == charCode) KeyStates.Empty else states postFcitxJob { - sendKey(charCode, states.states, up, timestamp) + sendKey(charCode, s.states, event.scanCode, up, timestamp) } return true } val keySym = KeySym.fromKeyEvent(event) if (keySym != null) { postFcitxJob { - sendKey(keySym, states, up, timestamp) + sendKey(keySym, states, event.scanCode, up, timestamp) } return true } @@ -489,6 +562,13 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // request to show floating CandidatesView when pressing physical keyboard + if (inputDeviceMgr.evaluateOnKeyDown(event, this)) { + postFcitxJob { + focus(true) + } + forceShowSelf() + } return forwardKeyEvent(event) || super.onKeyDown(keyCode, event) } @@ -496,6 +576,21 @@ class FcitxInputMethodService : LifecycleInputMethodService() { return forwardKeyEvent(event) || super.onKeyUp(keyCode, event) } + // Added in API level 14, deprecated in 29 + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onViewClicked(focusChanged: Boolean) { + super.onViewClicked(focusChanged) + if (Build.VERSION.SDK_INT < 34) { + inputDeviceMgr.evaluateOnViewClicked(this) + } + } + + @RequiresApi(34) + override fun onUpdateEditorToolType(toolType: Int) { + super.onUpdateEditorToolType(toolType) + inputDeviceMgr.evaluateOnUpdateEditorToolType(toolType, this) + } + private var firstBindInput = true override fun onBindInput() { @@ -578,9 +673,20 @@ class FcitxInputMethodService : LifecycleInputMethodService() { postFcitxJob { focus(true) } - // because onStartInputView will always be called after onStartInput, - // editorInfo and capFlags should be up-to-date - inputView?.startInput(info, capabilityFlags, restarting) + if (inputDeviceMgr.evaluateOnStartInputView(info, this)) { + // because onStartInputView will always be called after onStartInput, + // editorInfo and capFlags should be up-to-date + inputView?.startInput(info, capabilityFlags, restarting) + } else { + if (currentInputConnection?.monitorCursorAnchor() != true) { + if (!decorLocationUpdated) { + updateDecorLocation() + } + // anchor CandidatesView to bottom-left corner in case InputConnection does not + // support monitoring CursorAnchorInfo + workaroundNullCursorAnchorInfo() + } + } } override fun onUpdateSelection( @@ -598,8 +704,70 @@ class FcitxInputMethodService : LifecycleInputMethodService() { inputView?.updateSelection(newSelStart, newSelEnd) } + private val contentSize = floatArrayOf(0f, 0f) + private val decorLocation = floatArrayOf(0f, 0f) + private val decorLocationInt = intArrayOf(0, 0) + private var decorLocationUpdated = false + + private fun updateDecorLocation() { + contentSize[0] = contentView.width.toFloat() + contentSize[1] = contentView.height.toFloat() + decorView.getLocationOnScreen(decorLocationInt) + decorLocation[0] = decorLocationInt[0].toFloat() + decorLocation[1] = decorLocationInt[1].toFloat() + // contentSize and decorLocation can be completely wrong, + // when measuring right after the very first onStartInputView() of an IMS' lifecycle + if (contentSize[0] > 0 && contentSize[1] > 0) { + decorLocationUpdated = true + } + } + + private val anchorPosition = floatArrayOf(0f, 0f, 0f, 0f) + + /** + * anchor candidates view to bottom-left corner, only works if [decorLocationUpdated] + */ + private fun workaroundNullCursorAnchorInfo() { + anchorPosition[0] = 0f + anchorPosition[1] = contentSize[1] + anchorPosition[2] = 0f + anchorPosition[3] = contentSize[1] + candidatesView?.updateCursorAnchor(anchorPosition, contentSize) + } + override fun onUpdateCursorAnchorInfo(info: CursorAnchorInfo) { - // CursorAnchorInfo focus more on screen coordinates rather than selection + val bounds = info.getCharacterBounds(0) + if (bounds != null) { + // anchor to start of composing span instead of insertion mark if available + val horizontal = + if (candidatesView?.layoutDirection == View.LAYOUT_DIRECTION_RTL) bounds.right else bounds.left + anchorPosition[0] = horizontal + anchorPosition[1] = bounds.bottom + anchorPosition[2] = horizontal + anchorPosition[3] = bounds.top + } else { + anchorPosition[0] = info.insertionMarkerHorizontal + anchorPosition[1] = info.insertionMarkerBottom + anchorPosition[2] = info.insertionMarkerHorizontal + anchorPosition[3] = info.insertionMarkerTop + } + // avoid calling `decorView.getLocationOnScreen` repeatedly + if (!decorLocationUpdated) { + updateDecorLocation() + } + if (anchorPosition.any(Float::isNaN)) { + // anchor candidates view to bottom-left corner in case CursorAnchorInfo is invalid + workaroundNullCursorAnchorInfo() + return + } + // params of `Matrix.mapPoints` must be [x0, y0, x1, y1] + info.matrix.mapPoints(anchorPosition) + val (xOffset, yOffset) = decorLocation + anchorPosition[0] -= xOffset + anchorPosition[1] -= yOffset + anchorPosition[2] -= xOffset + anchorPosition[3] -= yOffset + candidatesView?.updateCursorAnchor(anchorPosition, contentSize) } private fun handleCursorUpdate(newSelStart: Int, newSelEnd: Int, updateIndex: Int) { @@ -651,7 +819,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } // because setComposingText(text, cursor) can only put cursor at end of composing, - // sometimes onUpdateCursorAnchorInfo/onUpdateSelection would receive event with wrong cursor position. + // sometimes onUpdateSelection would receive event with wrong cursor position. // those events need to be filtered. // because of https://android.googlesource.com/platform/frameworks/base.git/+/refs/tags/android-11.0.0_r45/core/java/android/view/inputmethod/BaseInputConnection.java#851 // it's not possible to set cursor inside composing text @@ -659,7 +827,18 @@ class FcitxInputMethodService : LifecycleInputMethodService() { val ic = currentInputConnection ?: return val lastSelection = selection.latest ic.beginBatchEdit() - if (!composingText.spanEquals(text)) { + if (composingText.spanEquals(text)) { + // composing text content is up-to-date + // update cursor only when it's not empty AND cursor position is valid + if (text.length > 0 && text.cursor >= 0) { + val p = text.cursor + composing.start + if (p != lastSelection.start) { + Timber.d("updateComposingText: set Android selection ($p, $p)") + ic.setSelection(p, p) + selection.predict(p) + } + } + } else { // composing text content changed Timber.d("updateComposingText: '$text' lastSelection=$lastSelection") if (text.isEmpty()) { @@ -691,17 +870,6 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } } Timber.d("updateComposingText: composing=$composing") - } else { - // composing text content is up-to-date - // update cursor only when it's not empty AND cursor position is valid - if (text.length > 0 && text.cursor >= 0) { - val p = text.cursor + composing.start - if (p != lastSelection.start) { - Timber.d("updateComposingText: set Android selection ($p, $p)") - ic.setSelection(p, p) - selection.predict(p) - } - } } composingText = text ic.endBatchEdit() @@ -722,7 +890,8 @@ class FcitxInputMethodService : LifecycleInputMethodService() { @SuppressLint("RestrictedApi") @RequiresApi(Build.VERSION_CODES.R) override fun onCreateInlineSuggestionsRequest(uiExtras: Bundle): InlineSuggestionsRequest? { - if (!inlineSuggestions) return null + // ignore inline suggestion when disabled by user || using physical keyboard with floating candidates view + if (!inlineSuggestions || !inputDeviceMgr.isVirtualKeyboard) return null val theme = ThemeManager.activeTheme val chipDrawable = if (theme.isDark) R.drawable.bkg_inline_suggestion_dark else R.drawable.bkg_inline_suggestion_light @@ -778,26 +947,23 @@ class FcitxInputMethodService : LifecycleInputMethodService() { @RequiresApi(Build.VERSION_CODES.R) override fun onInlineSuggestionsResponse(response: InlineSuggestionsResponse): Boolean { - if (!inlineSuggestions) return false - return inputView?.handleInlineSuggestions(response) ?: false - } - - fun nextInputMethodApp() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - switchToNextInputMethod(false) - } else { - @Suppress("DEPRECATION") - inputMethodManager.switchToNextInputMethod(window.window!!.attributes.token, false) - } + if (!inlineSuggestions || !inputDeviceMgr.isVirtualKeyboard) return false + return inputView?.handleInlineSuggestions(response) == true } override fun onFinishInputView(finishingInput: Boolean) { Timber.d("onFinishInputView: finishingInput=$finishingInput") - currentInputConnection?.finishComposingText() + decorLocationUpdated = false + inputDeviceMgr.onFinishInputView() + currentInputConnection?.apply { + finishComposingText() + monitorCursorAnchor(false) + } + resetComposingState() postFcitxJob { focus(false) } - inputView?.finishInput() + showingDialog?.dismiss() } override fun onFinishInput() { @@ -818,18 +984,39 @@ class FcitxInputMethodService : LifecycleInputMethodService() { } override fun onDestroy() { - AppPrefs.getInstance().apply { - keyboard.expandKeypressArea.unregisterOnChangeListener(recreateInputViewListener) - advanced.disableAnimation.unregisterOnChangeListener(recreateInputViewListener) + recreateInputViewPrefs.forEach { + it.unregisterOnChangeListener(recreateInputViewListener) } + prefs.candidates.unregisterOnChangeListener(recreateCandidatesViewListener) ThemeManager.removeOnChangedListener(onThemeChangeListener) super.onDestroy() // Fcitx might be used in super.onDestroy() FcitxDaemon.disconnect(javaClass.name) } + private var showingDialog: Dialog? = null + + fun showDialog(dialog: Dialog) { + showingDialog?.dismiss() + dialog.window?.also { + it.attributes.apply { + token = decorView.windowToken + type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG + } + it.addFlags( + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or WindowManager.LayoutParams.FLAG_DIM_BEHIND + ) + it.setDimAmount(styledFloat(android.R.attr.backgroundDimAmount)) + } + dialog.setOnDismissListener { + showingDialog = null + } + dialog.show() + showingDialog = dialog + } + + @Suppress("ConstPropertyName") companion object { const val DeleteSurroundingFlag = "org.fcitx.fcitx5.android.DELETE_SURROUNDING" } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt new file mode 100644 index 000000000..656f40dfd --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/InputDeviceManager.kt @@ -0,0 +1,146 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.text.InputType +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.input.candidates.floating.FloatingCandidatesMode +import org.fcitx.fcitx5.android.utils.monitorCursorAnchor + +class InputDeviceManager(private val onChange: (Boolean) -> Unit) { + + private var inputView: InputView? = null + private var candidatesView: CandidatesView? = null + + private fun setupInputViewEvents(isVirtual: Boolean) { + inputView?.handleEvents = isVirtual + inputView?.visibility = if (isVirtual) View.VISIBLE else View.GONE + } + + private fun setupCandidatesViewEvents(isVirtual: Boolean) { + candidatesView?.handleEvents = !isVirtual + // hide CandidatesView when entering virtual keyboard mode, + // but preserve the visibility when entering physical keyboard mode (in case it's empty) + if (isVirtual) { + candidatesView?.visibility = View.GONE + } + } + + private fun setupViewEvents(isVirtual: Boolean) { + setupInputViewEvents(isVirtual) + setupCandidatesViewEvents(isVirtual) + } + + var isVirtualKeyboard = true + private set(value) { + field = value + setupViewEvents(value) + } + + fun setInputView(inputView: InputView) { + this.inputView = inputView + setupInputViewEvents(this.isVirtualKeyboard) + } + + fun setCandidatesView(candidatesView: CandidatesView) { + this.candidatesView = candidatesView + setupCandidatesViewEvents(this.isVirtualKeyboard) + } + + private fun applyMode(service: FcitxInputMethodService, useVirtualKeyboard: Boolean) { + if (useVirtualKeyboard == isVirtualKeyboard) { + return + } + // monitor CursorAnchorInfo when switching to CandidatesView + service.currentInputConnection.monitorCursorAnchor(!useVirtualKeyboard) + service.postFcitxJob { + setCandidatePagingMode(if (useVirtualKeyboard) 0 else 1) + } + isVirtualKeyboard = useVirtualKeyboard + onChange(isVirtualKeyboard) + } + + private var startedInputView = false + private var isNullInputType = true + + private var candidatesViewMode by AppPrefs.getInstance().candidates.mode + + /** + * @return should use virtual keyboard + */ + fun evaluateOnStartInputView(info: EditorInfo, service: FcitxInputMethodService): Boolean { + startedInputView = true + isNullInputType = info.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_NULL + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> isVirtualKeyboard + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + return useVirtualKeyboard + } + + /** + * @return should force show input views + */ + fun evaluateOnKeyDown(e: KeyEvent, service: FcitxInputMethodService): Boolean { + if (startedInputView) { + // filter out back/home/volume buttons and combination keys + if (e.isPrintingKey && e.hasNoModifiers()) { + // evaluate virtual keyboard visibility when pressing physical keyboard while InputView visible + evaluateOnKeyDownInner(service) + } + // no need to force show InputView since it's already visible + return false + } else { + // force show InputView when focusing on text input (likely inputType is not TYPE_NULL) + // and pressing any digit/letter/punctuation key on physical keyboard + val showInputView = !isNullInputType && e.isPrintingKey && e.hasNoModifiers() + if (showInputView) { + evaluateOnKeyDownInner(service) + } + return showInputView + } + } + + private fun evaluateOnKeyDownInner(service: FcitxInputMethodService) { + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> false + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun evaluateOnViewClicked(service: FcitxInputMethodService) { + if (!startedInputView) return + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + else -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun evaluateOnUpdateEditorToolType(toolType: Int, service: FcitxInputMethodService) { + if (!startedInputView) return + val useVirtualKeyboard = when (candidatesViewMode) { + FloatingCandidatesMode.SystemDefault -> service.superEvaluateInputViewShown() + FloatingCandidatesMode.InputDevice -> + // switch to virtual keyboard on touch screen events, otherwise preserve current mode + if (toolType == MotionEvent.TOOL_TYPE_FINGER || toolType == MotionEvent.TOOL_TYPE_STYLUS) true else isVirtualKeyboard + FloatingCandidatesMode.Disabled -> true + } + applyMode(service, useVirtualKeyboard) + } + + fun onFinishInputView() { + startedInputView = false + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt index c6872b870..52680f473 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/InputView.kt @@ -1,46 +1,37 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input import android.annotation.SuppressLint -import android.app.Dialog import android.content.res.Configuration -import android.graphics.Color import android.os.Build import android.view.View import android.view.View.OnClickListener -import android.view.WindowManager +import android.view.WindowInsets import android.view.inputmethod.EditorInfo import android.view.inputmethod.InlineSuggestionsResponse import android.widget.ImageView import androidx.annotation.Keep import androidx.annotation.RequiresApi -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.CapabilityFlags import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.data.theme.ThemePrefs.NavbarBackground import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcaster import org.fcitx.fcitx5.android.input.broadcast.PreeditEmptyStateComponent import org.fcitx.fcitx5.android.input.broadcast.PunctuationComponent import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow import org.fcitx.fcitx5.android.input.picker.emojiPicker @@ -49,7 +40,6 @@ import org.fcitx.fcitx5.android.input.picker.symbolPicker import org.fcitx.fcitx5.android.input.popup.PopupComponent import org.fcitx.fcitx5.android.input.preedit.PreeditComponent import org.fcitx.fcitx5.android.input.wm.InputWindowManager -import org.fcitx.fcitx5.android.utils.styledFloat import org.fcitx.fcitx5.android.utils.unset import org.mechdancer.dependency.DynamicScope import org.mechdancer.dependency.manager.wrapToUniqueComponent @@ -77,16 +67,12 @@ import splitties.views.imageDrawable @SuppressLint("ViewConstructor") class InputView( - val service: FcitxInputMethodService, - val fcitx: FcitxConnection, - val theme: Theme -) : ConstraintLayout(service) { - - private var shouldUpdateNavbarForeground = false - private var shouldUpdateNavbarBackground = false + service: FcitxInputMethodService, + fcitx: FcitxConnection, + theme: Theme +) : BaseInputView(service, fcitx, theme) { private val keyBorder by ThemeManager.prefs.keyBorder - private val navbarBackground by ThemeManager.prefs.navbarBackground private val customBackground = imageView { scaleType = ImageView.ScaleType.CENTER_CROP @@ -107,8 +93,6 @@ class InputView( setOnClickListener(placeholderOnClickListener) } - private val eventHandlerJob: Job - private val scope = DynamicScope() private val themedContext = context.withTheme(R.style.Theme_InputViewTheme) private val broadcaster = InputBroadcaster() @@ -193,8 +177,10 @@ class InputView( } @Keep - private val onKeyboardSizeChangeListener = ManagedPreference.OnChangeListener { _, _ -> - updateKeyboardSize() + private val onKeyboardSizeChangeListener = ManagedPreferenceProvider.OnChangeListener { key -> + if (keyboardSizePrefs.any { it.key == key }) { + updateKeyboardSize() + } } val keyboardView: View @@ -203,67 +189,21 @@ class InputView( // MUST call before any operation setupScope() - eventHandlerJob = service.lifecycleScope.launch { - fcitx.runImmediately { eventFlow }.collect { - handleFcitxEvent(it) - } - } - // restore punctuation mapping in case of InputView recreation fcitx.launchOnReady { punctuation.updatePunctuationMapping(it.statusAreaActionsCached) } - keyboardSizePrefs.forEach { - it.registerOnChangeListener(onKeyboardSizeChangeListener) - } - // make sure KeyboardWindow's view has been created before it receives any broadcast windowManager.addEssentialWindow(keyboardWindow, createView = true) windowManager.addEssentialWindow(symbolPicker) windowManager.addEssentialWindow(emojiPicker) windowManager.addEssentialWindow(emoticonPicker) + // show KeyboardWindow by default + windowManager.attachWindow(KeyboardWindow) broadcaster.onImeUpdate(fcitx.runImmediately { inputMethodEntryCached }) - service.window.window!!.also { - when (navbarBackground) { - NavbarBackground.None -> { - WindowCompat.setDecorFitsSystemWindows(it, true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.isNavigationBarContrastEnforced = true - } - } - NavbarBackground.ColorOnly -> { - shouldUpdateNavbarForeground = true - shouldUpdateNavbarBackground = true - WindowCompat.setDecorFitsSystemWindows(it, true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - it.isNavigationBarContrastEnforced = false - } - } - NavbarBackground.Full -> { - shouldUpdateNavbarForeground = true - // allow draw behind navigation bar - WindowCompat.setDecorFitsSystemWindows(it, false) - // transparent navigation bar - it.navigationBarColor = Color.TRANSPARENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // don't apply scrim to transparent navigation bar - it.isNavigationBarContrastEnforced = false - } - ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> - insets.getInsets(WindowInsetsCompat.Type.navigationBars()).let { - bottomPaddingSpace.updateLayoutParams { - bottomMargin = it.bottom - } - } - WindowInsetsCompat.CONSUMED - } - } - } - } - customBackground.imageDrawable = theme.backgroundDrawable(keyBorder) keyboardView = constraintLayout { @@ -317,6 +257,8 @@ class InputView( centerVertically() centerHorizontally() }) + + keyboardPrefs.registerOnChangeListener(onKeyboardSizeChangeListener) } private fun updateKeyboardSize() { @@ -329,8 +271,8 @@ class InputView( val sidePadding = keyboardSidePaddingPx if (sidePadding == 0) { // hide side padding space views when unnecessary - leftPaddingSpace.visibility = View.GONE - rightPaddingSpace.visibility = View.GONE + leftPaddingSpace.visibility = GONE + rightPaddingSpace.visibility = GONE windowManager.view.updateLayoutParams { startToEnd = unset endToStart = unset @@ -338,8 +280,8 @@ class InputView( endOfParent() } } else { - leftPaddingSpace.visibility = View.VISIBLE - rightPaddingSpace.visibility = View.VISIBLE + leftPaddingSpace.visibility = VISIBLE + rightPaddingSpace.visibility = VISIBLE leftPaddingSpace.updateLayoutParams { width = sidePadding } @@ -357,26 +299,17 @@ class InputView( kawaiiBar.view.setPadding(sidePadding, 0, sidePadding, 0) } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + bottomPaddingSpace.updateLayoutParams { + bottomMargin = getNavBarBottomInset(insets) + } + return insets + } + /** * called when [InputView] is about to show, or restart */ fun startInput(info: EditorInfo, capFlags: CapabilityFlags, restarting: Boolean = false) { - if (!restarting) { - if (shouldUpdateNavbarForeground || shouldUpdateNavbarBackground) { - service.window.window!!.also { - if (shouldUpdateNavbarForeground) { - WindowCompat.getInsetsController(it, it.decorView) - .isAppearanceLightNavigationBars = !theme.isDark - } - if (shouldUpdateNavbarBackground) { - it.navigationBarColor = when (theme) { - is Theme.Builtin -> if (keyBorder) theme.backgroundColor else theme.keyboardColor - is Theme.Custom -> theme.backgroundColor - } - } - } - } - } broadcaster.onStartInput(info, capFlags) returnKeyDrawable.updateDrawableOnEditorInfo(info) if (focusChangeResetKeyboard || !restarting) { @@ -384,7 +317,7 @@ class InputView( } } - private fun handleFcitxEvent(it: FcitxEvent<*>) { + override fun handleFcitxEvent(it: FcitxEvent<*>) { when (it) { is FcitxEvent.CandidateListEvent -> { broadcaster.onCandidateUpdate(it.data) @@ -412,49 +345,14 @@ class InputView( broadcaster.onSelectionUpdate(start, end) } - private var showingDialog: Dialog? = null - - fun showDialog(dialog: Dialog) { - showingDialog?.dismiss() - val windowToken = windowToken - check(windowToken != null) { "InputView Token is null." } - val window = dialog.window!! - window.attributes.apply { - token = windowToken - type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG - } - window.addFlags( - WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM or - WindowManager.LayoutParams.FLAG_DIM_BEHIND - ) - window.setDimAmount(themedContext.styledFloat(android.R.attr.backgroundDimAmount)) - showingDialog = dialog.apply { - setOnDismissListener { this@InputView.showingDialog = null } - show() - } - } - - /** - * called when [InputView] is being hidden - */ - fun finishInput() { - showingDialog?.dismiss() - } - @RequiresApi(Build.VERSION_CODES.R) fun handleInlineSuggestions(response: InlineSuggestionsResponse): Boolean { return kawaiiBar.handleInlineSuggestions(response) } override fun onDetachedFromWindow() { - keyboardSizePrefs.forEach { - it.unregisterOnChangeListener(onKeyboardSizeChangeListener) - } - ViewCompat.setOnApplyWindowInsetsListener(this, null) - showingDialog?.dismiss() - // cancel eventHandlerJob and then clear DynamicScope, - // implies that InputView should not be attached again after detached. - eventHandlerJob.cancel() + keyboardPrefs.unregisterOnChangeListener(onKeyboardSizeChangeListener) + // clear DynamicScope, implies that InputView should not be attached again after detached. scope.clear() super.onDetachedFromWindow() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/NavigationBarManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/NavigationBarManager.kt new file mode 100644 index 000000000..6361670be --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/NavigationBarManager.kt @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.graphics.Color +import android.os.Build +import android.view.View +import android.view.Window +import androidx.annotation.ColorInt +import androidx.core.view.WindowCompat +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager +import org.fcitx.fcitx5.android.data.theme.ThemePrefs.NavbarBackground +import org.fcitx.fcitx5.android.utils.DeviceUtil + +class NavigationBarManager { + + private val keyBorder by ThemeManager.prefs.keyBorder + private val navbarBackground by ThemeManager.prefs.navbarBackground + + private var shouldUpdateNavbarForeground = false + private var shouldUpdateNavbarBackground = false + + private fun Window.useSystemNavbarBackground(enabled: Boolean) { + // 35+ enforces edge to edge and we must draw behind navbar + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + WindowCompat.setDecorFitsSystemWindows(this, enabled) + } + } + + private fun Window.setNavbarBackgroundColor(@ColorInt color: Int) { + /** + * Why on earth does it deprecated? It says + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-15.0.0_r3/core/java/android/view/Window.java#2720 + * "If the app targets VANILLA_ICE_CREAM or above, the color will be transparent and cannot be changed" + * but it only takes effect on API 35+ devices. Older devices still needs this. + */ + @Suppress("DEPRECATION") + navigationBarColor = color + } + + private fun Window.enforceNavbarContrast(enforced: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isNavigationBarContrastEnforced = enforced + } + } + + fun evaluate(window: Window) { + when (navbarBackground) { + NavbarBackground.None -> { + shouldUpdateNavbarForeground = false + shouldUpdateNavbarBackground = false + window.useSystemNavbarBackground(true) + window.enforceNavbarContrast(true) + } + NavbarBackground.ColorOnly -> { + shouldUpdateNavbarForeground = true + shouldUpdateNavbarBackground = true + window.useSystemNavbarBackground(true) + window.enforceNavbarContrast(false) + } + NavbarBackground.Full -> { + shouldUpdateNavbarForeground = true + shouldUpdateNavbarBackground = false + window.useSystemNavbarBackground(false) + window.setNavbarBackgroundColor(Color.TRANSPARENT) + window.enforceNavbarContrast(false) + // it seems One UI 7.0 (Android 15) does not allow drawing behind navbar + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && DeviceUtil.isSamsungOneUI) { + shouldUpdateNavbarBackground = true + } + } + } + } + + fun evaluate(window: Window, useVirtualKeyboard: Boolean) { + if (useVirtualKeyboard) { + evaluate(window) + } else { + shouldUpdateNavbarForeground = true + shouldUpdateNavbarBackground = true + window.useSystemNavbarBackground(true) + window.enforceNavbarContrast(false) + } + update(window) + } + + fun update(window: Window) { + val theme = ThemeManager.activeTheme + if (shouldUpdateNavbarForeground) { + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightNavigationBars = !theme.isDark + } + if (shouldUpdateNavbarBackground) { + window.setNavbarBackgroundColor( + if (!keyBorder && theme is Theme.Builtin) theme.keyboardColor else theme.backgroundColor + ) + } + } + + private val ignoreSystemWindowInsets by AppPrefs.getInstance().advanced.ignoreSystemWindowInsets + + private val emptyOnApplyWindowInsetsListener = View.OnApplyWindowInsetsListener { _, insets -> + insets + } + + fun setupInputView(v: BaseInputView) { + if (ignoreSystemWindowInsets) { + // suppress the view's own onApplyWindowInsets + v.setOnApplyWindowInsetsListener(emptyOnApplyWindowInsetsListener) + } else { + // on API 35+, we must call requestApplyInsets() manually after replacing views, + // otherwise View#onApplyWindowInsets won't be called. ¯\_(ツ)_/¯ + v.requestApplyInsets() + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/TouchEventReceiverWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/TouchEventReceiverWindow.kt new file mode 100644 index 000000000..58aa1fe56 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/TouchEventReceiverWindow.kt @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.widget.PopupWindow + +class TouchEventReceiverWindow( + private val contentView: View +) { + private val ctx = contentView.context + + private val window = PopupWindow(object : View(ctx) { + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + return contentView.dispatchTouchEvent(event) + } + }).apply { + // disable animation + animationStyle = 0 + } + + private var isWindowShowing = false + + fun showAt(x: Int, y: Int, w: Int, h: Int) { + isWindowShowing = true + if (window.isShowing) { + window.update(x, y, w, h) + } else { + window.width = w + window.height = h + window.showAtLocation(contentView, Gravity.TOP or Gravity.START, x, y) + } + } + + private val cachedLocation = intArrayOf(0, 0) + + fun show() { + val (x, y) = cachedLocation.also { contentView.getLocationInWindow(it) } + val width = contentView.width + val height = contentView.height + showAt(x, y, width, height) + } + + fun dismiss() { + if (isWindowShowing) { + isWindowShowing = false + window.dismiss() + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt index 7263f3849..3319ef324 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ExpandButtonStateMachine.kt @@ -40,7 +40,12 @@ object ExpandButtonStateMachine { } fun new(block: (State) -> Unit) = - EventStateMachine(Hidden).apply { + EventStateMachine( + initialState = Hidden, + externalBooleanStates = mutableMapOf( + ExpandedCandidatesEmpty to true + ) + ).apply { onNewStateListener = block } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt index 1e7dd375c..04d717186 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.bar @@ -47,10 +47,10 @@ import org.fcitx.fcitx5.android.input.bar.ui.CandidateUi import org.fcitx.fcitx5.android.input.bar.ui.IdleUi import org.fcitx.fcitx5.android.input.bar.ui.TitleUi import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateStyle import org.fcitx.fcitx5.android.input.candidates.expanded.window.FlexboxExpandedCandidateWindow import org.fcitx.fcitx5.android.input.candidates.expanded.window.GridExpandedCandidateWindow +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.clipboard.ClipboardWindow import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent import org.fcitx.fcitx5.android.input.dependency.context @@ -58,6 +58,7 @@ import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme import org.fcitx.fcitx5.android.input.editing.TextEditingWindow import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.input.keyboard.KeyboardWindow import org.fcitx.fcitx5.android.input.popup.PopupComponent import org.fcitx.fcitx5.android.input.status.StatusAreaWindow @@ -185,6 +186,13 @@ class KawaiiBarComponent : UniqueViewComponent( service.requestHideSelf(0) } + private val swipeDownHideKeyboardCallback = CustomGestureView.OnGestureListener { _, e -> + if (e.type == CustomGestureView.GestureType.Up && e.totalY > 0) { + service.requestHideSelf(0) + true + } else false + } + private var voiceInputSubtype: Pair? = null private val switchToVoiceInputCallback = View.OnClickListener { @@ -214,7 +222,12 @@ class KawaiiBarComponent : UniqueViewComponent( launchClipboardTimeoutJob() } } - hideKeyboardButton.setOnClickListener(hideKeyboardCallback) + hideKeyboardButton.apply { + setOnClickListener(hideKeyboardCallback) + swipeEnabled = true + swipeThresholdY = dp(HEIGHT.toFloat()) + onGestureListener = swipeDownHideKeyboardCallback + } buttonsUi.apply { undoButton.setOnClickListener { service.sendCombinationKeyEvents(KeyEvent.KEYCODE_Z, ctrl = true) @@ -253,7 +266,13 @@ class KawaiiBarComponent : UniqueViewComponent( } private val candidateUi by lazy { - CandidateUi(context, theme, horizontalCandidate.view) + CandidateUi(context, theme, horizontalCandidate.view).apply { + expandButton.apply { + swipeEnabled = true + swipeThresholdY = dp(HEIGHT.toFloat()) + onGestureListener = swipeDownHideKeyboardCallback + } + } } private val titleUi by lazy { @@ -291,6 +310,7 @@ class KawaiiBarComponent : UniqueViewComponent( ) } candidateUi.expandButton.setIcon(R.drawable.ic_baseline_expand_more_24) + candidateUi.expandButton.contentDescription = context.getString(R.string.expand_candidates_list) } // set expand candidate button to close expand candidate @@ -299,6 +319,7 @@ class KawaiiBarComponent : UniqueViewComponent( windowManager.attachWindow(KeyboardWindow) } candidateUi.expandButton.setIcon(R.drawable.ic_baseline_expand_less_24) + candidateUi.expandButton.contentDescription = context.getString(R.string.hide_candidates_list) } // should be used with setExpandButtonToAttach or setExpandButtonToDetach diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt index fc1db5cbf..2deed8025 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarStateMachine.kt @@ -6,7 +6,9 @@ package org.fcitx.fcitx5.android.input.bar import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.BooleanKey.CandidateEmpty import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.BooleanKey.PreeditEmpty -import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.* +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.Candidate +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.Idle +import org.fcitx.fcitx5.android.input.bar.KawaiiBarStateMachine.State.Title import org.fcitx.fcitx5.android.utils.BuildTransitionEvent import org.fcitx.fcitx5.android.utils.EventStateMachine import org.fcitx.fcitx5.android.utils.TransitionBuildBlock @@ -47,7 +49,11 @@ object KawaiiBarStateMachine { fun new(block: (State) -> Unit) = EventStateMachine( - Idle + initialState = Idle, + externalBooleanStates = mutableMapOf( + PreeditEmpty to true, + CandidateEmpty to true + ) ).apply { onNewStateListener = block } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt index 957bd83f6..c6cc8ab8d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.bar.ui @@ -130,6 +130,7 @@ class IdleUi( if (activate == inPrivate) return inPrivate = activate updateMenuButtonIcon() + updateMenuButtonContentDescription() updateMenuButtonRotation(instant = true) } @@ -139,6 +140,14 @@ class IdleUi( else R.drawable.ic_baseline_expand_more_24 } + private fun updateMenuButtonContentDescription() { + menuButton.contentDescription = when { + inPrivate -> ctx.getString(R.string.private_mode) + currentState == State.Toolbar -> ctx.getString(R.string.hide_toolbar) + else -> ctx.getString(R.string.expand_toolbar) + } + } + private fun updateMenuButtonRotation(instant: Boolean = false) { val targetRotation = menuButtonRotation menuButton.apply { @@ -153,10 +162,13 @@ class IdleUi( } fun setHideKeyboardIsVoiceInput(isVoiceInput: Boolean, callback: View.OnClickListener) { - hideKeyboardButton.setIcon( - if (isVoiceInput) R.drawable.ic_baseline_keyboard_voice_24 - else R.drawable.ic_baseline_arrow_drop_down_24 - ) + if (isVoiceInput) { + hideKeyboardButton.setIcon(R.drawable.ic_baseline_keyboard_voice_24) + hideKeyboardButton.contentDescription = ctx.getString(R.string.switch_to_voice_input) + } else { + hideKeyboardButton.setIcon(R.drawable.ic_baseline_arrow_drop_down_24) + hideKeyboardButton.contentDescription = ctx.getString(R.string.hide_keyboard) + } hideKeyboardButton.setOnClickListener(callback) } @@ -205,6 +217,7 @@ class IdleUi( popup.dismissAll() } currentState = state + updateMenuButtonContentDescription() updateMenuButtonRotation(instant = !fromUser) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt index 903f59cce..9f746dba6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/TitleUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.bar.ui @@ -29,7 +29,9 @@ import splitties.views.gravityVerticalCenter class TitleUi(override val ctx: Context, theme: Theme) : Ui { - private val backButton = ToolButton(ctx, R.drawable.ic_baseline_arrow_back_24, theme) + private val backButton = ToolButton(ctx, R.drawable.ic_baseline_arrow_back_24, theme).apply { + contentDescription = ctx.getString(R.string.back_to_keyboard) + } private val titleText = textView { typeface = Typeface.defaultFromStyle(Typeface.BOLD) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt index 87772f216..3170536fa 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/ButtonsBarUi.kt @@ -28,14 +28,24 @@ class ButtonsBarUi(override val ctx: Context, private val theme: Theme) : Ui { root.addView(it, FlexboxLayout.LayoutParams(size, size)) } - val undoButton = toolButton(R.drawable.ic_baseline_undo_24) + val undoButton = toolButton(R.drawable.ic_baseline_undo_24).apply { + contentDescription = ctx.getString(R.string.undo) + } - val redoButton = toolButton(R.drawable.ic_baseline_redo_24) + val redoButton = toolButton(R.drawable.ic_baseline_redo_24).apply { + contentDescription = ctx.getString(R.string.redo) + } - val cursorMoveButton = toolButton(R.drawable.ic_cursor_move) + val cursorMoveButton = toolButton(R.drawable.ic_cursor_move).apply { + contentDescription = ctx.getString(R.string.text_editing) + } - val clipboardButton = toolButton(R.drawable.ic_clipboard) + val clipboardButton = toolButton(R.drawable.ic_clipboard).apply { + contentDescription = ctx.getString(R.string.clipboard) + } - val moreButton = toolButton(R.drawable.ic_baseline_more_horiz_24) + val moreButton = toolButton(R.drawable.ic_baseline_more_horiz_24).apply { + contentDescription = ctx.getString(R.string.status_area) + } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt index 17c9e0a80..e927b15fb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateItemUi.kt @@ -2,19 +2,14 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates import android.content.Context -import android.widget.PopupMenu -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.color -import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.AutoScaleTextView import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView import org.fcitx.fcitx5.android.utils.pressHighlightDrawable -import splitties.resources.styledColor import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.lParams @@ -30,46 +25,19 @@ class CandidateItemUi(override val ctx: Context, theme: Theme) : Ui { textSize = 20f // sp isSingleLine = true gravity = gravityCenter - setTextColor(theme.keyTextColor) + setTextColor(theme.candidateTextColor) } override val root = view(::CustomGestureView) { background = pressHighlightDrawable(theme.keyPressHighlightColor) + /** + * candidate long press feedback is handled by [org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent.showCandidateActionMenu] + */ + longPressFeedbackEnabled = false + add(text, lParams(wrapContent, matchParent) { gravity = gravityCenter }) } - - private var promptMenu: PopupMenu? = null - - fun showExtraActionMenu(onForget: () -> Unit) { - promptMenu?.dismiss() - promptMenu = PopupMenu(ctx, root).apply { - menu.apply { - add(buildSpannedString { - bold { - color(ctx.styledColor(android.R.attr.colorAccent)) { - append(text.text.toString()) - } - } - }).apply { - isEnabled = false - } - add(R.string.action_forget_candidate_word).apply { - setOnMenuItemClickListener { - onForget() - true - } - } - add(android.R.string.cancel).apply { - setOnMenuItemClickListener { true } - } - } - setOnDismissListener { - if (it === promptMenu) promptMenu = null - } - show() - } - } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewHolder.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewHolder.kt new file mode 100644 index 000000000..1bde332c6 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/CandidateViewHolder.kt @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates + +import androidx.recyclerview.widget.RecyclerView + +class CandidateViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { + var idx = -1 + var text = "" +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt deleted file mode 100644 index cd17a3308..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateMode.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors - */ -package org.fcitx.fcitx5.android.input.candidates - -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference - -enum class HorizontalCandidateMode { - NeverFillWidth, - AutoFillWidth, - AlwaysFillWidth; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): HorizontalCandidateMode = valueOf(raw) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt index 87610a583..2b5f7c05c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidatesPagingSource.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.candidates.expanded @@ -13,7 +13,7 @@ class CandidatesPagingSource(val fcitx: FcitxConnection, val total: Int, val off PagingSource() { override suspend fun load(params: LoadParams): LoadResult { - // use candidate index for key, null means load from beginning (including offset) + // use candidate index for key, null means load from beginning (with offset) val startIndex = params.key ?: offset val pageSize = params.loadSize Timber.d("getCandidates(offset=$startIndex, limit=$pageSize)") diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt index e6957d68a..a4151e09f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.candidates.expanded @@ -10,7 +10,12 @@ import androidx.constraintlayout.widget.ConstraintLayout import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager -import org.fcitx.fcitx5.android.input.keyboard.* +import org.fcitx.fcitx5.android.input.keyboard.BackspaceKey +import org.fcitx.fcitx5.android.input.keyboard.BaseKeyboard +import org.fcitx.fcitx5.android.input.keyboard.ImageKeyView +import org.fcitx.fcitx5.android.input.keyboard.ImageLayoutSwitchKey +import org.fcitx.fcitx5.android.input.keyboard.KeyDef +import org.fcitx.fcitx5.android.input.keyboard.ReturnKey import splitties.views.backgroundColor import splitties.views.dsl.constraintlayout.bottomOfParent import splitties.views.dsl.constraintlayout.lParams diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt index cb125c2f2..65e6cd2e5 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateStyle.kt @@ -4,13 +4,10 @@ */ package org.fcitx.fcitx5.android.input.candidates.expanded -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class ExpandedCandidateStyle { - Grid, - Flexbox; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): ExpandedCandidateStyle = valueOf(raw) - } -} \ No newline at end of file +enum class ExpandedCandidateStyle(override val stringRes: Int) : ManagedPreferenceEnum { + Grid(R.string.expanded_candidate_style_grid), + Flexbox(R.string.expanded_candidate_style_flexbox); +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt similarity index 53% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt index 6472c2bdb..f60ec40f2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/GridPagingCandidateViewAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/GridPagingCandidateViewAdapter.kt @@ -1,8 +1,9 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates.adapter + +package org.fcitx.fcitx5.android.input.candidates.expanded import android.graphics.Paint import android.graphics.Rect @@ -10,27 +11,33 @@ import android.util.LruCache import android.view.ViewGroup import androidx.recyclerview.widget.GridLayoutManager import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import splitties.dimensions.dp import splitties.views.dsl.core.matchParent abstract class GridPagingCandidateViewAdapter(theme: Theme) : PagingCandidateViewAdapter(theme) { + companion object { + // 20f here is chosen randomly, since we only care about the ratio + private const val TEXT_SIZE = 20f + } + // cache measureWidth - private val measuredWidths = LruCache(200) + private val measuredWidths = object : LruCache(200) { + private val cachedPaint = Paint().apply { textSize = TEXT_SIZE } + private val cachedRect = Rect() + override fun create(key: String): Float { + cachedPaint.getTextBounds(key, 0, key.length, cachedRect) + return cachedRect.width() / TEXT_SIZE + } + } fun measureWidth(position: Int): Float { val candidate = getItem(position) ?: return 0f - return measuredWidths[candidate] ?: run { - val paint = Paint() - val bounds = Rect() - // 20f here is chosen randomly, since we only care about the ratio - paint.textSize = 20f - paint.getTextBounds(candidate, 0, candidate.length, bounds) - (bounds.width() / 20f).also { measuredWidths.put(candidate, it) } - } + return measuredWidths[candidate] } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { return super.onCreateViewHolder(parent, viewType).apply { itemView.apply { layoutParams = GridLayoutManager.LayoutParams(matchParent, dp(40)) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/PagingCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt similarity index 64% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/PagingCandidateViewAdapter.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt index e3615e878..c7d4d556d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/PagingCandidateViewAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/PagingCandidateViewAdapter.kt @@ -1,18 +1,19 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates.adapter + +package org.fcitx.fcitx5.android.input.candidates.expanded import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder open class PagingCandidateViewAdapter(val theme: Theme) : - PagingDataAdapter(diffCallback) { + PagingDataAdapter(diffCallback) { companion object { private val diffCallback = object : DiffUtil.ItemCallback() { @@ -26,10 +27,6 @@ open class PagingCandidateViewAdapter(val theme: Theme) : } } - class ViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { - var idx = -1 - } - var offset = 0 private set @@ -38,12 +35,14 @@ open class PagingCandidateViewAdapter(val theme: Theme) : refresh() } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(CandidateItemUi(parent.context, theme)) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { + return CandidateViewHolder(CandidateItemUi(parent.context, theme)) } - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.text.text = getItem(position)!! + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + val text = getItem(position)!! + holder.ui.text.text = text + holder.text = text holder.idx = position + offset } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt index 2997b4acb..c739139ea 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/SpanHelper.kt @@ -2,10 +2,10 @@ * SPDX-License-Identifier: LGPL-2.1-or-later * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded import androidx.recyclerview.widget.GridLayoutManager -import org.fcitx.fcitx5.android.input.candidates.adapter.GridPagingCandidateViewAdapter import kotlin.math.ceil import kotlin.math.max import kotlin.math.min diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt index a5ba98735..8b87ab58c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt @@ -1,20 +1,19 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape import android.view.View -import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.recyclerview.widget.RecyclerView -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs @@ -24,10 +23,11 @@ import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEve import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent -import org.fcitx.fcitx5.android.input.candidates.adapter.PagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.CandidatesPagingSource import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout +import org.fcitx.fcitx5.android.input.candidates.expanded.PagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme @@ -55,7 +55,6 @@ abstract class BaseExpandedCandidateWindow> : protected val disableAnimation by AppPrefs.getInstance().advanced.disableAnimation - private lateinit var lifecycleCoroutineScope: LifecycleCoroutineScope private lateinit var candidateLayout: ExpandedCandidateLayout protected val dividerDrawable by lazy { @@ -96,13 +95,19 @@ abstract class BaseExpandedCandidateWindow> : private var offsetJob: Job? = null private val candidatesPager by lazy { - Pager(PagingConfig(pageSize = 48)) { - CandidatesPagingSource( - fcitx, - total = horizontalCandidate.adapter.total, - offset = adapter.offset - ) - } + Pager( + config = PagingConfig( + pageSize = 48, + enablePlaceholders = false + ), + pagingSourceFactory = { + CandidatesPagingSource( + fcitx, + total = horizontalCandidate.adapter.total, + offset = adapter.offset + ) + } + ) } private var candidatesSubmitJob: Job? = null @@ -111,49 +116,35 @@ abstract class BaseExpandedCandidateWindow> : abstract fun nextPage() override fun onAttached() { - lifecycleCoroutineScope = candidateLayout.findViewTreeLifecycleOwner()!!.lifecycleScope bar.expandButtonStateMachine.push(ExpandedCandidatesAttached) candidateLayout.embeddedKeyboard.also { it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) it.keyActionListener = keyActionListener } - offsetJob = lifecycleCoroutineScope.launch { + offsetJob = service.lifecycleScope.launch { horizontalCandidate.expandedCandidateOffset.collect { - updateCandidatesWithOffset(it) + if (it <= 0) { + windowManager.attachWindow(KeyboardWindow) + } else { + candidateLayout.resetPosition() + adapter.refreshWithOffset(it) + } } } - candidatesSubmitJob = lifecycleCoroutineScope.launch { - candidatesPager.flow.collect { + candidatesSubmitJob = service.lifecycleScope.launch { + candidatesPager.flow.collectLatest { adapter.submitData(it) } } } - fun bindCandidateUiViewHolder(holder: PagingCandidateViewAdapter.ViewHolder) { + fun bindCandidateUiViewHolder(holder: CandidateViewHolder) { holder.itemView.setOnClickListener { fcitx.launchOnReady { it.select(holder.idx) } } - if (horizontalCandidate.canForgetWord) { - holder.itemView.setOnLongClickListener { _ -> - holder.ui.showExtraActionMenu(onForget = { - fcitx.launchOnReady { it.forget(holder.idx) } - }) - true - } - } else { - holder.itemView.setOnLongClickListener(null) - } - } - - private fun updateCandidatesWithOffset(offset: Int) { - val candidates = horizontalCandidate.adapter.candidates - if (candidates.isEmpty()) { - windowManager.attachWindow(KeyboardWindow) - } else { - adapter.refreshWithOffset(offset) - lifecycleCoroutineScope.launch(Dispatchers.Main) { - candidateLayout.resetPosition() - } + holder.itemView.setOnLongClickListener { + horizontalCandidate.showCandidateActionMenu(holder.idx, holder.text, holder.ui) + true } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt index 1b6417aef..b63d4b0cb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/FlexboxExpandedCandidateWindow.kt @@ -1,7 +1,8 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.util.DisplayMetrics @@ -11,8 +12,9 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.AlignItems import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.flexbox.JustifyContent -import org.fcitx.fcitx5.android.input.candidates.adapter.PagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout +import org.fcitx.fcitx5.android.input.candidates.expanded.PagingCandidateViewAdapter import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.FlexboxHorizontalDecoration import splitties.dimensions.dp import splitties.views.dsl.core.wrapContent @@ -23,7 +25,7 @@ class FlexboxExpandedCandidateWindow : override val adapter by lazy { object : PagingCandidateViewAdapter(theme) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { return super.onCreateViewHolder(parent, viewType).apply { itemView.apply { minimumWidth = dp(40) @@ -34,7 +36,7 @@ class FlexboxExpandedCandidateWindow : } } - override fun onBindViewHolder(holder: ViewHolder, position: Int) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { super.onBindViewHolder(holder, position) bindCandidateUiViewHolder(holder) } @@ -57,9 +59,9 @@ class FlexboxExpandedCandidateWindow : addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { this@FlexboxExpandedCandidateWindow.layoutManager.apply { - pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() != 0 + pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() > 0 pageDnBtn.isEnabled = - findLastCompletelyVisibleItemPosition() != itemCount - 1 + findLastCompletelyVisibleItemPosition() < itemCount - 1 } } }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt index e8f1045d2..dc365a5bf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/GridExpandedCandidateWindow.kt @@ -1,7 +1,8 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.candidates.expanded.window import android.content.res.Configuration @@ -10,8 +11,9 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.prefs.AppPrefs -import org.fcitx.fcitx5.android.input.candidates.adapter.GridPagingCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout +import org.fcitx.fcitx5.android.input.candidates.expanded.GridPagingCandidateViewAdapter import org.fcitx.fcitx5.android.input.candidates.expanded.SpanHelper import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.GridDecoration @@ -29,7 +31,7 @@ class GridExpandedCandidateWindow : override val adapter by lazy { object : GridPagingCandidateViewAdapter(theme) { - override fun onBindViewHolder(holder: ViewHolder, position: Int) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { super.onBindViewHolder(holder, position) bindCandidateUiViewHolder(holder) } @@ -51,9 +53,9 @@ class GridExpandedCandidateWindow : addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { (recyclerView.layoutManager as GridLayoutManager).apply { - pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() != 0 + pageUpBtn.isEnabled = findFirstCompletelyVisibleItemPosition() > 0 pageDnBtn.isEnabled = - findLastCompletelyVisibleItemPosition() != itemCount - 1 + findLastCompletelyVisibleItemPosition() < itemCount - 1 } } }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesMode.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesMode.kt new file mode 100644 index 000000000..aeae453e5 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesMode.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +enum class FloatingCandidatesMode(override val stringRes: Int) : ManagedPreferenceEnum { + SystemDefault(R.string.system_default), + InputDevice(R.string.follow_input_device), + Disabled(R.string.disabled) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesOrientation.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesOrientation.kt new file mode 100644 index 000000000..0107b999d --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/FloatingCandidatesOrientation.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +enum class FloatingCandidatesOrientation(override val stringRes: Int): ManagedPreferenceEnum { + Automatic(R.string.automatic), + Horizontal(R.string.horizontal), + Vertical(R.string.vertical) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/LabeledCandidateItemUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/LabeledCandidateItemUi.kt new file mode 100644 index 000000000..613473e0f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/LabeledCandidateItemUi.kt @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import android.content.Context +import android.graphics.Color +import android.widget.TextView +import androidx.core.text.buildSpannedString +import androidx.core.text.color +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.data.theme.Theme +import splitties.views.backgroundColor +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.textView + +class LabeledCandidateItemUi( + override val ctx: Context, + val theme: Theme, + setupTextView: TextView.() -> Unit +) : Ui { + + override val root = textView { + setupTextView(this) + } + + fun update(candidate: FcitxEvent.Candidate, active: Boolean) { + val labelFg = if (active) theme.genericActiveForegroundColor else theme.candidateLabelColor + val fg = if (active) theme.genericActiveForegroundColor else theme.candidateTextColor + val altFg = if (active) theme.genericActiveForegroundColor else theme.candidateCommentColor + root.text = buildSpannedString { + color(labelFg) { append(candidate.label) } + color(fg) { append(candidate.text) } + if (candidate.comment.isNotBlank()) { + append(" ") + color(altFg) { append(candidate.comment) } + } + } + val bg = if (active) theme.genericActiveBackgroundColor else Color.TRANSPARENT + root.backgroundColor = bg + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PagedCandidatesUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PagedCandidatesUi.kt new file mode 100644 index 000000000..f0684d18b --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PagedCandidatesUi.kt @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import android.annotation.SuppressLint +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.TextView +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.RecyclerView +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayoutManager +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.core.FcitxEvent.PagedCandidateEvent.LayoutHint +import org.fcitx.fcitx5.android.data.theme.Theme +import splitties.views.dsl.core.Ui +import splitties.views.dsl.recyclerview.recyclerView + +class PagedCandidatesUi( + override val ctx: Context, + val theme: Theme, + private val setupTextView: TextView.() -> Unit, + private val onCandidateClick: (Int) -> Unit, + private val onPrevPage: () -> Unit, + private val onNextPage: () -> Unit +) : Ui { + + private var data = FcitxEvent.PagedCandidateEvent.Data.Empty + + private var isVertical = false + + sealed class UiHolder(open val ui: Ui) : RecyclerView.ViewHolder(ui.root) { + class Candidate(override val ui: LabeledCandidateItemUi) : UiHolder(ui) + class Pagination(override val ui: PaginationUi) : UiHolder(ui) + } + + private val candidatesAdapter = object : RecyclerView.Adapter() { + override fun getItemCount() = + data.candidates.size + (if (data.hasPrev || data.hasNext) 1 else 0) + + override fun getItemViewType(position: Int) = if (position < data.candidates.size) 0 else 1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UiHolder { + return when (viewType) { + 0 -> UiHolder.Candidate(LabeledCandidateItemUi(ctx, theme, setupTextView)) + else -> UiHolder.Pagination(PaginationUi(ctx, theme)).apply { + ui.prevIcon.setOnClickListener { + onPrevPage.invoke() + } + ui.nextIcon.setOnClickListener { + onNextPage.invoke() + } + } + }.apply { + // assign default LayoutParams, otherwise updateLayoutParams won't work + ui.root.layoutParams = FlexboxLayoutManager.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + } + } + + override fun onBindViewHolder(holder: UiHolder, position: Int) { + when (holder) { + is UiHolder.Candidate -> { + val candidate = data.candidates[position] + holder.ui.update(candidate, active = position == data.cursorIndex) + holder.ui.root.setOnClickListener { + onCandidateClick.invoke(position) + } + holder.ui.root.updateLayoutParams { + width = if (isVertical) MATCH_PARENT else WRAP_CONTENT + } + } + is UiHolder.Pagination -> { + holder.ui.update(data) + holder.ui.root.updateLayoutParams { + flexGrow = 1f + width = if (isVertical) MATCH_PARENT else WRAP_CONTENT + alignSelf = if (isVertical) AlignItems.STRETCH else AlignItems.CENTER + } + } + } + } + } + + private val candidatesLayoutManager = FlexboxLayoutManager(ctx).apply { + flexWrap = FlexWrap.WRAP + } + + override val root = recyclerView { + isFocusable = false + adapter = candidatesAdapter + layoutManager = candidatesLayoutManager + overScrollMode = View.OVER_SCROLL_NEVER + } + + @SuppressLint("NotifyDataSetChanged") + fun update( + data: FcitxEvent.PagedCandidateEvent.Data, + orientation: FloatingCandidatesOrientation + ) { + this.data = data + this.isVertical = when (orientation) { + FloatingCandidatesOrientation.Automatic -> data.layoutHint == LayoutHint.Vertical + else -> orientation == FloatingCandidatesOrientation.Vertical + } + candidatesLayoutManager.apply { + if (isVertical) { + flexDirection = FlexDirection.COLUMN + alignItems = AlignItems.STRETCH + } else { + flexDirection = FlexDirection.ROW + alignItems = AlignItems.BASELINE + } + } + candidatesAdapter.notifyDataSetChanged() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PaginationUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PaginationUi.kt new file mode 100644 index 000000000..44bc9b7ce --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/floating/PaginationUi.kt @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.floating + +import android.content.Context +import android.content.res.ColorStateList +import android.widget.ImageView +import androidx.annotation.DrawableRes +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.utils.styledFloat +import splitties.dimensions.dp +import splitties.resources.drawable +import splitties.views.dsl.constraintlayout.before +import splitties.views.dsl.constraintlayout.centerVertically +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.endOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.imageDrawable + +class PaginationUi(override val ctx: Context, val theme: Theme) : Ui { + + private fun createIcon(@DrawableRes icon: Int) = imageView { + imageTintList = ColorStateList.valueOf(theme.keyTextColor) + imageDrawable = drawable(icon) + scaleType = ImageView.ScaleType.CENTER_CROP + isClickable = true + } + + val prevIcon = createIcon(R.drawable.ic_baseline_arrow_prev_24) + val nextIcon = createIcon(R.drawable.ic_baseline_arrow_next_24) + + private val disabledAlpha = styledFloat(android.R.attr.disabledAlpha) + + override val root = constraintLayout { + val w = dp(10) + val h = dp(20) + add(nextIcon, lParams(w, h) { + centerVertically() + endOfParent() + }) + add(prevIcon, lParams(w, h) { + centerVertically() + before(nextIcon) + }) + } + + fun update(data: FcitxEvent.PagedCandidateEvent.Data) { + prevIcon.alpha = if (data.hasPrev) 1f else disabledAlpha + nextIcon.alpha = if (data.hasNext) 1f else disabledAlpha + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt similarity index 70% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt index 9f3d3ec46..8ee143705 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/HorizontalCandidateComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateComponent.kt @@ -1,44 +1,55 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates + +package org.fcitx.fcitx5.android.input.candidates.horizontal import android.content.res.Configuration import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape +import android.widget.PopupMenu +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.color import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxEvent -import org.fcitx.fcitx5.android.core.InputMethodEntry import org.fcitx.fcitx5.android.daemon.launchOnReady +import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.TransitionEvent.ExpandedCandidatesUpdated import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode.AlwaysFillWidth -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode.AutoFillWidth -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateMode.NeverFillWidth -import org.fcitx.fcitx5.android.input.candidates.adapter.HorizontalCandidateViewAdapter +import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import org.fcitx.fcitx5.android.input.candidates.expanded.decoration.FlexboxVerticalDecoration +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.AlwaysFillWidth +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.AutoFillWidth +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateMode.NeverFillWidth import org.fcitx.fcitx5.android.input.dependency.UniqueViewComponent import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.fcitx +import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme +import org.fcitx.fcitx5.android.utils.item import org.mechdancer.dependency.manager.must import splitties.dimensions.dp +import splitties.resources.styledColor import kotlin.math.max class HorizontalCandidateComponent : UniqueViewComponent(), InputBroadcastReceiver { + private val service by manager.inputMethodService() private val context by manager.context() private val fcitx by manager.fcitx() private val theme by manager.theme() @@ -75,19 +86,17 @@ class HorizontalCandidateComponent : val expandedCandidateOffset = _expandedCandidateOffset.asSharedFlow() - private fun refreshExpanded() { - runBlocking { - _expandedCandidateOffset.emit(view.childCount) - } + private fun refreshExpanded(childCount: Int) { + _expandedCandidateOffset.tryEmit(childCount) bar.expandButtonStateMachine.push( ExpandedCandidatesUpdated, - ExpandedCandidatesEmpty to (adapter.total == layoutManager.childCount) + ExpandedCandidatesEmpty to (adapter.total == childCount) ) } val adapter: HorizontalCandidateViewAdapter by lazy { object : HorizontalCandidateViewAdapter(theme) { - override fun onBindViewHolder(holder: ViewHolder, position: Int) { + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { super.onBindViewHolder(holder, position) holder.itemView.updateLayoutParams { minWidth = layoutMinWidth @@ -96,15 +105,9 @@ class HorizontalCandidateComponent : holder.itemView.setOnClickListener { fcitx.launchOnReady { it.select(holder.idx) } } - if (canForgetWord) { - holder.itemView.setOnLongClickListener { _ -> - holder.ui.showExtraActionMenu(onForget = { - fcitx.launchOnReady { it.forget(holder.idx) } - }) - true - } - } else { - holder.itemView.setOnLongClickListener(null) + holder.itemView.setOnLongClickListener { + showCandidateActionMenu(holder.idx, candidates[position], holder.ui) + true } } } @@ -116,14 +119,15 @@ class HorizontalCandidateComponent : override fun canScrollHorizontally() = false override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) + val cnt = this.childCount if (secondLayoutPassNeeded) { - if (childCount < adapter.candidates.size) { + if (cnt < adapter.candidates.size) { // [^2] RecyclerView can't display all candidates // update LayoutParams in onLayoutCompleted would trigger another // onLayoutCompleted, skip the second one to avoid infinite loop if (secondLayoutPassDone) return secondLayoutPassDone = true - for (i in 0 until childCount) { + for (i in 0 until cnt) { getChildAt(i)!!.updateLayoutParams { flexGrow = 1f } @@ -132,7 +136,7 @@ class HorizontalCandidateComponent : secondLayoutPassNeeded = false } } - refreshExpanded() + refreshExpanded(cnt) } // no need to override `generate{,Default}LayoutParams`, because HorizontalCandidateViewAdapter // guarantees ViewHolder's layoutParams to be `FlexboxLayoutManager.LayoutParams` @@ -191,14 +195,43 @@ class HorizontalCandidateComponent : adapter.updateCandidates(candidates, total) // not sure why empty candidates won't trigger `FlexboxLayoutManager#onLayoutCompleted()` if (candidates.isEmpty()) { - refreshExpanded() + refreshExpanded(0) } } - var canForgetWord: Boolean = false - private set + private fun triggerCandidateAction(idx: Int, actionIdx: Int) { + fcitx.runIfReady { triggerCandidateAction(idx, actionIdx) } + } - override fun onImeUpdate(ime: InputMethodEntry) { - canForgetWord = (ime.addon == "pinyin" || ime.addon == "table") + private var candidateActionMenu: PopupMenu? = null + + fun showCandidateActionMenu(idx: Int, text: String, ui: CandidateItemUi) { + candidateActionMenu?.dismiss() + candidateActionMenu = null + service.lifecycleScope.launch { + val actions = fcitx.runOnReady { getCandidateActions(idx) } + if (actions.isEmpty()) return@launch + InputFeedbacks.hapticFeedback(ui.root, longPress = true) + candidateActionMenu = PopupMenu(context, ui.root).apply { + menu.add(buildSpannedString { + bold { + color(context.styledColor(android.R.attr.colorAccent)) { + append(text) + } + } + }).apply { + isEnabled = false + } + actions.forEach { action -> + menu.item(action.text) { + triggerCandidateAction(idx, action.id) + } + } + setOnDismissListener { + candidateActionMenu = null + } + show() + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateMode.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateMode.kt new file mode 100644 index 000000000..1be621824 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateMode.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.horizontal + +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum + +enum class HorizontalCandidateMode(override val stringRes: Int) : ManagedPreferenceEnum { + NeverFillWidth(R.string.horizontal_candidate_never_fill), + AutoFillWidth(R.string.horizontal_candidate_auto_fill), + AlwaysFillWidth(R.string.horizontal_candidate_always_fill); +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/HorizontalCandidateViewAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt similarity index 73% rename from app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/HorizontalCandidateViewAdapter.kt rename to app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt index abd7b8ee3..f2d4c78b0 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/adapter/HorizontalCandidateViewAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/horizontal/HorizontalCandidateViewAdapter.kt @@ -1,8 +1,9 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ -package org.fcitx.fcitx5.android.input.candidates.adapter + +package org.fcitx.fcitx5.android.input.candidates.horizontal import android.annotation.SuppressLint import android.view.ViewGroup @@ -11,17 +12,14 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.flexbox.FlexboxLayoutManager import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi +import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder import splitties.dimensions.dp import splitties.views.dsl.core.matchParent import splitties.views.dsl.core.wrapContent import splitties.views.setPaddingDp open class HorizontalCandidateViewAdapter(val theme: Theme) : - RecyclerView.Adapter() { - - inner class ViewHolder(val ui: CandidateItemUi) : RecyclerView.ViewHolder(ui.root) { - var idx = -1 - } + RecyclerView.Adapter() { var candidates: Array = arrayOf() private set @@ -39,19 +37,21 @@ open class HorizontalCandidateViewAdapter(val theme: Theme) : override fun getItemCount() = candidates.size @CallSuper - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CandidateViewHolder { val ui = CandidateItemUi(parent.context, theme) ui.root.apply { minimumWidth = dp(40) setPaddingDp(10, 0, 10, 0) layoutParams = FlexboxLayoutManager.LayoutParams(wrapContent, matchParent) } - return ViewHolder(ui) + return CandidateViewHolder(ui) } @CallSuper - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.text.text = candidates[position] + override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) { + val text = candidates[position] + holder.ui.text.text = text + holder.text = text holder.idx = position } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt index 753e01a4a..bd7c7fa3e 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardAdapter.kt @@ -7,8 +7,6 @@ package org.fcitx.fcitx5.android.input.clipboard import android.os.Build import android.view.ViewGroup import android.widget.PopupMenu -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView @@ -16,12 +14,13 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.utils.DeviceUtil -import splitties.resources.drawable +import org.fcitx.fcitx5.android.utils.item import splitties.resources.styledColor import kotlin.math.min abstract class ClipboardAdapter( private val theme: Theme, + private val entryRadius: Float, private val maskSensitive: Boolean ) : PagingDataAdapter(diffCallback) { @@ -87,7 +86,7 @@ abstract class ClipboardAdapter( class ViewHolder(val entryUi: ClipboardEntryUi) : RecyclerView.ViewHolder(entryUi.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = - ViewHolder(ClipboardEntryUi(parent.context, theme)) + ViewHolder(ClipboardEntryUi(parent.context, theme, entryRadius)) override fun onBindViewHolder(holder: ViewHolder, position: Int) { val entry = getItem(position) ?: return @@ -97,29 +96,28 @@ abstract class ClipboardAdapter( onPaste(entry) } root.setOnLongClickListener { - val iconColor = ctx.styledColor(android.R.attr.colorControlNormal) val popup = PopupMenu(ctx, root) - fun menuItem(@StringRes title: Int, @DrawableRes ic: Int, callback: () -> Unit) { - popup.menu.add(title).apply { - icon = ctx.drawable(ic)?.apply { setTint(iconColor) } - setOnMenuItemClickListener { - callback() - true - } + val menu = popup.menu + val iconTint = ctx.styledColor(android.R.attr.colorControlNormal) + if (entry.pinned) { + menu.item(R.string.unpin, R.drawable.ic_outline_push_pin_24, iconTint) { + onUnpin(entry.id) + } + } else { + menu.item(R.string.pin, R.drawable.ic_baseline_push_pin_24, iconTint) { + onPin(entry.id) } } - if (entry.pinned) menuItem(R.string.unpin, R.drawable.ic_outline_push_pin_24) { - onUnpin(entry.id) - } else menuItem(R.string.pin, R.drawable.ic_baseline_push_pin_24) { - onPin(entry.id) - } - menuItem(R.string.edit, R.drawable.ic_baseline_edit_24) { + menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint) { onEdit(entry.id) } - menuItem(R.string.delete, R.drawable.ic_baseline_delete_24) { + menu.item(R.string.share, R.drawable.ic_baseline_share_24, iconTint) { + onShare(entry) + } + menu.item(R.string.delete, R.drawable.ic_baseline_delete_24, iconTint) { onDelete(entry.id) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !DeviceUtil.isSamsungOneUI) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !DeviceUtil.isSamsungOneUI && !DeviceUtil.isFlyme) { popup.setForceShowIcon(true) } popup.setOnDismissListener { @@ -148,6 +146,8 @@ abstract class ClipboardAdapter( abstract fun onEdit(id: Int) + abstract fun onShare(entry: ClipboardEntry) + abstract fun onDelete(id: Int) } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt index 7c423a3b3..6d6eb53ec 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardEntryUi.kt @@ -31,7 +31,7 @@ import splitties.views.dsl.core.wrapContent import splitties.views.imageDrawable import splitties.views.setPaddingDp -class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui { +class ClipboardEntryUi(override val ctx: Context, private val theme: Theme, radius: Float) : Ui { val textView = textView { minLines = 1 @@ -62,7 +62,6 @@ class ClipboardEntryUi(override val ctx: Context, private val theme: Theme) : Ui override val root = CustomGestureView(ctx).apply { isClickable = true minimumHeight = dp(30) - val radius = dp(2f) foreground = RippleDrawable( ColorStateList.valueOf(theme.keyPressHighlightColor), null, GradientDrawable().apply { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt index adc70d806..aa5e705d0 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardStateMachine.kt @@ -42,8 +42,14 @@ object ClipboardStateMachine { }) } - fun new(initialState: State, block: (State) -> Unit) = - EventStateMachine(initialState).apply { + fun new(initial: State, empty: Boolean, listening: Boolean, block: (State) -> Unit) = + EventStateMachine( + initialState = initial, + externalBooleanStates = mutableMapOf( + ClipboardDbEmpty to empty, + ClipboardListeningEnabled to listening + ) + ).apply { onNewStateListener = block } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt index 3032a1df7..fef73bf5d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt @@ -53,7 +53,9 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { add(viewAnimator, defaultLParams(matchParent, matchParent)) } - val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme) + val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply { + contentDescription = ctx.getString(R.string.delete_all) + } val extension = horizontalLayout { add(deleteAllButton, lParams(dp(40), dp(40))) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt index 993bedaf2..6b6fac783 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt @@ -5,6 +5,7 @@ package org.fcitx.fcitx5.android.input.clipboard import android.annotation.SuppressLint +import android.content.Intent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -20,6 +21,7 @@ import androidx.paging.PagingConfig import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager +import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.SnackbarContentLayout import kotlinx.coroutines.Job @@ -29,6 +31,7 @@ import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardDbEmpty import org.fcitx.fcitx5.android.input.clipboard.ClipboardStateMachine.BooleanKey.ClipboardListeningEnabled @@ -44,11 +47,11 @@ import org.fcitx.fcitx5.android.input.wm.InputWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.EventStateMachine +import org.fcitx.fcitx5.android.utils.item import org.mechdancer.dependency.manager.must import splitties.dimensions.dp import splitties.resources.styledColor import splitties.views.dsl.core.withTheme -import kotlin.properties.Delegates class ClipboardWindow : InputWindow.ExtendedInputWindow() { @@ -59,15 +62,10 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private val snackbarCtx by lazy { context.withTheme(R.style.InputViewSnackbarTheme) } + private var snackbarInstance: Snackbar? = null private lateinit var stateMachine: EventStateMachine - private var isClipboardDbEmpty by Delegates.observable(ClipboardManager.itemCount == 0) { _, _, new -> - stateMachine.push( - ClipboardDbUpdated, ClipboardDbEmpty to new - ) - } - @Keep private val clipboardEnabledListener = ManagedPreference.OnChangeListener { _, it -> stateMachine.push( @@ -81,13 +79,19 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private val clipboardReturnAfterPaste by prefs.clipboardReturnAfterPaste private val clipboardMaskSensitive by prefs.clipboardMaskSensitive + private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius + private val clipboardEntriesPager by lazy { Pager(PagingConfig(pageSize = 16)) { ClipboardManager.allEntries() } } private var adapterSubmitJob: Job? = null private val adapter: ClipboardAdapter by lazy { - object : ClipboardAdapter(this@ClipboardWindow.theme, clipboardMaskSensitive) { + object : ClipboardAdapter( + theme, + context.dp(clipboardEntryRadius.toFloat()), + clipboardMaskSensitive + ) { override fun onPin(id: Int) { service.lifecycleScope.launch { ClipboardManager.pin(id) } } @@ -100,6 +104,17 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { AppUtil.launchClipboardEdit(context, id) } + override fun onShare(entry: ClipboardEntry) { + val target = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, entry.text) + } + val chooser = Intent.createChooser(target, null).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + service.startActivity(chooser) + } + override fun onDelete(id: Int) { service.lifecycleScope.launch { ClipboardManager.delete(id) @@ -162,27 +177,18 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private fun promptDeleteAll(skipPinned: Boolean) { promptMenu?.dismiss() promptMenu = PopupMenu(context, ui.deleteAllButton).apply { - menu.apply { - add(buildSpannedString { - bold { - color(context.styledColor(android.R.attr.colorAccent)) { - append(context.getString(if (skipPinned) R.string.delete_all_except_pinned else R.string.delete_all_pinned_items)) - } + menu.add(buildSpannedString { + bold { + color(context.styledColor(android.R.attr.colorAccent)) { + append(context.getString(if (skipPinned) R.string.delete_all_except_pinned else R.string.delete_all_pinned_items)) } - }).apply { - isEnabled = false } - add(android.R.string.cancel).apply { - setOnMenuItemClickListener { true } - } - add(android.R.string.ok).apply { - setOnMenuItemClickListener { - service.lifecycleScope.launch { - val ids = ClipboardManager.deleteAll(skipPinned) - showUndoSnackbar(*ids) - } - true - } + }).isEnabled = false + menu.add(android.R.string.cancel) + menu.item(android.R.string.ok) { + service.lifecycleScope.launch { + val ids = ClipboardManager.deleteAll(skipPinned) + showUndoSnackbar(*ids) } } setOnDismissListener { @@ -192,22 +198,40 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } } + private val pendingDeleteIds = arrayListOf() + @SuppressLint("RestrictedApi") private fun showUndoSnackbar(vararg id: Int) { - val str = context.resources.getString(R.string.num_items_deleted, id.size) - Snackbar.make(snackbarCtx, ui.root, str, Snackbar.LENGTH_LONG) - .setBackgroundTint(theme.keyBackgroundColor) - .setTextColor(theme.keyTextColor) + id.forEach { pendingDeleteIds.add(it) } + val str = context.resources.getString(R.string.num_items_deleted, pendingDeleteIds.size) + snackbarInstance = Snackbar.make(snackbarCtx, ui.root, str, Snackbar.LENGTH_LONG) + .setBackgroundTint(theme.popupBackgroundColor) + .setTextColor(theme.popupTextColor) .setActionTextColor(theme.genericActiveBackgroundColor) .setAction(R.string.undo) { service.lifecycleScope.launch { - ClipboardManager.undoDelete(*id) + ClipboardManager.undoDelete(*pendingDeleteIds.toIntArray()) + pendingDeleteIds.clear() } } .addCallback(object : Snackbar.Callback() { override fun onDismissed(transientBottomBar: Snackbar, event: Int) { - service.lifecycleScope.launch { - ClipboardManager.realDelete() + if (snackbarInstance === transientBottomBar) { + snackbarInstance = null + } + when (event) { + BaseCallback.DISMISS_EVENT_SWIPE, + BaseCallback.DISMISS_EVENT_MANUAL, + BaseCallback.DISMISS_EVENT_TIMEOUT -> { + service.lifecycleScope.launch { + ClipboardManager.realDelete() + pendingDeleteIds.clear() + } + } + BaseCallback.DISMISS_EVENT_ACTION, + BaseCallback.DISMISS_EVENT_CONSECUTIVE -> { + // user clicked "undo" or deleted more items which makes a new snackbar + } } } }).apply { @@ -227,18 +251,21 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } override fun onAttached() { + val isEmpty = ClipboardManager.itemCount == 0 + val isListening = clipboardEnabledPref.getValue() val initialState = when { - !clipboardEnabledPref.getValue() -> EnableListening - isClipboardDbEmpty -> AddMore + !isListening -> EnableListening + isEmpty -> AddMore else -> Normal } - stateMachine = ClipboardStateMachine.new(initialState) { + stateMachine = ClipboardStateMachine.new(initialState, isEmpty, isListening) { ui.switchUiByState(it) } // manually switch to initial ui ui.switchUiByState(initialState) adapter.addLoadStateListener { - isClipboardDbEmpty = it.append.endOfPaginationReached && adapter.itemCount < 1 + val empty = it.append.endOfPaginationReached && adapter.itemCount < 1 + stateMachine.push(ClipboardDbUpdated, ClipboardDbEmpty to empty) } adapterSubmitJob = service.lifecycleScope.launch { clipboardEntriesPager.flow.collect { @@ -253,6 +280,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { adapter.onDetached() adapterSubmitJob?.cancel() promptMenu?.dismiss() + snackbarInstance?.dismiss() } override val title: String by lazy { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingButton.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingButton.kt new file mode 100644 index 000000000..356e7709c --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingButton.kt @@ -0,0 +1,159 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.editing + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import androidx.annotation.DrawableRes +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView +import org.fcitx.fcitx5.android.input.keyboard.borderedKeyBackgroundDrawable +import org.fcitx.fcitx5.android.input.keyboard.insetRadiusDrawable +import org.fcitx.fcitx5.android.utils.borderDrawable +import org.fcitx.fcitx5.android.utils.pressHighlightDrawable +import org.fcitx.fcitx5.android.utils.rippleDrawable +import splitties.dimensions.dp +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.wrapContent +import splitties.views.gravityCenter +import splitties.views.imageResource +import kotlin.math.max + +@SuppressLint("ViewConstructor") +class TextEditingButton( + ctx: Context, + private val theme: Theme, + private val rippled: Boolean, + private val bordered: Boolean, + private val radius: Float, + private val altStyle: Boolean = false +) : CustomGestureView(ctx) { + + // bordered + private val shadowWidth = dp(1) + private val hInset = dp(4) + private val vInset = dp(4) + + // !bordered + private val lineWidth = max(1, dp(1) / 2) + + init { + if (bordered) { + val bkgColor = if (altStyle) theme.altKeyBackgroundColor else theme.keyBackgroundColor + background = borderedKeyBackgroundDrawable( + bkgColor, theme.keyShadowColor, + radius, shadowWidth, hInset, vInset + ) + foreground = if (rippled) { + RippleDrawable( + ColorStateList.valueOf(theme.keyPressHighlightColor), null, + insetRadiusDrawable(hInset, vInset, radius) + ) + } else { + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + insetRadiusDrawable(hInset, vInset, radius, theme.keyPressHighlightColor) + ) + } + } + } else { + background = borderDrawable(lineWidth, theme.dividerColor) + foreground = + if (rippled) rippleDrawable(theme.keyPressHighlightColor) + else pressHighlightDrawable(theme.keyPressHighlightColor) + } + } + + val textView = textView { + isClickable = false + isFocusable = false + background = null + setTextColor(if (altStyle) theme.altKeyTextColor else theme.keyTextColor) + } + + val imageView = imageView { + isClickable = false + isFocusable = false + imageTintList = ColorStateList.valueOf(theme.altKeyTextColor) + } + + fun setText(id: Int) { + textView.setText(id) + removeView(imageView) + add(textView, lParams(wrapContent, wrapContent, gravityCenter)) + } + + fun setIcon(@DrawableRes icon: Int) { + imageView.imageResource = icon + removeView(textView) + add(imageView, lParams(wrapContent, wrapContent, gravityCenter)) + } + + fun enableActivatedState() { + textView.setTextColor( + ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled) + ), + intArrayOf( + theme.genericActiveForegroundColor, + if (altStyle) theme.altKeyTextColor else theme.keyTextColor + ) + ) + ) + imageView.imageTintList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_activated), + intArrayOf(android.R.attr.state_enabled) + ), + intArrayOf( + theme.genericActiveForegroundColor, + theme.altKeyTextColor + ) + ) + background = if (bordered) { + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_activated), + borderedKeyBackgroundDrawable( + theme.genericActiveBackgroundColor, theme.keyShadowColor, + radius, shadowWidth, hInset, vInset + ) + ) + addState( + intArrayOf(android.R.attr.state_enabled), + borderedKeyBackgroundDrawable( + theme.keyBackgroundColor, theme.keyShadowColor, + radius, shadowWidth, hInset, vInset + ) + ) + } + } else { + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_activated), + borderDrawable( + lineWidth, + theme.dividerColor, + theme.genericActiveBackgroundColor + ) + ) + addState( + intArrayOf(android.R.attr.state_enabled), + borderDrawable(lineWidth, theme.dividerColor) + ) + } + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt index 546189ba8..327f92958 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingUi.kt @@ -1,26 +1,18 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.editing import android.content.Context -import android.content.res.ColorStateList -import android.graphics.drawable.StateListDrawable import android.view.View import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.InputFeedbacks import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.bar.ui.ToolButton -import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView -import org.fcitx.fcitx5.android.utils.borderDrawable -import org.fcitx.fcitx5.android.utils.pressHighlightDrawable -import org.fcitx.fcitx5.android.utils.rippleDrawable import splitties.dimensions.dp -import splitties.resources.drawable import splitties.views.dsl.constraintlayout.above import splitties.views.dsl.constraintlayout.below import splitties.views.dsl.constraintlayout.bottomOfParent @@ -34,113 +26,67 @@ import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.horizontalLayout -import splitties.views.dsl.core.imageView import splitties.views.dsl.core.lParams -import splitties.views.dsl.core.textView -import splitties.views.dsl.core.wrapContent -import splitties.views.gravityCenter -import splitties.views.imageDrawable -import splitties.views.padding -class TextEditingUi(override val ctx: Context, private val theme: Theme) : Ui { - - private val keyRippleEffect by ThemeManager.prefs.keyRippleEffect - - private val borderWidth = ctx.dp(1) / 2 - - private fun View.applyBorderedBackground() { - background = borderDrawable(borderWidth, theme.dividerColor) - foreground = - if (keyRippleEffect) rippleDrawable(theme.keyPressHighlightColor) - else pressHighlightDrawable(theme.keyPressHighlightColor) - } - - class GTextButton(context: Context) : CustomGestureView(context) { - val text = textView { - isClickable = false - isFocusable = false - background = null +class TextEditingUi( + override val ctx: Context, + private val theme: Theme, + private val ripple: Boolean, + private val border: Boolean, + private val radius: Float +) : Ui { + + private fun textButton(@StringRes id: Int, altStyle: Boolean = false) = + TextEditingButton(ctx, theme, ripple, border, radius, altStyle).apply { + setText(id) } - init { - add(text, lParams(wrapContent, wrapContent, gravityCenter)) - } - } - - class GImageButton(context: Context) : CustomGestureView(context) { - val image = imageView { - isClickable = false - isFocusable = false + private fun iconButton(@DrawableRes icon: Int, altStyle: Boolean = false) = + TextEditingButton(ctx, theme, ripple, border, radius, altStyle).apply { + setIcon(icon) } - init { - add(image, lParams(wrapContent, wrapContent, gravityCenter)) - } + val upButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_up_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_up) } - private fun textButton(@StringRes id: Int) = GTextButton(ctx).apply { - text.setText(id) - text.setTextColor(theme.keyTextColor) - stateListAnimator = null - applyBorderedBackground() + val rightButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_right_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_right) } - private fun iconButton(@DrawableRes icon: Int) = GImageButton(ctx).apply { - image.imageDrawable = drawable(icon)!!.apply { - setTint(theme.altKeyTextColor) - } - padding = dp(10) - applyBorderedBackground() + val downButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_down_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_down) } - val upButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_up_24) - - val rightButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_right_24) - - val downButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_down_24) - - val leftButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_left_24) + val leftButton = iconButton(R.drawable.ic_baseline_keyboard_arrow_left_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_left) + } val selectButton = textButton(R.string.select).apply { - text.setTextColor( - ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_activated), - intArrayOf(android.R.attr.state_enabled) - ), - intArrayOf(theme.genericActiveForegroundColor, theme.keyTextColor) - ) - ) - background = StateListDrawable().apply { - addState( - intArrayOf(android.R.attr.state_activated), - borderDrawable( - borderWidth, - theme.dividerColor, - theme.genericActiveBackgroundColor - ) - ) - addState( - intArrayOf(android.R.attr.state_enabled), - borderDrawable(borderWidth, theme.dividerColor) - ) - } + enableActivatedState() } - val homeButton = iconButton(R.drawable.ic_baseline_first_page_24) + val homeButton = iconButton(R.drawable.ic_baseline_first_page_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_to_start) + } - val endButton = iconButton(R.drawable.ic_baseline_last_page_24) + val endButton = iconButton(R.drawable.ic_baseline_last_page_24).apply { + contentDescription = ctx.getString(R.string.move_cursor_to_end) + } - val selectAllButton = textButton(android.R.string.selectAll) + val selectAllButton = textButton(android.R.string.selectAll, altStyle = true) - val cutButton = textButton(android.R.string.cut).apply { visibility = View.GONE } + val cutButton = textButton(android.R.string.cut, altStyle = true).apply { + visibility = View.GONE + } - val copyButton = textButton(android.R.string.copy) + val copyButton = textButton(android.R.string.copy, altStyle = true) - val pasteButton = textButton(android.R.string.paste) + val pasteButton = textButton(android.R.string.paste, altStyle = true) - val backspaceButton = iconButton(R.drawable.ic_baseline_backspace_24).apply { + val backspaceButton = iconButton(R.drawable.ic_baseline_backspace_24, altStyle = true).apply { soundEffect = InputFeedbacks.SoundEffect.Delete + contentDescription = ctx.getString(R.string.backspace) } override val root = constraintLayout { @@ -244,7 +190,9 @@ class TextEditingUi(override val ctx: Context, private val theme: Theme) : Ui { } } - val clipboardButton = ToolButton(ctx, R.drawable.ic_clipboard, theme) + val clipboardButton = ToolButton(ctx, R.drawable.ic_clipboard, theme).apply { + contentDescription = ctx.getString(R.string.clipboard) + } val extension = horizontalLayout { add(clipboardButton, lParams(dp(40), dp(40))) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt index fa5feceff..78e1725b6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/editing/TextEditingWindow.kt @@ -7,6 +7,9 @@ package org.fcitx.fcitx5.android.input.editing import android.view.KeyEvent import android.view.View import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.InputFeedbacks +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.clipboard.ClipboardWindow @@ -24,6 +27,12 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), private val windowManager: InputWindowManager by manager.must() private val theme by manager.theme() + private val hapticOnRepeat by AppPrefs.getInstance().keyboard.hapticOnRepeat + + private val buttonRipple by ThemeManager.prefs.keyRippleEffect + private val buttonBorder by ThemeManager.prefs.keyBorder + private val buttonRadius by ThemeManager.prefs.textEditingButtonRadius + private var hasSelection = false private var userSelection = false @@ -32,11 +41,14 @@ class TextEditingWindow : InputWindow.ExtendedInputWindow(), } private val ui by lazy { - TextEditingUi(context, theme).apply { + TextEditingUi(context, theme, buttonRipple, buttonBorder, buttonRadius.toFloat()).apply { fun CustomGestureView.onClickWithRepeating(block: () -> Unit) { setOnClickListener { block() } repeatEnabled = true - onRepeatListener = { block() } + onRepeatListener = { + block() + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(this) + } } leftButton.onClickWithRepeating { sendDirectionKey(KeyEvent.KEYCODE_DPAD_LEFT) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt index fa9ef3351..84a21447c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/BaseKeyboard.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.keyboard @@ -67,6 +67,8 @@ abstract class BaseKeyboard( private val vivoKeypressWorkaround by prefs.advanced.vivoKeypressWorkaround + private val hapticOnRepeat by prefs.keyboard.hapticOnRepeat + var popupActionListener: PopupActionListener? = null private val selectionSwipeThreshold = dp(10f) @@ -162,16 +164,17 @@ abstract class BaseKeyboard( swipeRepeatEnabled = true swipeThresholdX = selectionSwipeThreshold swipeThresholdY = disabledSwipeThreshold - onGestureListener = OnGestureListener { _, event -> + onGestureListener = OnGestureListener { view, event -> when (event.type) { GestureType.Move -> when (val count = event.countX) { 0 -> false else -> { val sym = if (count > 0) FcitxKeyMapping.FcitxKey_Right else FcitxKeyMapping.FcitxKey_Left - val action = KeyAction.SymAction(KeySym(sym), KeyStates.Empty) + val action = KeyAction.SymAction(KeySym(sym), KeyStates.Virtual) repeat(count.absoluteValue) { onAction(action) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) } true } @@ -184,12 +187,13 @@ abstract class BaseKeyboard( swipeRepeatEnabled = true swipeThresholdX = selectionSwipeThreshold swipeThresholdY = disabledSwipeThreshold - onGestureListener = OnGestureListener { _, event -> + onGestureListener = OnGestureListener { view, event -> when (event.type) { GestureType.Move -> { val count = event.countX if (count != 0) { onAction(KeyAction.MoveSelectionAction(count)) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) true } else false } @@ -216,8 +220,9 @@ abstract class BaseKeyboard( } is KeyDef.Behavior.Repeat -> { repeatEnabled = true - onRepeatListener = { _ -> + onRepeatListener = { view -> onAction(it.action) + if (hapticOnRepeat) InputFeedbacks.hapticFeedback(view) } } is KeyDef.Behavior.Swipe -> { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt index 772d9a8eb..a4a8bb9d7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CommonKeyActionListener.kt @@ -1,22 +1,21 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.input.keyboard import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.core.FcitxAPI -import org.fcitx.fcitx5.android.core.KeyState import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.broadcast.PreeditEmptyStateComponent -import org.fcitx.fcitx5.android.input.candidates.HorizontalCandidateComponent +import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.fcitx import org.fcitx.fcitx5.android.input.dependency.inputMethodService -import org.fcitx.fcitx5.android.input.dependency.inputView import org.fcitx.fcitx5.android.input.dialog.AddMoreInputMethodsPrompt import org.fcitx.fcitx5.android.input.dialog.InputMethodPickerDialog import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener.BackspaceSwipeState.Reset @@ -35,6 +34,7 @@ import org.fcitx.fcitx5.android.input.keyboard.KeyAction.SymAction import org.fcitx.fcitx5.android.input.keyboard.KeyAction.UnicodeAction import org.fcitx.fcitx5.android.input.picker.PickerWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager +import org.fcitx.fcitx5.android.utils.switchToNextIME import org.mechdancer.dependency.Dependent import org.mechdancer.dependency.UniqueComponent import org.mechdancer.dependency.manager.ManagedHandler @@ -51,7 +51,6 @@ class CommonKeyActionListener : private val context by manager.context() private val fcitx by manager.fcitx() private val service by manager.inputMethodService() - private val inputView by manager.inputView() private val preeditState: PreeditEmptyStateComponent by manager.must() private val horizontalCandidate: HorizontalCandidateComponent by manager.must() private val windowManager: InputWindowManager by manager.must() @@ -83,7 +82,7 @@ class CommonKeyActionListener : private fun showInputMethodPicker() { fcitx.launchOnReady { service.lifecycleScope.launch { - inputView.showDialog(InputMethodPickerDialog.build(it, service, context)) + service.showDialog(InputMethodPickerDialog.build(it, service, context)) } } } @@ -92,7 +91,7 @@ class CommonKeyActionListener : KeyActionListener { action, _ -> when (action) { is FcitxKeyAction -> service.postFcitxJob { - sendKey(action.act, KeyState.Virtual.state) + sendKey(action.act, action.states.states, action.code) } is SymAction -> service.postFcitxJob { sendKey(action.sym, action.states) @@ -115,7 +114,7 @@ class CommonKeyActionListener : service.postFcitxJob { if (enabledIme().size < 2) { service.lifecycleScope.launch { - inputView.showDialog(AddMoreInputMethodsPrompt.build(context)) + service.showDialog(AddMoreInputMethodsPrompt.build(context)) } } else { enumerateIme() @@ -128,7 +127,7 @@ class CommonKeyActionListener : } } LangSwitchBehavior.NextInputMethodApp -> { - service.nextInputMethodApp() + service.switchToNextIME() } } } @@ -138,7 +137,7 @@ class CommonKeyActionListener : Stopped -> { backspaceSwipeState = if ( preeditState.isEmpty && - horizontalCandidate.adapter.total == 0 + horizontalCandidate.adapter.total <= 0 // total is -1 on initialization ) { service.applySelectionOffset(action.start, action.end) Selection diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt index ba7b92b91..b76804469 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/CustomGestureView.kt @@ -58,6 +58,9 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { var longPressEnabled = false private var longPressJob: Job? = null + @Volatile + var longPressFeedbackEnabled = true + @Volatile private var repeatStarted = false var repeatEnabled = false @@ -90,7 +93,9 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { private val touchSlop: Float = ViewConfiguration.get(ctx).scaledTouchSlop.toFloat() init { + // disable system sound effect and haptic feedback isSoundEffectsEnabled = false + isHapticFeedbackEnabled = false } override fun setEnabled(enabled: Boolean) { @@ -148,7 +153,9 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { longPressJob?.cancel() longPressJob = lifecycleScope.launch { delay(longPressDelay.toLong()) - InputFeedbacks.hapticFeedback(this@CustomGestureView, true) + if (longPressFeedbackEnabled) { + InputFeedbacks.hapticFeedback(this@CustomGestureView, true) + } longPressTriggered = performLongClick() } } @@ -173,6 +180,7 @@ open class CustomGestureView(ctx: Context) : FrameLayout(ctx) { } MotionEvent.ACTION_UP -> { isPressed = false + InputFeedbacks.hapticFeedback(this, longPress = true, keyUp = true) dispatchGestureEvent(GestureType.Up, event.x, event.y) val shouldPerformClick = !(touchMovedOutside || longPressTriggered || diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt index 4182bb38f..192cfbadb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyAction.kt @@ -4,22 +4,22 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.core.KeyState import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.core.KeySym +import org.fcitx.fcitx5.android.core.ScancodeMapping import org.fcitx.fcitx5.android.input.picker.PickerWindow sealed class KeyAction { - data class FcitxKeyAction(var act: String) : KeyAction() + data class FcitxKeyAction( + val act: String, + val code: Int = ScancodeMapping.charToScancode(act[0]), + val states: KeyStates = KeyStates.Virtual + ) : KeyAction() - data class SymAction(val sym: KeySym, val states: KeyStates = VirtualState) : KeyAction() { - companion object { - val VirtualState = KeyStates(KeyState.Virtual) - } - } + data class SymAction(val sym: KeySym, val states: KeyStates = KeyStates.Virtual) : KeyAction() - data class CommitAction(var text: String) : KeyAction() + data class CommitAction(val text: String) : KeyAction() data class CapsAction(val lock: Boolean) : KeyAction() @@ -39,5 +39,5 @@ sealed class KeyAction { data class PickerSwitchAction(val key: PickerWindow.Key? = null) : KeyAction() - data object SpaceLongPressAction: KeyAction() + data object SpaceLongPressAction : KeyAction() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDrawable.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDrawable.kt new file mode 100644 index 000000000..f5ea1e1b5 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyDrawable.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.keyboard + +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import androidx.annotation.ColorInt + +fun radiusDrawable( + r: Float, @ColorInt + color: Int = Color.WHITE +): Drawable = GradientDrawable().apply { + setColor(color) + cornerRadius = r +} + +fun insetRadiusDrawable( + hInset: Int, + vInset: Int, + r: Float = 0f, + @ColorInt color: Int = Color.WHITE +): Drawable = InsetDrawable( + radiusDrawable(r, color), + hInset, vInset, hInset, vInset +) + +fun insetOvalDrawable( + hInset: Int, + vInset: Int, + @ColorInt color: Int = Color.WHITE +): Drawable = InsetDrawable( + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + }, + hInset, vInset, hInset, vInset +) + +fun borderedKeyBackgroundDrawable( + @ColorInt bkgColor: Int, + @ColorInt shadowColor: Int, + radius: Float, + shadowWidth: Int, + hMargin: Int, + vMargin: Int +): Drawable = LayerDrawable( + arrayOf( + radiusDrawable(radius, shadowColor), + radiusDrawable(radius, bkgColor), + ) +).apply { + setLayerInset(0, hMargin, vMargin, hMargin, vMargin - shadowWidth) + setLayerInset(1, hMargin, vMargin, hMargin, vMargin) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt index 3a09bbd2e..2252d7459 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/KeyView.kt @@ -13,9 +13,7 @@ import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable -import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.StateListDrawable import android.util.TypedValue @@ -119,29 +117,17 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc } // key border if ((bordered && def.border != Border.Off) || def.border == Border.On) { - // background: key border - appearanceView.background = LayerDrawable( - arrayOf( - GradientDrawable().apply { - cornerRadius = radius - setColor(theme.keyShadowColor) - }, - GradientDrawable().apply { - cornerRadius = radius - setColor( - when (def.variant) { - Variant.Normal, Variant.AltForeground -> theme.keyBackgroundColor - Variant.Alternative -> theme.altKeyBackgroundColor - Variant.Accent -> theme.accentKeyBackgroundColor - } - ) - } - ) - ).apply { - val shadowWidth = dp(1) - setLayerInset(0, hMargin, vMargin, hMargin, vMargin - shadowWidth) - setLayerInset(1, hMargin, vMargin, hMargin, vMargin) + val bkgColor = when (def.variant) { + Variant.Normal, Variant.AltForeground -> theme.keyBackgroundColor + Variant.Alternative -> theme.altKeyBackgroundColor + Variant.Accent -> theme.accentKeyBackgroundColor } + val shadowWidth = dp(1) + // background: key border + appearanceView.background = borderedKeyBackgroundDrawable( + bkgColor, theme.keyShadowColor, + radius, shadowWidth, hMargin, vMargin + ) // foreground: press highlight or ripple setupPressHighlight() } else { @@ -155,13 +141,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc } private fun setupPressHighlight(mask: Drawable? = null) { - appearanceView.foreground = if (rippled) + appearanceView.foreground = if (rippled) { RippleDrawable( ColorStateList.valueOf(theme.keyPressHighlightColor), null, // ripple should be masked with an opaque color mask ?: highlightMaskDrawable(Color.WHITE) ) - else + } else { StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), @@ -169,16 +155,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc mask ?: highlightMaskDrawable(theme.keyPressHighlightColor) ) } + } } - private fun highlightMaskDrawable(@ColorInt color: Int) = - InsetDrawable( - if (bordered) GradientDrawable().apply { - cornerRadius = radius - setColor(color) - } else ColorDrawable(color), - hMargin, vMargin, hMargin, vMargin - ) + private fun highlightMaskDrawable(@ColorInt color: Int): Drawable { + return if (bordered) insetRadiusDrawable(hMargin, vMargin, radius, color) + else InsetDrawable(ColorDrawable(color), hMargin, vMargin, hMargin, vMargin) + } override fun setEnabled(enabled: Boolean) { super.setEnabled(enabled) @@ -219,23 +202,16 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc val minHeight = dp(26) val hInset = dp(10) val vInset = if (h < minHeight) 0 else min((h - minHeight) / 2, dp(16)) - appearanceView.background = InsetDrawable( - GradientDrawable().apply { - cornerRadius = bkgRadius - setColor(theme.spaceBarColor) - }, - hInset, vInset, hInset, vInset + appearanceView.background = insetRadiusDrawable( + hInset, vInset, bkgRadius, theme.spaceBarColor ) // InsetDrawable sets padding to container view; remove padding to prevent text from bing clipped appearanceView.padding = 0 // apply press highlight for background area setupPressHighlight( - InsetDrawable( - GradientDrawable().apply { - cornerRadius = bkgRadius - setColor(if (rippled) Color.WHITE else theme.keyPressHighlightColor) - }, - hInset, vInset, hInset, vInset + insetRadiusDrawable( + hInset, vInset, bkgRadius, + if (rippled) Color.WHITE else theme.keyPressHighlightColor ) ) } @@ -243,21 +219,13 @@ abstract class KeyView(ctx: Context, val theme: Theme, val def: KeyDef.Appearanc val drawableSize = min(min(w, h), dp(35)) val hInset = (w - drawableSize) / 2 val vInset = (h - drawableSize) / 2 - appearanceView.background = InsetDrawable( - GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(theme.accentKeyBackgroundColor) - }, - hInset, vInset, hInset, vInset + appearanceView.background = insetOvalDrawable( + hInset, vInset, theme.accentKeyBackgroundColor ) appearanceView.padding = 0 setupPressHighlight( - InsetDrawable( - GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(if (rippled) Color.WHITE else theme.keyPressHighlightColor) - }, - hInset, vInset, hInset, vInset + insetOvalDrawable( + hInset, vInset, if (rippled) Color.WHITE else theme.keyPressHighlightColor ) ) } @@ -330,6 +298,7 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) topToTop = parentId bottomToBottom = parentId } + altText.visibility = View.VISIBLE altText.updateLayoutParams { // reset bottomToBottom = unset; bottomMargin = 0 @@ -348,6 +317,7 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) topToTop = parentId; topMargin = vMargin bottomToTop = altText.existingOrNewId } + altText.visibility = View.VISIBLE altText.updateLayoutParams { // reset topToTop = unset; topMargin = 0 @@ -359,6 +329,18 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) } } + private fun applyNoAltTextPosition() { + mainText.updateLayoutParams { + // reset + topMargin = 0 + bottomToTop = unset + // set + topToTop = parentId + bottomToBottom = parentId + } + altText.visibility = View.GONE + } + private fun applyLayout(orientation: Int) { when (ThemeManager.prefs.punctuationPosition.getValue()) { PunctuationPosition.Bottom -> when (orientation) { @@ -366,6 +348,7 @@ class AltTextKeyView(ctx: Context, theme: Theme, def: KeyDef.Appearance.AltText) else -> applyBottomAltTextPosition() } PunctuationPosition.TopRight -> applyTopRightAltTextPosition() + PunctuationPosition.None -> applyNoAltTextPosition() } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt index ef743669a..d4b905471 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/LangSwitchBehavior.kt @@ -4,14 +4,11 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class LangSwitchBehavior { - Enumerate, - ToggleActivate, - NextInputMethodApp; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): LangSwitchBehavior = valueOf(raw) - } +enum class LangSwitchBehavior(override val stringRes: Int) : ManagedPreferenceEnum { + Enumerate(R.string.space_behavior_enumerate), + ToggleActivate(R.string.space_behavior_activate), + NextInputMethodApp(R.string.lang_switch_behavior_next_ime_app); } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt index 116053a59..adea1fce6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SpaceLongPressBehavior.kt @@ -4,15 +4,12 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class SpaceLongPressBehavior { - None, - Enumerate, - ToggleActivate, - ShowPicker; - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): SpaceLongPressBehavior = valueOf(raw) - } +enum class SpaceLongPressBehavior(override val stringRes: Int) : ManagedPreferenceEnum { + None(R.string.space_behavior_none), + Enumerate(R.string.space_behavior_enumerate), + ToggleActivate(R.string.space_behavior_activate), + ShowPicker(R.string.space_behavior_picker); } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt index ed22ba4f7..7ccf2315f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/SwipeSymbolDirection.kt @@ -4,17 +4,14 @@ */ package org.fcitx.fcitx5.android.input.keyboard -import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum -enum class SwipeSymbolDirection { - Up, - Down, - Disabled; +enum class SwipeSymbolDirection(override val stringRes: Int): ManagedPreferenceEnum { + Up(R.string.swipe_up), + Down(R.string.swipe_down), + Disabled(R.string.disabled); fun checkY(totalY: Int): Boolean = (this != Disabled) && (totalY != 0) && ((totalY > 0) == (this == Down)) - - companion object : ManagedPreference.StringLikeCodec { - override fun decode(raw: String): SwipeSymbolDirection = valueOf(raw) - } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt index dc987c415..f3476aafb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/keyboard/TextKeyboard.kt @@ -11,6 +11,8 @@ import androidx.annotation.Keep import androidx.core.view.allViews import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.InputMethodEntry +import org.fcitx.fcitx5.android.core.KeyState +import org.fcitx.fcitx5.android.core.KeyStates import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.theme.Theme @@ -111,29 +113,40 @@ class TextKeyboard( private var punctuationMapping: Map = mapOf() private fun transformPunctuation(p: String) = punctuationMapping.getOrDefault(p, p) - private fun transformInputString(c: String): String { - if (c.length != 1) return c - if (c[0].isLetter()) return transformAlphabet(c) - return transformPunctuation(c) - } - override fun onAction(action: KeyAction, source: KeyActionListener.Source) { + var transformed = action when (action) { - is KeyAction.FcitxKeyAction -> if (source == KeyActionListener.Source.Keyboard) { - transformKeyAction(action) + is KeyAction.FcitxKeyAction -> when (source) { + KeyActionListener.Source.Keyboard -> { + when (capsState) { + CapsState.None -> { + transformed = action.copy(act = action.act.lowercase()) + } + CapsState.Once -> { + transformed = action.copy( + act = action.act.uppercase(), + states = KeyStates(KeyState.Virtual, KeyState.Shift) + ) + switchCapsState() + } + CapsState.Lock -> { + transformed = action.copy( + act = action.act.uppercase(), + states = KeyStates(KeyState.Virtual, KeyState.CapsLock) + ) + } + } + } + KeyActionListener.Source.Popup -> { + if (capsState == CapsState.Once) { + switchCapsState() + } + } } is KeyAction.CapsAction -> switchCapsState(action.lock) else -> {} } - super.onAction(action, source) - } - - private fun transformKeyAction(action: KeyAction.FcitxKeyAction) { - if (action.act.length > 1) { - return - } - action.act = transformAlphabet(action.act) - if (capsState == CapsState.Once) switchCapsState() + super.onAction(transformed, source) } override fun onAttach() { @@ -156,12 +169,21 @@ class TextKeyboard( append(ime.displayName) ime.subMode.run { label.ifEmpty { name.ifEmpty { null } } }?.let { append(" ($it)") } } + if (capsState != CapsState.None) { + switchCapsState() + } + } + + private fun transformPopupPreview(c: String): String { + if (c.length != 1) return c + if (c[0].isLetter()) return transformAlphabet(c) + return transformPunctuation(c) } override fun onPopupAction(action: PopupAction) { val newAction = when (action) { - is PopupAction.PreviewAction -> action.copy(content = transformInputString(action.content)) - is PopupAction.PreviewUpdateAction -> action.copy(content = transformInputString(action.content)) + is PopupAction.PreviewAction -> action.copy(content = transformPopupPreview(action.content)) + is PopupAction.PreviewUpdateAction -> action.copy(content = transformPopupPreview(action.content)) is PopupAction.ShowKeyboardAction -> { val label = action.keyboard.label if (label.length == 1 && label[0].isLetter()) @@ -174,13 +196,18 @@ class TextKeyboard( } private fun switchCapsState(lock: Boolean = false) { - capsState = if (lock) when (capsState) { - CapsState.Lock -> CapsState.None - else -> CapsState.Lock - } else when (capsState) { - CapsState.None -> CapsState.Once - else -> CapsState.None - } + capsState = + if (lock) { + when (capsState) { + CapsState.Lock -> CapsState.None + else -> CapsState.Lock + } + } else { + when (capsState) { + CapsState.None -> CapsState.Once + else -> CapsState.None + } + } updateCapsButtonIcon() updateAlphabetKeys() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt index 7dc848d02..67545fe64 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerData.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker @@ -8,7 +8,7 @@ import org.fcitx.fcitx5.android.R object PickerData { - class Category(val label: String = "", val icon: Int = 0) + data class Category(val label: String = "", val icon: Int = 0) val RecentlyUsedCategory = Category("⟳", R.drawable.ic_baseline_access_time_24) @@ -19,7 +19,7 @@ object PickerData { "'", "\"", "=", "_", "`", ":", ";", "?", "~", "|", "+", "-", "\\", "/", "[", "]", "{", "}", "<", ">", "“", "”", "·", "‘", "’", "¡", "¿", "¥", - "€", "£", "¢", "©", "®", "™", "℃", "℉", + "€", "£", "¢", "©", "®", "℗", "™", "℠", "°", "§", "№", "†", "‡", "‥", "…", "‰", "※", "‾", "⁄", "‼", "⁇", "⁈", "⁉", "√", "π", "±", "×", "÷", "¶", "∆", "¤", "µ", "‹", "›", "«", "»" @@ -97,19 +97,19 @@ object PickerData { "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "🫠", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "☺️", "😚", "😙", "🥲", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🫢", "🫣", "🤫", "🤔", "🫡", "🤐", "🤨", "😐", - "😑", "😶", "🫥", "😶‍🌫️", "😏", "😒", "🙄", "😬", "😮‍💨", "🤥", "😌", "😔", "😪", - "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "😵‍💫", + "😑", "😶", "🫥", "😶‍🌫️", "😏", "😒", "🙄", "😬", "😮‍💨", "🤥", "😌", "😔", "🙂‍↕️", "🙂‍↔️", "😪", + "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "😵‍💫", "🫨", "🫩", "🤯", "🤠", "🥳", "🥸", "😎", "🤓", "🧐", "😕", "🫤", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "🥹", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾", "🙈", "🙉", "🙊", "💋", "💌", "💘", "💝", "💖", "💗", "💓", "💞", "💕", "💟", "❣️", "💔", "❤️‍🔥", "❤️‍🩹", "❤️", "🧡", "💛", - "💚", "💙", "💜", "🤎", "🖤", "🤍", "💯", "💢", "💥", "💫", "💦", "💨", "🕳️", + "💚", "💙", "💜", "🤎", "🖤", "🤍", "🩷", "🩵", "🩶", "💯", "💢", "💥", "💫", "💦", "💨", "🕳️", "💣", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", "💤", ), Category("🧑", R.drawable.ic_baseline_emoji_people_24) to arrayOf( - "👋", "🤚", "🖐️", "✋", "🖖", "🫱", "🫲", "🫳", "🫴", "👌", "🤌", "🤏", "✌️", + "👋", "🤚", "🖐️", "✋", "🖖", "🫱", "🫲", "🫸", "🫷", "🫳", "🫴", "👌", "🤌", "🤏", "✌️", "🤞", "🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "🫵", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🫀", "🫁", @@ -127,8 +127,8 @@ object PickerData { "🤱", "👩‍🍼", "👨‍🍼", "🧑‍🍼", "👼", "🎅", "🤶", "🧑‍🎄", "🦸", "🦸‍♂️", "🦸‍♀️", "🦹", "🦹‍♂️", "🦹‍♀️", "🧙", "🧙‍♂️", "🧙‍♀️", "🧚", "🧚‍♂️", "🧚‍♀️", "🧛", "🧛‍♂️", "🧛‍♀️", "🧜", "🧜‍♂️", "🧜‍♀️", "🧝", "🧝‍♂️", "🧝‍♀️", "🧞", "🧞‍♂️", "🧞‍♀️", "🧟", "🧟‍♂️", "🧟‍♀️", "🧌", "💆", "💆‍♂️", "💆‍♀️", - "💇", "💇‍♂️", "💇‍♀️", "🚶", "🚶‍♂️", "🚶‍♀️", "🧍", "🧍‍♂️", "🧍‍♀️", "🧎", "🧎‍♂️", "🧎‍♀️", "🧑‍🦯", - "👨‍🦯", "👩‍🦯", "🧑‍🦼", "👨‍🦼", "👩‍🦼", "🧑‍🦽", "👨‍🦽", "👩‍🦽", "🏃", "🏃‍♂️", "🏃‍♀️", "💃", "🕺", + "💇", "💇‍♂️", "💇‍♀️", "🚶", "🚶‍♂️", "🚶‍♀️", "🚶‍➡️", "🚶‍♀️‍➡️", "🚶‍♂️‍➡️", "🧍", "🧍‍♂️", "🧍‍♀️", "🧎", "🧎‍♂️", "🧎‍♀️", "🧎‍➡️", "🧎‍♀️‍➡️", "🧎‍♂️‍➡️", "🧑‍🦯", + "👨‍🦯", "👩‍🦯", "🧑‍🦯‍➡️", "👨‍🦯‍➡️", "👩‍🦯‍➡️", "🧑‍🦽", "👨‍🦽", "👩‍🦽", "🧑‍🦽‍➡️", "👨‍🦽‍➡️", "👩‍🦽‍➡️", "🧑‍🦼", "👨‍🦼", "👩‍🦼", "🧑‍🦼‍➡️", "👨‍🦼‍➡️", "👩‍🦼‍➡️", "🏃", "🏃‍♂️", "🏃‍♀️", "🏃‍➡️", "🏃‍♂️‍➡️", "🏃‍♀️‍➡️", "💃", "🕺", "🕴️", "👯", "👯‍♂️", "👯‍♀️", "🧖", "🧖‍♂️", "🧖‍♀️", "🧗", "🧗‍♂️", "🧗‍♀️", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏌️‍♂️", "🏌️‍♀️", "🏄", "🏄‍♂️", "🏄‍♀️", "🚣", "🚣‍♂️", "🚣‍♀️", "🏊", "🏊‍♂️", "🏊‍♀️", "⛹️", "⛹️‍♂️", "⛹️‍♀️", "🏋️", "🏋️‍♂️", "🏋️‍♀️", "🚴", "🚴‍♂️", "🚴‍♀️", "🚵", "🚵‍♂️", "🚵‍♀️", "🤸", @@ -136,26 +136,26 @@ object PickerData { "🤹‍♀️", "🧘", "🧘‍♂️", "🧘‍♀️", "🛀", "🛌", "🧑‍🤝‍🧑", "👭", "👫", "👬", "💏", "👩‍❤️‍💋‍👨", "👨‍❤️‍💋‍👨", "👩‍❤️‍💋‍👩", "💑", "👩‍❤️‍👨", "👨‍❤️‍👨", "👩‍❤️‍👩", "👪", "👨‍👩‍👦", "👨‍👩‍👧", "👨‍👩‍👧‍👦", "👨‍👩‍👦‍👦", "👨‍👩‍👧‍👧", "👨‍👨‍👦", "👨‍👨‍👧", "👨‍👨‍👧‍👦", "👨‍👨‍👦‍👦", "👨‍👨‍👧‍👧", "👩‍👩‍👦", "👩‍👩‍👧", "👩‍👩‍👧‍👦", "👩‍👩‍👦‍👦", "👩‍👩‍👧‍👧", "👨‍👦", "👨‍👦‍👦", "👨‍👧", "👨‍👧‍👦", "👨‍👧‍👧", - "👩‍👦", "👩‍👦‍👦", "👩‍👧", "👩‍👧‍👦", "👩‍👧‍👧", "🗣️", "👤", "👥", "🫂", "👣" + "👩‍👦", "👩‍👦‍👦", "👩‍👧", "👩‍👧‍👦", "👩‍👧‍👧", "🧑‍🧑‍🧒", "🧑‍🧑‍🧒‍🧒", "🧑‍🧒", "🧑‍🧒‍🧒", "🗣️", "👤", "👥", "🫂", "👣" ), Category("🌸", R.drawable.ic_baseline_flower_24) to arrayOf( "🐵", "🐒", "🦍", "🦧", "🐶", "🐕", "🦮", "🐕‍🦺", "🐩", "🐺", "🦊", "🦝", "🐱", "🐈", "🐈‍⬛", "🦁", "🐯", "🐅", "🐆", "🐴", "🐎", "🦄", "🦓", "🦌", "🦬", "🐮", "🐂", "🐃", "🐄", "🐷", "🐖", "🐗", "🐽", "🐏", "🐑", "🐐", "🐪", "🐫", "🦙", "🦒", "🐘", "🦣", "🦏", "🦛", "🐭", "🐁", "🐀", "🐹", "🐰", "🐇", "🐿️", "🦫", - "🦔", "🦇", "🐻", "🐻‍❄️", "🐨", "🐼", "🦥", "🦦", "🦨", "🦘", "🦡", "🐾", "🦃", - "🐔", "🐓", "🐣", "🐤", "🐥", "🐦", "🐧", "🕊️", "🦅", "🦆", "🦢", "🦉", "🦤", - "🪶", "🦩", "🦚", "🦜", "🐸", "🐊", "🐢", "🦎", "🐍", "🐲", "🐉", "🦕", "🦖", - "🐳", "🐋", "🐬", "🦭", "🐟", "🐠", "🐡", "🦈", "🐙", "🐚", "🪸", "🐌", "🦋", + "🦔", "🦇", "🐻", "🐻‍❄️", "🐨", "🐼", "🫎", "🫏", "🦥", "🦦", "🦨", "🦘", "🦡", "🐾", "🦃", + "🐔", "🐓", "🐣", "🐤", "🐥", "🐦", "🐦‍⬛", "🐧", "🕊️", "🦅", "🦆", "🦢", "🦉", "🦤", "🪿", + "🪽", "🪶", "🦩", "🦚", "🐦‍🔥", "🦜", "🐸", "🐊", "🐢", "🦎", "🐍", "🐲", "🐉", "🦕", "🦖", + "🐳", "🐋", "🐬", "🦭", "🐟", "🐠", "🐡", "🦈", "🐙", "🪼", "🐚", "🪸", "🐌", "🦋", "🐛", "🐜", "🐝", "🪲", "🐞", "🦗", "🪳", "🕷️", "🕸️", "🦂", "🦟", "🪰", "🪱", - "🦠", "💐", "🌸", "💮", "🪷", "🏵️", "🌹", "🥀", "🌺", "🌻", "🌼", "🌷", "🌱", - "🪴", "🌲", "🌳", "🌴", "🌵", "🌾", "🌿", "☘️", "🍀", "🍁", "🍂", "🍃", "🪹", + "🦠", "💐", "🌸", "💮", "🪷", "🏵️", "🌹", "🥀", "🌺", "🌻", "🌼", "🌷", "🪻", "🌱", + "🪴", "🌲", "🌳", "🌴", "🪾", "🌵", "🌾", "🌿", "☘️", "🍀", "🍁", "🍂", "🍃", "🪹", "🪺" ), - Category("🎂", R.drawable.ic_baseline_cake_24) to arrayOf ( - "🍇", "🍈", "🍉", "🍊", "🍋", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", - "🍓", "🫐", "🥝", "🍅", "🫒", "🥥", "🥑", "🍆", "🥔", "🥕", "🌽", "🌶️", "🫑", - "🥒", "🥬", "🥦", "🧄", "🧅", "🍄", "🥜", "🫘", "🌰", "🍞", "🥐", "🥖", "🫓", + Category("🎂", R.drawable.ic_baseline_cake_24) to arrayOf( + "🍇", "🍈", "🍉", "🍊", "🍋", "🍋‍🟩", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", + "🍓", "🫐", "🥝", "🍅", "🫒", "🥥", "🥑", "🍆", "🥔", "🥕", "🌽", "🌶️", "🫑", "🫛", + "🥒", "🥬", "🥦", "🧄", "🧅", "🫚", "🫜", "🍄", "🍄‍🟫", "🥜", "🫘", "🌰", "🍞", "🥐", "🥖", "🫓", "🥨", "🥯", "🥞", "🧇", "🧀", "🍖", "🍗", "🥩", "🥓", "🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🌯", "🫔", "🥙", "🧆", "🥚", "🍳", "🥘", "🍲", "🫕", "🥣", "🥗", "🍿", "🧈", "🧂", "🥫", "🍱", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍠", "🍢", @@ -165,7 +165,7 @@ object PickerData { "🥂", "🥃", "🫗", "🥤", "🧋", "🧃", "🧉", "🧊", "🥢", "🍽️", "🍴", "🥄", "🔪", "🫙", "🏺" ), - Category("🚘", R.drawable.ic_baseline_directions_car_24) to arrayOf ( + Category("🚘", R.drawable.ic_baseline_directions_car_24) to arrayOf( "🌍", "🌎", "🌏", "🌐", "🗺️", "🗾", "🧭", "🏔️", "⛰️", "🌋", "🗻", "🏕️", "🏖️", "🏜️", "🏝️", "🏞️", "🏟️", "🏛️", "🏗️", "🧱", "🪨", "🪵", "🛖", "🏘️", "🏚️", "🏠", "🏡", "🏢", "🏣", "🏤", "🏥", "🏦", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏯", @@ -184,7 +184,7 @@ object PickerData { "🌤️", "🌥️", "🌦️", "🌧️", "🌨️", "🌩️", "🌪️", "🌫️", "🌬️", "🌀", "🌈", "🌂", "☂️", "☔", "⛱️", "⚡", "❄️", "☃️", "⛄", "☄️", "🔥", "💧", "🌊" ), - Category("⚽", R.drawable.ic_baseline_sports_basketball_24) to arrayOf ( + Category("⚽", R.drawable.ic_baseline_sports_basketball_24) to arrayOf( "🎃", "🎄", "🎆", "🎇", "🧨", "✨", "🎈", "🎉", "🎊", "🎋", "🎍", "🎎", "🎏", "🎐", "🎑", "🧧", "🎀", "🎁", "🎗️", "🎟️", "🎫", "🎖️", "🏆", "🏅", "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", @@ -193,13 +193,13 @@ object PickerData { "🎲", "🧩", "🧸", "🪅", "🪩", "🪆", "♠️", "♥️", "♦️", "♣️", "♟️", "🃏", "🀄", "🎴", "🎭", "🖼️", "🎨", "🧵", "🪡", "🧶", "🪢" ), - Category("💡", R.drawable.ic_baseline_emoji_objects_24) to arrayOf ( + Category("💡", R.drawable.ic_baseline_emoji_objects_24) to arrayOf( "👓", "🕶️", "🥽", "🥼", "🦺", "👔", "👕", "👖", "🧣", "🧤", "🧥", "🧦", "👗", "👘", "🥻", "🩱", "🩲", "🩳", "👙", "👚", "👛", "👜", "👝", "🛍️", "🎒", "🩴", "👞", "👟", "🥾", "🥿", "👠", "👡", "🩰", "👢", "👑", "👒", "🎩", "🎓", "🧢", - "🪖", "⛑️", "📿", "💄", "💍", "💎", "🔇", "🔈", "🔉", "🔊", "📢", "📣", "📯", + "🪖", "⛑️", "📿", "💄", "🪭", "💍", "💎", "🔇", "🔈", "🔉", "🔊", "📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️", "🎛️", "🎤", "🎧", "📻", "🎷", "🪗", - "🎸", "🎹", "🎺", "🎻", "🪕", "🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", + "🎸", "🎹", "🎺", "🎻", "🪕", "🪉", "🥁", "🪘", "🪇", "🪈", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🪫", "🔌", "💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀", "🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼", "🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", @@ -209,21 +209,21 @@ object PickerData { "📁", "📂", "🗂️", "📅", "📆", "🗒️", "🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️", "📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫", "🪃", "🏹", "🛡️", - "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️", "🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", + "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️", "🦯", "🔗", "⛓️", "⛓️‍💥", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩼", "🩺", "🩻", "🚪", "🛗", "🪞", "🪟", "🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", - "🪤", "🪒", "🧴", "🧷", "🧹", "🧺", "🧻", "🪣", "🧼", "🫧", "🪥", "🧽", "🧯", + "🪤", "🪒", "🧴", "🧷", "🧹", "🪏", "🧺", "🧻", "🪣", "🧼", "🫧", "🫟", "🪥", "🪮", "🧽", "🧯", "🛒", "🚬", "⚰️", "🪦", "⚱️", "🗿", "🪧", "🪪" ), - Category("🔣", R.drawable.ic_baseline_emoji_symbols_24) to arrayOf( + Category("🔣", R.drawable.ic_baseline_emoji_symbols_24) to arrayOf( "🏧", "🚮", "🚰", "♿", "🚹", "🚺", "🚻", "🚼", "🚾", "🛂", "🛃", "🛄", "🛅", "⚠️", "🚸", "⛔", "🚫", "🚳", "🚭", "🚯", "🚱", "🚷", "📵", "🔞", "☢️", "☣️", "⬆️", "↗️", "➡️", "↘️", "⬇️", "↙️", "⬅️", "↖️", "↕️", "↔️", "↩️", "↪️", "⤴️", "⤵️", "🔃", "🔄", "🔙", "🔚", "🔛", "🔜", "🔝", "🛐", "⚛️", "🕉️", "✡️", "☸️", "☯️", - "✝️", "☦️", "☪️", "☮️", "🕎", "🔯", "♈", "♉", "♊", "♋", "♌", "♍", "♎", + "✝️", "☦️", "☪️", "☮️", "🕎", "🪯", "🔯", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "⛎", "🔀", "🔁", "🔂", "▶️", "⏩", "⏭️", "⏯️", "◀️", "⏪", "⏮️", "🔼", "⏫", "🔽", "⏬", "⏸️", "⏹️", "⏺️", "⏏️", "🎦", "🔅", - "🔆", "📶", "📳", "📴", "♀️", "♂️", "⚧️", "✖️", "➕", "➖", "➗", "🟰", "♾️", + "🔆", "📶", "📳", "📴", "🛜", "🫆", "♀️", "♂️", "⚧️", "✖️", "➕", "➖", "➗", "🟰", "♾️", "‼️", "⁉️", "❓", "❔", "❕", "❗", "〰️", "💱", "💲", "⚕️", "♻️", "⚜️", "🔱", "📛", "🔰", "⭕", "✅", "☑️", "✔️", "❌", "❎", "➰", "➿", "〽️", "✳️", "✴️", "❇️", "©️", "®️", "™️", "#️⃣", "*️⃣", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", @@ -239,7 +239,7 @@ object PickerData { "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", - "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", + "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇶", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt index 422c7c834..d36086d97 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPageUi.kt @@ -1,11 +1,14 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker import android.content.Context +import android.view.View import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxKeyMapping import org.fcitx.fcitx5.android.core.KeySym @@ -26,6 +29,7 @@ import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Border import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Variant import org.fcitx.fcitx5.android.input.keyboard.KeyView import org.fcitx.fcitx5.android.input.keyboard.TextKeyView +import org.fcitx.fcitx5.android.input.popup.EmojiModifier import org.fcitx.fcitx5.android.input.popup.PopupAction import org.fcitx.fcitx5.android.input.popup.PopupActionListener import splitties.views.dsl.constraintlayout.below @@ -43,35 +47,33 @@ import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.matchParent -class PickerPageUi(override val ctx: Context, val theme: Theme, private val density: Density) : Ui { +class PickerPageUi( + override val ctx: Context, + theme: Theme, + density: Density, + bordered: Boolean = false +) : Ui { enum class Density( val pageSize: Int, val columnCount: Int, val rowCount: Int, val textSize: Float, + val autoScale: Boolean, val showBackspace: Boolean ) { // symbol: 10/10/8, backspace on bottom right - High(28, 10, 3, 19f, true), + High(28, 10, 3, 19f, false, true), // emoji: 7/7/6, backspace on bottom right - Medium(20, 7, 3, 23.7f, true), + Medium(20, 7, 3, 23.7f, false, true), // emoticon: 4/4/4, no backspace - Low(12, 4, 3, 19f, false) + Low(12, 4, 3, 19f, true, false) } companion object { - val BackspaceAppearance = Appearance.Image( - src = R.drawable.ic_baseline_backspace_24, - variant = Variant.Alternative, - border = Border.Off, - viewId = R.id.button_backspace - ) - val BackspaceAction = SymAction(KeySym(FcitxKeyMapping.FcitxKey_BackSpace)) - private var popupOnKeyPress by AppPrefs.getInstance().keyboard.popupOnKeyPress } @@ -82,12 +84,12 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens displayText = "", textSize = density.textSize, variant = Variant.Normal, - border = Border.Off + border = if (bordered) Border.On else Border.Off ) private val keyViews = Array(density.pageSize) { TextKeyView(ctx, theme, keyAppearance).apply { - if (density == Density.Low) { + if (density.autoScale) { mainText.apply { scaleMode = AutoScaleTextView.Mode.Proportional setPadding(hMargin, vMargin, hMargin, vMargin) @@ -96,91 +98,59 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens } } - private val backspaceKey = ImageKeyView(ctx, theme, BackspaceAppearance).apply { - setOnClickListener { onBackspaceClick() } - repeatEnabled = true - onRepeatListener = { onBackspaceClick() } - } + private val backspaceAppearance = Appearance.Image( + src = R.drawable.ic_baseline_backspace_24, + variant = Variant.Alternative, + border = if (bordered) Border.On else Border.Off, + viewId = R.id.button_backspace + ) - private fun onBackspaceClick() { - keyActionListener?.onKeyAction(BackspaceAction, Source.Keyboard) + private val backspaceKey by lazy { + val action: (View) -> Unit = { + keyActionListener?.onKeyAction(BackspaceAction, Source.Keyboard) + } + val listener = View.OnClickListener { action.invoke(it) } + ImageKeyView(ctx, theme, backspaceAppearance).apply { + setOnClickListener(listener) + repeatEnabled = true + onRepeatListener = action + } } override val root = constraintLayout { val columnCount = density.columnCount val rowCount = density.rowCount val keyWidth = 1f / columnCount - when (density) { - Density.High -> { - keyViews.forEachIndexed { i, keyView -> - val row = i / columnCount - val column = i % columnCount - add(keyView, lParams { - // layout_constraintTop_to - if (row == 0) { - // first row, align top to top of parent - topOfParent() - } else { - // not first row, align top to bottom of first view in last row - topToBottomOf(keyViews[(row - 1) * columnCount]) - } - // layout_constraintBottom_to - if (row == rowCount - 1) { - // last row, align bottom to bottom of parent - bottomOfParent() - } else { - // not last row, align bottom to top of first view in next row - bottomToTopOf(keyViews[(row + 1) * columnCount]) - } - // layout_constraintRight_to - if (i == keyViews.size - 1) { - // last key (likely not last column), align end to start of backspace button - rightToLeftOf(backspaceKey) - } else if (column == columnCount - 1) { - // last column, align end to end of parent - rightOfParent() - } else { - // neither, align end to start of next view - rightToLeftOf(keyViews[i + 1]) - } - matchConstraintPercentWidth = keyWidth - }) + keyViews.forEachIndexed { i, keyView -> + val row = i / columnCount + val column = i % columnCount + add(keyView, lParams { + // layout_constraintTop_to + if (row == 0) { + // first row, align top to top of parent + topOfParent() + } else { + // not first row, align top to bottom of first view in last row + topToBottomOf(keyViews[(row - 1) * columnCount]) } - } - - Density.Medium, Density.Low -> { - keyViews.forEachIndexed { i, keyView -> - val row = i / columnCount - val column = i % columnCount - add(keyView, lParams { - // layout_constraintTop_to - if (row == 0) { - // first row, align top to top of parent - topOfParent() - } else { - // not first row, align top to bottom of first view in last row - topToBottomOf(keyViews[(row - 1) * columnCount]) - } - // layout_constraintBottom_to - if (row == rowCount - 1) { - // last row, align bottom to bottom of parent - bottomOfParent() - } else { - // not last row, align bottom to top of first view in next row - bottomToTopOf(keyViews[(row + 1) * columnCount]) - } - // layout_constraintLeft_to - if (column == 0) { - // first column, align start to start of parent - leftOfParent() - } else { - // not first column, align start to end of last column - leftToRightOf(keyViews[i - 1]) - } - matchConstraintPercentWidth = keyWidth - }) + // layout_constraintBottom_to + if (row == rowCount - 1) { + // last row, align bottom to bottom of parent + bottomOfParent() + } else { + // not last row, align bottom to top of first view in next row + bottomToTopOf(keyViews[(row + 1) * columnCount]) } - } + // layout_constraintLeft_to + if (column == 0) { + // first column, align start to start of parent + leftOfParent() + } else { + // not first column, align start to end of last column + leftToRightOf(keyViews[i - 1]) + } + matchConstraintPercentWidth = keyWidth + }) } if (density.showBackspace) { add(backspaceKey, lParams { @@ -191,6 +161,16 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens rightOfParent() matchConstraintPercentWidth = 0.15f }) + keyViews.last().updateLayoutParams { + // align right of last key to left of backspace + rightToLeftOf(backspaceKey) + } + keyViews[(rowCount - 1) * columnCount].updateLayoutParams { + // first key of last row, align its right to the left of its next sibling + rightToLeftOf(keyViews[(rowCount - 1) * columnCount + 1]) + // pack the entire last row together, towards the backspace + horizontalChainStyle = ConstraintLayout.LayoutParams.CHAIN_PACKED + } } layoutParams = ViewGroup.LayoutParams(matchParent, matchParent) } @@ -199,7 +179,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens keyActionListener?.onKeyAction(CommitAction(str), Source.Keyboard) } - fun setItems(items: Array) { + fun setItems(items: List, withSkinTone: Boolean = false) { keyViews.forEachIndexed { i, keyView -> keyView.apply { if (i >= items.size) { @@ -211,10 +191,12 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens onGestureListener = null } else { isEnabled = true - val text = items[i] - mainText.text = text + val label = items[i] + val commitString = + if (withSkinTone) EmojiModifier.getPreferredTone(label) else label + mainText.text = commitString setOnClickListener { - onSymbolClick(text) + onSymbolClick(commitString) } setOnLongClickListener { view -> view as KeyView @@ -227,7 +209,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens onPopupAction( PopupAction.ShowKeyboardAction( view.id, - KeyDef.Popup.Keyboard(text), + KeyDef.Popup.Keyboard(label), bounds ) ) @@ -245,7 +227,7 @@ class PickerPageUi(override val ctx: Context, val theme: Theme, private val dens // so update bounds when it's pressed view.updateBounds() onPopupAction( - PopupAction.PreviewAction(view.id, text, view.bounds) + PopupAction.PreviewAction(view.id, label, view.bounds) ) } false diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt index 539788b20..13e9f40b7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPagesAdapter.kt @@ -1,9 +1,10 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker +import android.text.TextPaint import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.fcitx.fcitx5.android.data.RecentlyUsed @@ -16,105 +17,67 @@ class PickerPagesAdapter( private val keyActionListener: KeyActionListener, private val popupActionListener: PopupActionListener, data: List>>, - val density: PickerPageUi.Density, - recentlyUsedFileName: String + private val density: PickerPageUi.Density, + recentlyUsedFileName: String, + private val bordered: Boolean = false, + private val isEmoji: Boolean = false ) : RecyclerView.Adapter() { class ViewHolder(val ui: PickerPageUi) : RecyclerView.ViewHolder(ui.root) /** - * calculated layout of data in the form of - * [(start page of the category, # of pages)] - * Note: unlike [ranges], [pages], and [categories], - * this does not include recently used category. + * list<`Category` to `[start, end]`>, starting with empty "RecentlyUsed" category */ - private val cats: Array> - - private val ranges: List - - private val pages: ArrayList> + private val categories: MutableList> = mutableListOf( + PickerData.RecentlyUsedCategory to IntRange(0, 0) + ) /** - * INVARIANT: The recently used category only takes one page - * It does not interleave the layout calculation for data. - * See the note on [cats] for details + * list, starting with empty "RecentlyUsed" page */ - private val recentlyUsed = RecentlyUsed(recentlyUsedFileName, density.pageSize) + private val pages: MutableList> = mutableListOf(listOf()) - val categories: List + fun getCategoryList(): List { + return categories.map { it.first } + } init { - // Add recently used category - categories = listOf(PickerData.RecentlyUsedCategory) + data.map { it.first } - val concat = data.flatMap { it.second.toList() } - // shift the start page of each category in data by one - var start = 1 - var p = 0 - pages = ArrayList() - // Add a placeholder for the recently used page - // We will update it in [updateRecent] - pages.add(arrayOf()) - cats = Array(data.size) { i -> - val v = data[i].second - val filled = v.size / density.pageSize - val rest = v.size % density.pageSize - val pageNum = filled + if (rest != 0) 1 else 0 - for (j in start until start + filled) { - pages.add(j, (p until p + density.pageSize).map { - concat[it] - }.toTypedArray()) - p += density.pageSize - } - if (rest != 0) { - pages.add(start + pageNum - 1, (p until p + rest).map { - concat[it] - }.toTypedArray()) - p += rest - } - (start to pageNum).also { start += pageNum } - } - // Add recently used page - ranges = listOf(0..0) + cats.map { (start, pageNum) -> - start until start + pageNum + val textPaint = TextPaint() + data.forEach { (cat, arr) -> + val chunks = arr.filter { if (isEmoji) textPaint.hasGlyph(it) else true } + .chunked(density.pageSize) + categories.add(cat to IntRange(pages.size, pages.size + chunks.size - 1)) + pages.addAll(chunks) } - recentlyUsed.load() } + private val recentlyUsed = RecentlyUsed(recentlyUsedFileName, density.pageSize) + fun insertRecent(text: String) { if (text.length == 1 && text[0].code.let { it in Digit || it in FullWidthDigit }) return recentlyUsed.insert(text) } - private fun updateRecent() { - pages[0] = recentlyUsed.toOrderedList().toTypedArray() + fun getCategoryIndexOfPage(page: Int): Int { + return categories.indexOfFirst { page in it.second } } - fun saveRecent() { - recentlyUsed.save() + fun getCategoryRangeOfPage(page: Int): IntRange { + return categories.find { page in it.second }?.second ?: IntRange(0, 0) } - fun getCategoryOfPage(page: Int) = - ranges.indexOfFirst { page in it } - - fun getCategoryRangeOfPage(page: Int) = - ranges.find { page in it } ?: (0..0) - - fun getStartPageOfCategory(cat: Int) = - // Recently used category only has one page which must be the first page - if (cat == 0) - 0 - // Otherwise, we need offset it by one - else - cats[cat - 1].first + fun getRangeOfCategoryIndex(cat: Int): IntRange { + return categories[cat].second + } override fun getItemCount() = pages.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(PickerPageUi(parent.context, theme, density)) + return ViewHolder(PickerPageUi(parent.context, theme, density, bordered)) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.ui.setItems(pages[position]) + holder.ui.setItems(pages[position], isEmoji) } override fun onViewAttachedToWindow(holder: ViewHolder) { @@ -122,9 +85,8 @@ class PickerPagesAdapter( if (holder.bindingAdapterPosition == 0) { // prevent popup on RecentlyUsed page holder.ui.popupActionListener = null - // update RecentlyUsed when it's page attached - updateRecent() - holder.ui.setItems(pages[0]) + // RecentlyUsed content are already modified with skin tones + holder.ui.setItems(recentlyUsed.items, withSkinTone = false) } else { holder.ui.popupActionListener = popupActionListener } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt index 85bde9656..00f6700c2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerPaginationUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker @@ -58,6 +58,9 @@ class PickerPaginationUi(override val ctx: Context, val theme: Theme) : Ui { } fun updateScrollProgress(current: Int, progress: Float) { + if (pageCount <= 1) { + return + } highlight.updateLayoutParams { marginStart = ((current + progress) * highlight.width).roundToInt() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt index 3c4b2d954..115e455be 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindow.kt @@ -1,17 +1,18 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.picker +import android.annotation.SuppressLint import android.view.Gravity import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope import androidx.transition.Slide import androidx.transition.Transition import androidx.viewpager2.widget.ViewPager2 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent import org.fcitx.fcitx5.android.input.dependency.inputMethodService import org.fcitx.fcitx5.android.input.dependency.theme @@ -30,10 +31,11 @@ import org.mechdancer.dependency.manager.must class PickerWindow( override val key: Key, - val data: List>>, - val density: PickerPageUi.Density, + private val data: List>>, + private val density: PickerPageUi.Density, private val switchKey: KeyDef, - val popupPreview: Boolean = true + private val popupPreview: Boolean = true, + private val followKeyBorder: Boolean = true ) : InputWindow.ExtendedInputWindow(), EssentialWindow { enum class Key : EssentialWindow.Key { @@ -49,6 +51,8 @@ class PickerWindow( private val popup: PopupComponent by manager.must() private val returnKeyDrawable: ReturnKeyDrawableComponent by manager.must() + private val keyBorder by ThemeManager.prefs.keyBorder + private lateinit var pickerLayout: PickerLayout private lateinit var pickerPagesAdapter: PickerPagesAdapter @@ -116,26 +120,27 @@ class PickerWindow( override fun onCreateView() = PickerLayout(context, theme, switchKey).apply { pickerLayout = this + val bordered = followKeyBorder && keyBorder + val isEmoji = key === Key.Emoji pickerPagesAdapter = PickerPagesAdapter( - theme, keyActionListener, popupActionListener, data, density, key.name + theme, keyActionListener, popupActionListener, data, + density, key.name, bordered, isEmoji ) tabsUi.apply { - setTabs(pickerPagesAdapter.categories) + setTabs(pickerPagesAdapter.getCategoryList()) setOnTabClickListener { i -> - pager.setCurrentItem(pickerPagesAdapter.getStartPageOfCategory(i), false) + pager.setCurrentItem(pickerPagesAdapter.getRangeOfCategoryIndex(i).first, false) } } pager.apply { adapter = pickerPagesAdapter // show first symbol category by default, rather than recently used - val initialPage = pickerPagesAdapter.getStartPageOfCategory(1) - setCurrentItem(initialPage, false) + val range = pickerPagesAdapter.getRangeOfCategoryIndex(1) + setCurrentItem(range.first, false) // update initial tab and page manually to avoid // "Adding or removing callbacks during dispatch to callbacks" tabsUi.activateTab(1) - paginationUi.updatePageCount( - pickerPagesAdapter.getCategoryRangeOfPage(initialPage).run { last - first + 1 } - ) + paginationUi.updatePageCount(range.run { last - first + 1 }) registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageScrolled( position: Int, @@ -143,15 +148,12 @@ class PickerWindow( positionOffsetPixels: Int ) { val range = pickerPagesAdapter.getCategoryRangeOfPage(position) - val start = range.first - val total = range.last - start + 1 - val current = position - start - paginationUi.updatePageCount(total) - paginationUi.updateScrollProgress(current, positionOffset) + paginationUi.updatePageCount(range.run { last - first + 1 }) + paginationUi.updateScrollProgress(position - range.first, positionOffset) } override fun onPageSelected(position: Int) { - tabsUi.activateTab(pickerPagesAdapter.getCategoryOfPage(position)) + tabsUi.activateTab(pickerPagesAdapter.getCategoryIndexOfPage(position)) popup.dismissAll() } }) @@ -160,18 +162,28 @@ class PickerWindow( override fun onCreateBarExtension() = pickerLayout.tabsUi.root + private val skinTonePreference = AppPrefs.getInstance().symbols.defaultEmojiSkinTone + + @SuppressLint("NotifyDataSetChanged") + private val refreshPagesListener = ManagedPreference.OnChangeListener { _, _ -> + pickerPagesAdapter.notifyDataSetChanged() + } + override fun onAttached() { pickerLayout.embeddedKeyboard.also { it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) it.keyActionListener = keyActionListener } + if (key === Key.Emoji) { + skinTonePreference.registerOnChangeListener(refreshPagesListener) + } } override fun onDetached() { popup.dismissAll() pickerLayout.embeddedKeyboard.keyActionListener = null - service.lifecycleScope.launch(Dispatchers.IO) { - pickerPagesAdapter.saveRecent() + if (key === Key.Emoji) { + skinTonePreference.unregisterOnChangeListener(refreshPagesListener) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt index 5d6bd6d95..5fae0d885 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/picker/PickerWindowPreset.kt @@ -21,14 +21,16 @@ fun emojiPicker(): PickerWindow = PickerWindow( key = PickerWindow.Key.Emoji, data = PickerData.Emoji, density = PickerPageUi.Density.Medium, - popupPreview = false, switchKey = TextPickerSwitchKey(":-)", PickerWindow.Key.Emoticon), + popupPreview = false, + followKeyBorder = false ) fun emoticonPicker(): PickerWindow = PickerWindow( key = PickerWindow.Key.Emoticon, data = PickerData.Emoticon, density = PickerPageUi.Density.Low, + switchKey = ImagePickerSwitchKey(R.drawable.ic_baseline_tag_faces_24, PickerWindow.Key.Emoji), popupPreview = false, - switchKey = ImagePickerSwitchKey(R.drawable.ic_baseline_tag_faces_24, PickerWindow.Key.Emoji) + followKeyBorder = false ) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/EmojiModifier.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/EmojiModifier.kt new file mode 100644 index 000000000..600095d09 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/EmojiModifier.kt @@ -0,0 +1,114 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.popup + +import android.icu.lang.UCharacter +import android.icu.lang.UProperty +import android.os.Build +import android.text.TextPaint +import androidx.annotation.RequiresApi +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum +import org.fcitx.fcitx5.android.utils.includes + +object EmojiModifier { + + enum class SkinTone(val value: String, override val stringRes: Int) : ManagedPreferenceEnum { + Default("", R.string.emoji_skin_tone_none), + Type_1_2("🏻", R.string.emoji_skin_tone_type_1_2), + Type_3("🏼", R.string.emoji_skin_tone_type_3), + Type_4("🏽", R.string.emoji_skin_tone_type_4), + Type_5("🏾", R.string.emoji_skin_tone_type_5), + Type_6("🏿", R.string.emoji_skin_tone_type_6) + } + + /** + * **Special Case 1:** Drop `U+FE0F` (Variation Selector-16) when combining with skin tone + */ + private val SpecialCase1 = intArrayOf( + 0x261D, // ☝️ + 0x26F9, // ⛹️ + 0x270C, // ✌️ + 0x1F3CB, // 🏋️ + 0x1F3CC, // 🏌️ + 0x1F574, // 🕴️ + 0x1F575, // 🕵️ + 0x1F590, // 🖐️ + ) + private const val VariationSelector16 = 0xFE0F + + /** + * **Special Case 2:** Make `U+1F91D`(🤝 Handshake) in 🧑‍🤝‍🧑 not modifiable + */ + private val SpecialCase2 = intArrayOf( + 0x1F9D1, 0x200D, 0x1F91D, 0x200D, 0x1F9D1, + ) + + private val defaultSkinTone by AppPrefs.getInstance().symbols.defaultEmojiSkinTone + + fun isSupported(): Boolean { + // UProperty.EMOJI_MODIFIER_BASE requires API 28 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + } + + private fun isModifiable(modifiable: BooleanArray): Boolean { + val sum = modifiable.sumOf { if (it) 1 else 0 } + // bail if too crowded + // eg. https://emojipedia.org/family-man-medium-light-skin-tone-woman-medium-light-skin-tone-girl-medium-light-skin-tone-boy-medium-light-skin-tone + return sum == 1 || sum == 2 + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun getCodePoints(emoji: String): Pair { + val codePoints = emoji.codePoints().toArray() + val modifiable = BooleanArray(codePoints.size) { + UCharacter.hasBinaryProperty(codePoints[it], UProperty.EMOJI_MODIFIER_BASE) + } + // make U+1F91D not modifiable if the whole sequence is special + if (codePoints contentEquals SpecialCase2) { + modifiable[2] = false + } + return codePoints to modifiable + } + + private fun buildEmoji(codePoints: IntArray, modifiable: BooleanArray, tone: SkinTone): String { + return buildString { + for (i in 0.. 0 && codePoints[i] == VariationSelector16 && + SpecialCase1.includes(codePoints[i - 1]) && + tone != SkinTone.Default + ) continue + appendCodePoint(codePoints[i]) + if (modifiable[i]) { + append(tone.value) + } + } + } + } + + private val DefaultTextPaint = TextPaint() + + fun getPreferredTone(emoji: String): String { + if (!isSupported()) return emoji + val (codePoints, modifiable) = getCodePoints(emoji) + if (!isModifiable(modifiable)) return emoji + val candidate = buildEmoji(codePoints, modifiable, defaultSkinTone) + return if (DefaultTextPaint.hasGlyph(candidate)) candidate else emoji + } + + fun produceSkinTones(emoji: String): Array? { + if (!isSupported()) return null + val (codePoints, modifiable) = getCodePoints(emoji) + if (!isModifiable(modifiable)) return null + val candidates = SkinTone.entries + .filter { it != defaultSkinTone } + .map { buildEmoji(codePoints, modifiable, it) } + .filter { DefaultTextPaint.hasGlyph(it) } + return if (candidates.isEmpty()) null else candidates.toTypedArray() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt index be0174211..1d8ffe161 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupComponent.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.popup @@ -59,12 +59,22 @@ class PopupComponent : } private val hideThreshold = 100L + private val rootLocation = intArrayOf(0, 0) + private val rootBounds: Rect = Rect() + val root by lazy { context.frameLayout { // we want (0, 0) at top left layoutDirection = View.LAYOUT_DIRECTION_LTR isClickable = false isFocusable = false + + addOnLayoutChangeListener { v, left, top, right, bottom, _, _, _, _ -> + val (x, y) = rootLocation.also { v.getLocationInWindow(it) } + val width = right - left + val height = bottom - top + rootBounds.set(x, y, x + width, y + height) + } } } @@ -97,7 +107,9 @@ class PopupComponent : } private fun showKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) { - val keys = PopupPreset[keyboard.label] ?: return + val keys = PopupPreset[keyboard.label] + ?: EmojiModifier.produceSkinTones(keyboard.label) + ?: return // clear popup preview text OR create empty popup preview showingEntryUi[viewId]?.setText("") ?: showPopup(viewId, "", bounds) reallyShowKeyboard(viewId, keys, bounds) @@ -110,6 +122,7 @@ class PopupComponent : val keyboardUi = PopupKeyboardUi( context, theme, + rootBounds, bounds, { dismissPopup(viewId) }, popupRadius, @@ -120,13 +133,7 @@ class PopupComponent : keys, labels ) - root.apply { - add(keyboardUi.root, lParams { - leftMargin = bounds.left + keyboardUi.offsetX - topMargin = bounds.top + keyboardUi.offsetY - }) - } - showingContainerUi[viewId] = keyboardUi + showPopupContainer(viewId, keyboardUi) } private fun showMenu(viewId: Int, menu: KeyDef.Popup.Menu, bounds: Rect) { @@ -136,17 +143,22 @@ class PopupComponent : val menuUi = PopupMenuUi( context, theme, + rootBounds, bounds, { dismissPopup(viewId) }, menu.items, ) + showPopupContainer(viewId, menuUi) + } + + private fun showPopupContainer(viewId: Int, ui: PopupContainerUi) { root.apply { - add(menuUi.root, lParams { - leftMargin = bounds.left + menuUi.offsetX - topMargin = bounds.top + menuUi.offsetY + add(ui.root, lParams { + leftMargin = ui.triggerBounds.left + ui.offsetX - rootBounds.left + topMargin = ui.triggerBounds.top + ui.offsetY - rootBounds.top }) } - showingContainerUi[viewId] = menuUi + showingContainerUi[viewId] = ui } private fun changeFocus(viewId: Int, x: Float, y: Float): Boolean { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt index ab51cffc1..8b54eee24 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupContainerUi.kt @@ -15,7 +15,8 @@ import kotlin.math.roundToInt abstract class PopupContainerUi( override val ctx: Context, val theme: Theme, - val bounds: Rect, + val outerBounds: Rect, + val triggerBounds: Rect, val onDismissSelf: PopupContainerUi.() -> Unit ) : Ui { @@ -25,22 +26,28 @@ abstract class PopupContainerUi( abstract override val root: View /** - * Offset on X axis to put this [PopupKeyboardUi] relative to popup trigger view [bounds] + * Offset on X axis to put this [PopupKeyboardUi] relative to popup trigger view [triggerBounds] */ abstract val offsetX: Int /** - * Offset on Y axis to put this [PopupKeyboardUi] relative to popup trigger view [bounds] + * Offset on Y axis to put this [PopupKeyboardUi] relative to popup trigger view [triggerBounds] */ abstract val offsetY: Int - fun calcInitialFocusedColumn(columnCount: Int, columnWidth: Int, bounds: Rect): Int { - val leftSpace = bounds.left - val rightSpace = ctx.resources.displayMetrics.widthPixels - bounds.right + fun calcInitialFocusedColumn( + columnCount: Int, + columnWidth: Int, + outerBounds: Rect, + triggerBounds: Rect + ): Int { + // assume trigger bounds inside outer bounds + val leftSpace = triggerBounds.left - outerBounds.left + val rightSpace = outerBounds.right - triggerBounds.right var col = (columnCount - 1) / 2 while (columnWidth * col > leftSpace) col-- while (columnWidth * (columnCount - col - 1) > rightSpace) col++ - return col + return col.coerceIn(0, columnCount - 1) } /** diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt index 96c1cbb51..f7ba9c4d3 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupKeyboardUi.kt @@ -30,7 +30,8 @@ import kotlin.math.roundToInt /** * @param ctx [Context] * @param theme [Theme] - * @param bounds bound [Rect] of popup trigger view. Used to calculate free space of both sides and + * @param outerBounds bound [Rect] of [PopupComponent] root view. + * @param triggerBounds bound [Rect] of popup trigger view. Used to calculate free space of both sides and * determine column order. See [focusColumn] and [columnOrder]. * @param onDismissSelf callback when popup keyboard wants to close * @param radius popup keyboard and key radius @@ -44,7 +45,8 @@ import kotlin.math.roundToInt class PopupKeyboardUi( override val ctx: Context, theme: Theme, - bounds: Rect, + outerBounds: Rect, + triggerBounds: Rect, onDismissSelf: PopupContainerUi.() -> Unit = {}, private val radius: Float, private val keyWidth: Int, @@ -52,7 +54,7 @@ class PopupKeyboardUi( private val popupHeight: Int, private val keys: Array, private val labels: Array -) : PopupContainerUi(ctx, theme, bounds, onDismissSelf) { +) : PopupContainerUi(ctx, theme, outerBounds, triggerBounds, onDismissSelf) { class PopupKeyUi(override val ctx: Context, val theme: Theme, val text: String) : Ui { @@ -93,7 +95,7 @@ class PopupKeyboardUi( columnCount = (keyCount / rowCount).roundToInt() focusRow = 0 - focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, bounds) + focusColumn = calcInitialFocusedColumn(columnCount, keyWidth, outerBounds, triggerBounds) } /** @@ -125,8 +127,8 @@ class PopupKeyboardUi( * Applying only `1.` parts of both X and Y offset, the origin should transform from `o` to `p`. * `2.` parts of both offset transform it from `p` to `c`. */ - override val offsetX = ((bounds.width() - keyWidth) / 2) - (keyWidth * focusColumn) - override val offsetY = (bounds.height() - popupHeight) - (keyHeight * (rowCount - 1)) + override val offsetX = ((triggerBounds.width() - keyWidth) / 2) - (keyWidth * focusColumn) + override val offsetY = (triggerBounds.height() - popupHeight) - (keyHeight * (rowCount - 1)) private val columnOrder = createColumnOrder(columnCount, focusColumn) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt index 9bc9badf5..86a41075c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupMenuUi.kt @@ -25,10 +25,11 @@ import kotlin.math.floor class PopupMenuUi( override val ctx: Context, theme: Theme, - bounds: Rect, + outerBounds: Rect, + triggerBounds: Rect, onDismissSelf: PopupContainerUi.() -> Unit = {}, private val items: Array -) : PopupContainerUi(ctx, theme, bounds, onDismissSelf) { +) : PopupContainerUi(ctx, theme, outerBounds, triggerBounds, onDismissSelf) { private val keySize = ctx.dp(48) @@ -50,9 +51,10 @@ class PopupMenuUi( ) private val columnCount = items.size - private val focusColumn = calcInitialFocusedColumn(columnCount, keySize, bounds) + private val focusColumn = + calcInitialFocusedColumn(columnCount, keySize, outerBounds, triggerBounds) - override val offsetX = ((bounds.width() - keySize) / 2) - (keySize * focusColumn) + override val offsetX = ((triggerBounds.width() - keySize) / 2) - (keySize * focusColumn) override val offsetY = ctx.dp(-52) private val columnOrder = createColumnOrder(columnCount, focusColumn) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt index 5761466ce..11905d2f1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/popup/PopupPreset.kt @@ -29,8 +29,8 @@ val PopupPreset: Map> = hashMapOf( "g" to arrayOf("=", "G", "ğ"), "h" to arrayOf("/", "H"), "j" to arrayOf("#", "J"), - "k" to arrayOf("(", "K"), - "l" to arrayOf(")", "L", "ł"), + "k" to arrayOf("(", "[", "{", "K"), + "l" to arrayOf(")", "]", "}", "L", "ł"), "z" to arrayOf("'", "Z", "`", "ž", "ź", "ż"), "x" to arrayOf(":", "X", "×"), "c" to arrayOf("\"", "C", "ç", "ć", "č"), @@ -147,6 +147,7 @@ val PopupPreset: Map> = hashMapOf( "<" to arrayOf("≤", "«", "‹", "⟨"), "=" to arrayOf("∞", "≠", "≈"), ">" to arrayOf("≥", "»", "›", "⟩"), + "°" to arrayOf("′", "″", "‴"), // // Currency // diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt index 1e7cf2322..0f2c4a59a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditComponent.kt @@ -6,6 +6,8 @@ package org.fcitx.fcitx5.android.input.preedit import android.view.View import org.fcitx.fcitx5.android.core.FcitxEvent +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.dependency.context import org.fcitx.fcitx5.android.input.dependency.theme @@ -13,6 +15,9 @@ import org.mechdancer.dependency.Dependent import org.mechdancer.dependency.UniqueComponent import org.mechdancer.dependency.manager.ManagedHandler import org.mechdancer.dependency.manager.managedHandler +import splitties.dimensions.dp +import splitties.views.backgroundColor +import splitties.views.horizontalPadding class PreeditComponent : UniqueComponent(), Dependent, InputBroadcastReceiver, ManagedHandler by managedHandler() { @@ -20,7 +25,19 @@ class PreeditComponent : UniqueComponent(), Dependent, InputBr private val context by manager.context() private val theme by manager.theme() - val ui by lazy { PreeditUi(context, theme) } + val ui by lazy { + val keyBorder = ThemeManager.prefs.keyBorder.getValue() + val bkgColor = + if (!keyBorder && theme is Theme.Builtin) theme.barColor else theme.backgroundColor + PreeditUi(context, theme, setupTextView = { + backgroundColor = bkgColor + horizontalPadding = dp(8) + }).apply { + // TODO make it customizable + root.alpha = 0.8f + root.visibility = View.INVISIBLE + } + } override fun onInputPanelUpdate(data: FcitxEvent.InputPanelEvent.Data) { ui.update(data) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt index d9c4b2370..7c7a8b476 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/preedit/PreeditUi.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.preedit @@ -17,17 +17,18 @@ import androidx.annotation.ColorInt import androidx.core.text.buildSpannedString import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.data.theme.Theme -import org.fcitx.fcitx5.android.data.theme.ThemeManager import splitties.dimensions.dp -import splitties.views.backgroundColor import splitties.views.dsl.core.Ui import splitties.views.dsl.core.add import splitties.views.dsl.core.lParams import splitties.views.dsl.core.textView import splitties.views.dsl.core.verticalLayout -import splitties.views.horizontalPadding -class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui { +open class PreeditUi( + override val ctx: Context, + private val theme: Theme, + private val setupTextView: (TextView.() -> Unit)? = null +) : Ui { class CursorSpan(ctx: Context, @ColorInt color: Int, metrics: Paint.FontMetricsInt) : DynamicDrawableSpan() { @@ -43,18 +44,10 @@ class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui { CursorSpan(ctx, theme.keyTextColor, upView.paint.fontMetricsInt) } - private val keyBorder by ThemeManager.prefs.keyBorder - - private val barBackground = when (theme) { - is Theme.Builtin -> if (keyBorder) theme.backgroundColor else theme.barColor - is Theme.Custom -> theme.backgroundColor - } - private fun createTextView() = textView { - backgroundColor = barBackground - horizontalPadding = dp(8) setTextColor(theme.keyTextColor) textSize = 16f + setupTextView?.invoke(this) } private val upView = createTextView() @@ -65,43 +58,41 @@ class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui { private set override val root: View = verticalLayout { - alpha = 0.8f - visibility = View.INVISIBLE add(upView, lParams()) add(downView, lParams()) } - private fun updateTextView(view: TextView, str: CharSequence, visible: Boolean) = view.run { - if (visible) { - text = str - if (visibility == View.GONE) visibility = View.VISIBLE - } else if (visibility != View.GONE) { - visibility = View.GONE - } + private fun updateTextView(view: TextView, str: CharSequence, visible: Boolean) { + view.text = str + view.visibility = if (visible) View.VISIBLE else View.GONE } fun update(inputPanel: FcitxEvent.InputPanelEvent.Data) { - val bkgColor = theme.genericActiveBackgroundColor + val activeBkg = theme.genericActiveBackgroundColor val upString: SpannedString val upCursor: Int if (inputPanel.auxUp.isEmpty()) { - upString = inputPanel.preedit.toSpannedString(bkgColor) + upString = inputPanel.preedit.toSpannedString(activeBkg) upCursor = inputPanel.preedit.cursor } else { upString = buildSpannedString { - append(inputPanel.auxUp.toSpannedString(bkgColor)) - append(inputPanel.preedit.toSpannedString(bkgColor)) + append(inputPanel.auxUp.toSpannedString(activeBkg)) + append(inputPanel.preedit.toSpannedString(activeBkg)) } upCursor = inputPanel.preedit.cursor.let { if (it < 0) it else inputPanel.auxUp.length + it } } - val downString = inputPanel.auxDown.toSpannedString(bkgColor) + val downString = inputPanel.auxDown.toSpannedString(activeBkg) val hasUp = upString.isNotEmpty() val hasDown = downString.isNotEmpty() visible = hasUp || hasDown - if (!visible) return + if (!visible) { + updateTextView(upView, "", false) + updateTextView(downView, "", false) + return + } val upStringWithCursor = if (upCursor < 0 || upCursor == upString.length) { upString } else buildSpannedString { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt index 04bfb92de..5e0c37204 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/status/StatusAreaWindow.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.input.status @@ -14,9 +14,9 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.Action +import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.daemon.FcitxConnection import org.fcitx.fcitx5.android.daemon.launchOnReady -import org.fcitx.fcitx5.android.core.SubtypeManager import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService @@ -73,7 +73,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), ReloadConfig ), StatusAreaEntry.Android( - context.getString(R.string.keyboard), + context.getString(R.string.virtual_keyboard), R.drawable.ic_baseline_keyboard_24, Keyboard ) @@ -101,7 +101,7 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), val popup = PopupMenu(context, view) val menu = popup.menu val hasDivider = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !DeviceUtil.isHMOS) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !DeviceUtil.isHMOS && !DeviceUtil.isHonorMagicOS) { menu.setGroupDividerEnabled(true) true } else { @@ -183,12 +183,14 @@ class StatusAreaWindow : InputWindow.ExtendedInputWindow(), private val editorInfoButton by lazy { ToolButton(context, R.drawable.ic_baseline_info_24, theme).apply { + contentDescription = context.getString(R.string.editor_info_inspector) setOnClickListener { windowManager.attachWindow(EditorInfoWindow()) } } } private val settingsButton by lazy { ToolButton(context, R.drawable.ic_baseline_settings_24, theme).apply { + contentDescription = context.getString(R.string.open_input_method_settings) setOnClickListener { AppUtil.launchMain(context) } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt b/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt index aacef75d6..ab4127cf7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/provider/FcitxDataProvider.kt @@ -78,7 +78,7 @@ class FcitxDataProvider : DocumentsProvider() { private fun fileFromDocId(docId: String) = File(docIdPrefix, docId) override fun onCreate(): Boolean { - baseDir = context!!.getExternalFilesDir(null)!! + baseDir = context!!.getExternalFilesDir(null) ?: return false docIdPrefix = "${baseDir.parent}${File.separator}" textFilePaths = Array(TEXT_FILES.size) { baseDir.resolve(TEXT_FILES[it]).absolutePath } return true diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListPreset.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListPreset.kt new file mode 100644 index 000000000..779a0b8e8 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/DynamicListPreset.kt @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.common + +import android.content.Context +import android.view.View +import android.widget.CheckBox +import android.widget.ImageButton + +@Suppress("FunctionName") +fun Context.DynamicListUi( + mode: BaseDynamicListUi.Mode, + initialEntries: List, + enableOrder: Boolean = false, + initCheckBox: (CheckBox.(T) -> Unit) = { visibility = View.GONE }, + initSettingsButton: (ImageButton.(T) -> Unit) = { visibility = View.GONE }, + show: (T) -> String +): BaseDynamicListUi = object : + BaseDynamicListUi( + this, + mode, + initialEntries, + enableOrder, + initCheckBox, + initSettingsButton + ) { + init { + addTouchCallback() + } + + override fun showEntry(x: T): String = show(x) +} + +@Suppress("FunctionName") +fun Context.CheckBoxListUi( + initialEntries: List, + initCheckBox: (CheckBox.(T) -> Unit), + initSettingsButton: (ImageButton.(T) -> Unit), + show: (T) -> String +) = DynamicListUi( + BaseDynamicListUi.Mode.Immutable(), + initialEntries, + false, + initCheckBox, + initSettingsButton, + show +) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/Preset.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ProgressBarDialogIndeterminate.kt similarity index 58% rename from app/src/main/java/org/fcitx/fcitx5/android/ui/common/Preset.kt rename to app/src/main/java/org/fcitx/fcitx5/android/ui/common/ProgressBarDialogIndeterminate.kt index 33e9b9bf8..373857cd6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/common/Preset.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/common/ProgressBarDialogIndeterminate.kt @@ -1,13 +1,14 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.ui.common +import android.animation.ValueAnimator import android.content.Context -import android.view.View -import android.widget.CheckBox -import android.widget.ImageButton +import android.os.Build +import android.provider.Settings import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LifecycleCoroutineScope @@ -15,53 +16,18 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.utils.getGlobalSettings import splitties.dimensions.dp +import splitties.resources.resolveThemeAttribute import splitties.views.dsl.core.add import splitties.views.dsl.core.horizontalMargin import splitties.views.dsl.core.lParams import splitties.views.dsl.core.matchParent import splitties.views.dsl.core.styles.AndroidStyles +import splitties.views.dsl.core.textView import splitties.views.dsl.core.verticalLayout import splitties.views.dsl.core.verticalMargin - -@Suppress("FunctionName") -fun Context.DynamicListUi( - mode: BaseDynamicListUi.Mode, - initialEntries: List, - enableOrder: Boolean = false, - initCheckBox: (CheckBox.(T) -> Unit) = { visibility = View.GONE }, - initSettingsButton: (ImageButton.(T) -> Unit) = { visibility = View.GONE }, - show: (T) -> String -): BaseDynamicListUi = object : - BaseDynamicListUi( - this, - mode, - initialEntries, - enableOrder, - initCheckBox, - initSettingsButton - ) { - init { - addTouchCallback() - } - - override fun showEntry(x: T): String = show(x) -} - -@Suppress("FunctionName") -fun Context.CheckBoxListUi( - initialEntries: List, - initCheckBox: (CheckBox.(T) -> Unit), - initSettingsButton: (ImageButton.(T) -> Unit), - show: (T) -> String -) = DynamicListUi( - BaseDynamicListUi.Mode.Immutable(), - initialEntries, - false, - initCheckBox, - initSettingsButton, - show -) +import splitties.views.textAppearance @Suppress("FunctionName") fun Context.ProgressBarDialogIndeterminate(@StringRes title: Int): AlertDialog.Builder { @@ -69,8 +35,20 @@ fun Context.ProgressBarDialogIndeterminate(@StringRes title: Int): AlertDialog.B return AlertDialog.Builder(this) .setTitle(title) .setView(verticalLayout { - add(androidStyles.progressBar.horizontal { - isIndeterminate = true + val shouldAnimate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ValueAnimator.areAnimatorsEnabled() + } else { + getGlobalSettings(Settings.Global.ANIMATOR_DURATION_SCALE) > 0f + } + add(if (shouldAnimate) { + androidStyles.progressBar.horizontal { + isIndeterminate = true + } + } else { + textView { + setText(R.string.please_wait) + textAppearance = resolveThemeAttribute(android.R.attr.textAppearanceListItem) + } }, lParams { width = matchParent verticalMargin = dp(20) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt index 23bae2bb9..0cced1194 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/AboutFragment.kt @@ -1,23 +1,26 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main +import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Bundle -import androidx.navigation.fragment.findNavController import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import org.fcitx.fcitx5.android.utils.Const import org.fcitx.fcitx5.android.utils.addCategory import org.fcitx.fcitx5.android.utils.addPreference import org.fcitx.fcitx5.android.utils.formatDateTime +import org.fcitx.fcitx5.android.utils.navigateWithAnim class AboutFragment : PaddingPreferenceFragment() { + @SuppressLint("UseKtx") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { addPreference(R.string.privacy_policy) { @@ -27,7 +30,7 @@ class AboutFragment : PaddingPreferenceFragment() { R.string.open_source_licenses, R.string.licenses_of_third_party_libraries ) { - findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + navigateWithAnim(SettingsRoute.License) } addPreference(R.string.source_code, R.string.github_repo) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.githubRepo))) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/CropImageActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/CropImageActivity.kt new file mode 100644 index 000000000..3651e4382 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/CropImageActivity.kt @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable +import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import com.canhub.cropper.CropImageOptions +import com.canhub.cropper.CropImageView +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.utils.item +import org.fcitx.fcitx5.android.utils.parcelable +import org.fcitx.fcitx5.android.utils.subMenu +import org.fcitx.fcitx5.android.utils.toast +import splitties.dimensions.dp +import splitties.resources.styledColor +import splitties.views.backgroundColor +import splitties.views.dsl.constraintlayout.below +import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.topOfParent +import splitties.views.dsl.core.add +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.view +import splitties.views.dsl.core.wrapContent +import splitties.views.topPadding +import timber.log.Timber +import java.io.File + +class CropImageActivity : AppCompatActivity(), CropImageView.OnCropImageCompleteListener { + + companion object { + const val CROP_OPTIONS = "crop_options" + const val CROP_RESULT = "crop_result" + } + + sealed class CropOption() : Parcelable { + abstract val width: Int + abstract val height: Int + + @Parcelize + data class New(override val width: Int, override val height: Int) : CropOption() + + @Parcelize + data class Edit( + override val width: Int, + override val height: Int, + val sourceUri: Uri, + val initialRect: Rect? = null, + val initialRotation: Int = 0 + ) : CropOption() + } + + sealed class CropResult : Parcelable { + @Parcelize + data object Fail : CropResult() + + @Parcelize + data class Success( + val rect: Rect, + val rotation: Int, + val file: File, + val srcUri: Uri + ) : CropResult() { + @IgnoredOnParcel + private var _bitmap: Bitmap? = null + val bitmap: Bitmap + get() { + _bitmap?.let { return it } + return BitmapFactory.decodeFile(file.path).also { + _bitmap = it + file.delete() + } + } + } + } + + class CropContract : ActivityResultContract() { + override fun createIntent(context: Context, input: CropOption): Intent { + return Intent(context, CropImageActivity::class.java).putExtra(CROP_OPTIONS, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): CropResult { + val result = intent?.parcelable(CROP_RESULT) + if (resultCode != RESULT_OK || result == null) { + return CropResult.Fail + } + return result + } + } + + private lateinit var cropOption: CropOption + + private lateinit var root: ConstraintLayout + private lateinit var toolbar: Toolbar + private lateinit var cropView: CropImageView + + private fun getDefaultCropImageOptions() = CropImageOptions( + // CropImageView + snapRadius = 0f, + guidelines = CropImageView.Guidelines.ON_TOUCH, + showProgressBar = true, + progressBarColor = styledColor(android.R.attr.colorAccent), + // CropOverlayView + borderLineThickness = dp(1f), + borderCornerOffset = 0f, + ) + + private var selectedImageUri: Uri? = null + + private val launcher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + setResult(RESULT_CANCELED) + finish() + } else { + selectedImageUri = uri + cropView.setImageUriAsync(uri) + } + } + + private lateinit var tempOutFile: File + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + cropOption = intent.parcelable(CROP_OPTIONS) ?: CropOption.New(1, 1) + enableEdgeToEdge() + setupRootView() + setContentView(root) + setupCropView(cropOption) + onBackPressedDispatcher.addCallback { + setResult(RESULT_CANCELED) + finish() + } + toolbar.setNavigationOnClickListener { + onBackPressedDispatcher.onBackPressed() + } + } + + private fun setupRootView() { + toolbar = view(::Toolbar) { + backgroundColor = styledColor(android.R.attr.colorPrimary) + elevation = dp(4f) + navigationIcon = DrawerArrowDrawable(context).apply { progress = 1f } + setupToolbarMenu(menu) + } + cropView = CropImageView(this).apply { + setOnCropImageCompleteListener(this@CropImageActivity) + setImageCropOptions(getDefaultCropImageOptions()) + } + root = constraintLayout { + add(toolbar, lParams(matchParent, wrapContent) { + topOfParent() + centerHorizontally() + }) + add(cropView, lParams(matchParent) { + below(toolbar) + centerHorizontally() + bottomOfParent() + }) + } + ViewCompat.setOnApplyWindowInsetsListener(root) { _, windowInsets -> + val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) + val navBars = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) + root.updateLayoutParams { + leftMargin = navBars.left + rightMargin = navBars.right + bottomMargin = navBars.bottom + } + toolbar.topPadding = statusBars.top + windowInsets + } + } + + private fun setupToolbarMenu(menu: Menu) { + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.rotate, R.drawable.ic_baseline_rotate_right_24, iconTint, true) { + cropView.rotateImage(90) + } + menu.subMenu(R.string.flip, R.drawable.ic_baseline_flip_24, iconTint, true) { + item(R.string.flip_vertically) { + cropView.flipImageVertically() + } + item(R.string.flip_horizontally) { + cropView.flipImageHorizontally() + } + } + menu.item(R.string.crop, R.drawable.ic_baseline_check_24, iconTint, true) { + onCropImage() + } + } + + private fun setupCropView(option: CropOption) { + cropView.setAspectRatio(option.width, option.height) + when (option) { + is CropOption.New -> { + launcher.launch("image/*") + } + is CropOption.Edit -> { + cropView.setOnSetImageUriCompleteListener { view, uri, e -> + view.cropRect = option.initialRect + view.rotatedDegrees = option.initialRotation + } + cropView.setImageUriAsync(option.sourceUri) + } + } + } + + private fun onCropImage() { + tempOutFile = File.createTempFile("cropped", ".png", cacheDir) + cropView.croppedImageAsync( + saveCompressFormat = Bitmap.CompressFormat.PNG, + reqWidth = cropOption.width, + reqHeight = cropOption.height, + options = CropImageView.RequestSizeOptions.RESIZE_INSIDE, + customOutputUri = Uri.fromFile(tempOutFile) + ) + } + + override fun onCropImageComplete(view: CropImageView, result: CropImageView.CropResult) { + try { + result + val success = CropResult.Success( + result.cropRect!!, + result.rotation, + tempOutFile, + (cropOption as? CropOption.Edit)?.sourceUri ?: selectedImageUri!! + ) + setResult(RESULT_OK, Intent().putExtra(CROP_RESULT, success)) + } catch (e: Exception) { + Timber.e("Exception when cropping image: $e") + toast(e) + setResult(RESULT_CANCELED) + } + finish() + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt index 56b0a96fe..d02f4881a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/DeveloperFragment.kt @@ -93,12 +93,29 @@ class DeveloperFragment : PaddingPreferenceFragment() { isIconSpaceReserved = false isSingleLineTitle = false }) + addPreference(R.string.restart_fcitx_instance) { + AlertDialog.Builder(context) + .setTitle(R.string.restart_fcitx_instance) + .setMessage(R.string.restart_fcitx_instance_confirm) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + lifecycleScope.launch { + withContext(NonCancellable + Dispatchers.IO) { + FcitxDaemon.stopFcitx() + withContext(Dispatchers.Main) { + context.toast(R.string.done) + } + } + } + } + .show() + } addPreference(R.string.delete_and_sync_data) { AlertDialog.Builder(context) .setTitle(R.string.delete_and_sync_data) .setMessage(R.string.delete_and_sync_data_message) .setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { DataManager.deleteAndSync() withContext(Dispatchers.Main) { context.toast(R.string.synced) @@ -113,7 +130,7 @@ class DeveloperFragment : PaddingPreferenceFragment() { .setTitle(R.string.clear_clb_db) .setMessage(R.string.clear_clp_db_confirm) .setPositiveButton(android.R.string.ok) { _, _ -> - lifecycleScope.launch(Dispatchers.IO) { + lifecycleScope.launch(NonCancellable + Dispatchers.IO) { ClipboardManager.nukeTable() withContext(Dispatchers.Main) { context.toast(R.string.done) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt index 9fd826090..937998890 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/LogActivity.kt @@ -6,7 +6,6 @@ package org.fcitx.fcitx5.android.ui.main import android.os.Bundle import android.view.Menu -import android.view.MenuItem import android.view.ViewGroup import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher @@ -27,8 +26,8 @@ import org.fcitx.fcitx5.android.ui.main.log.LogView import org.fcitx.fcitx5.android.utils.DeviceInfo import org.fcitx.fcitx5.android.utils.Logcat import org.fcitx.fcitx5.android.utils.iso8601UTCDateTime +import org.fcitx.fcitx5.android.utils.item import org.fcitx.fcitx5.android.utils.toast -import splitties.resources.drawable import splitties.resources.styledColor import splitties.views.topPadding @@ -98,27 +97,14 @@ class LogActivity : AppCompatActivity() { } override fun onCreateOptionsMenu(menu: Menu): Boolean { + val iconTint = styledColor(android.R.attr.colorControlNormal) if (!fromCrash) { - menu.add(R.string.clear).apply { - icon = drawable(R.drawable.ic_baseline_delete_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - logView.clear() - true - } + menu.item(R.string.clear, R.drawable.ic_baseline_delete_24, iconTint, true) { + logView.clear() } } - menu.add(R.string.export).apply { - icon = drawable(R.drawable.ic_baseline_save_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - launcher.launch("$packageName-${iso8601UTCDateTime()}.txt") - true - } + menu.item(R.string.export, R.drawable.ic_baseline_save_24, iconTint, true) { + launcher.launch("$packageName-${iso8601UTCDateTime()}.txt") } return true } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt index cbc967297..20fdf18ea 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainActivity.kt @@ -1,39 +1,42 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main import android.Manifest +import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.view.Menu -import android.view.MenuItem import android.view.ViewGroup import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.forEach import androidx.core.view.updateLayoutParams import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController +import org.fcitx.fcitx5.android.BuildConfig import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.databinding.ActivityMainBinding -import org.fcitx.fcitx5.android.ui.main.settings.PinyinDictionaryFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import org.fcitx.fcitx5.android.ui.setup.SetupActivity import org.fcitx.fcitx5.android.utils.Const +import org.fcitx.fcitx5.android.utils.item +import org.fcitx.fcitx5.android.utils.navigateWithAnim +import org.fcitx.fcitx5.android.utils.parcelable import org.fcitx.fcitx5.android.utils.startActivity import splitties.dimensions.dp -import splitties.resources.drawable import splitties.resources.styledColor import splitties.views.topPadding @@ -58,13 +61,15 @@ class MainActivity : AppCompatActivity() { windowInsets } setContentView(binding.root) - setSupportActionBar(binding.toolbar) - val appBarConfiguration = AppBarConfiguration( - // always show back icon regardless of `navController.currentDestination` - topLevelDestinationIds = setOf() - ) + // always show toolbar back arrow icon + // https://android.googlesource.com/platform/frameworks/support/+/32e643112d0217619237a0d7101b50919c6caf51/navigation/navigation-ui/src/main/java/androidx/navigation/ui/AbstractAppBarOnDestinationChangedListener.kt#80 + binding.toolbar.navigationIcon = DrawerArrowDrawable(this).apply { progress = 1f } + // show menu icon and other action icons on toolbar + // don't use `setSupportActionBar(binding.toolbar)` here, + // because navController would change toolbar title, we need to control it by ourselves + setupToolbarMenu(binding.toolbar.menu) navController = binding.navHostFragment.getFragment().navController - binding.toolbar.setupWithNavController(navController, appBarConfiguration) + navController.graph = SettingsRoute.createGraph(navController) binding.toolbar.setNavigationOnClickListener { // prevent navigate up when child fragment has enabled `OnBackPressedCallback` if (onBackPressedDispatcher.hasEnabledCallbacks()) { @@ -81,117 +86,83 @@ class MainActivity : AppCompatActivity() { binding.toolbar.elevation = dp(if (it) 4f else 0f) } navController.addOnDestinationChangedListener { _, dest, _ -> - when (dest.id) { - R.id.themeFragment -> viewModel.disableToolbarShadow() - else -> viewModel.enableToolbarShadow() + dest.label?.let { viewModel.setToolbarTitle(it.toString()) } + if (dest.hasRoute()) { + viewModel.disableToolbarShadow() + } else { + viewModel.enableToolbarShadow() } } - if (intent?.action == Intent.ACTION_MAIN && SetupActivity.shouldShowUp()) { - startActivity() - } else { - processIntent(intent) - } - addOnNewIntentListener { - processIntent(it) - } + processIntent(intent) checkNotificationPermission() } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + processIntent(intent) + } + private fun processIntent(intent: Intent?) { - if (intent?.action == Intent.ACTION_VIEW) { - intent.data?.let { + val action = intent?.action ?: return + when (action) { + Intent.ACTION_MAIN -> if (SetupActivity.shouldShowUp()) { + startActivity() + } + Intent.ACTION_VIEW -> intent.data?.let { AlertDialog.Builder(this) .setTitle(R.string.pinyin_dict) .setMessage(R.string.whether_import_dict) .setNegativeButton(android.R.string.cancel) { _, _ -> } .setPositiveButton(android.R.string.ok) { _, _ -> - navController.popBackStack(R.id.mainFragment, false) - navController.navigate( - R.id.action_mainFragment_to_pinyinDictionaryFragment, - bundleOf(PinyinDictionaryFragment.INTENT_DATA_URI to it) - ) + navController.popBackStack(SettingsRoute.Index, false) + navController.navigateWithAnim(SettingsRoute.PinyinDict(it)) } .show() } + Intent.ACTION_RUN -> { + val route = intent.parcelable(EXTRA_SETTINGS_ROUTE) ?: return + navController.popBackStack(SettingsRoute.Index, false) + navController.navigateWithAnim(route) + } } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menu.apply { - add(R.string.save).apply { - icon = drawable(R.drawable.ic_baseline_save_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarSaveButtonOnClickListener.apply { - observe(this@MainActivity) { listener -> isVisible = listener != null } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarSaveButtonOnClickListener.value?.invoke() - true - } - } - val aboutMenus = mutableListOf() - add(R.string.faq).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.faqUrl))) - true - } - } - add(R.string.developer).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_developerFragment) - true - } - } - add(R.string.about).apply { - aboutMenus.add(this) - setOnMenuItemClickListener { - navController.navigate(R.id.action_mainFragment_to_aboutFragment) - true - } - } - viewModel.aboutButton.apply { - observe(this@MainActivity) { enabled -> - aboutMenus.forEach { menu -> menu.isVisible = enabled } - } - setValue(value) - } - - add(R.string.edit).apply { - icon = drawable(R.drawable.ic_baseline_edit_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarEditButtonVisible.apply { - observe(this@MainActivity) { isVisible = it } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarEditButtonOnClickListener.value?.invoke() - true - } - } - - add(R.string.delete).apply { - icon = drawable(R.drawable.ic_baseline_delete_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - viewModel.toolbarDeleteButtonOnClickListener.apply { - observe(this@MainActivity) { listener -> isVisible = listener != null } - setValue(value) - } - setOnMenuItemClickListener { - viewModel.toolbarDeleteButtonOnClickListener.value?.invoke() - true - } + private fun setupToolbarMenu(menu: Menu) { + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.save, R.drawable.ic_baseline_save_24, iconTint, true) { + viewModel.toolbarSaveButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarSaveButtonOnClickListener + .observe(this@MainActivity) { listener -> isVisible = listener != null } + } + val aboutMenuItems = listOf( + menu.item(R.string.faq) { + @SuppressLint("UseKtx") + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Const.faqUrl))) + }, + menu.item(R.string.developer) { + navController.navigateWithAnim(SettingsRoute.Developer) + }, + menu.item(R.string.about) { + navController.navigateWithAnim(SettingsRoute.About) } + ) + viewModel.aboutButton.observe(this@MainActivity) { enabled -> + aboutMenuItems.forEach { menu -> menu.isVisible = enabled } } - return true + menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint, true) { + viewModel.toolbarEditButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarEditButtonVisible.observe(this@MainActivity) { isVisible = it } + } + menu.item(R.string.delete, R.drawable.ic_baseline_delete_24, iconTint, true) { + viewModel.toolbarDeleteButtonOnClickListener.value?.invoke() + }.apply { + viewModel.toolbarDeleteButtonOnClickListener + .observe(this@MainActivity) { listener -> isVisible = listener != null } + } + // all menus should be invisible and enabled on demand + menu.forEach { it.isVisible = false } } private var needNotifications by AppPrefs.getInstance().internal.needNotifications @@ -239,4 +210,8 @@ class MainActivity : AppCompatActivity() { super.onStop() } + companion object { + const val EXTRA_SETTINGS_ROUTE = "${BuildConfig.APPLICATION_ID}.EXTRA_SETTINGS_ROUTE" + } + } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt index 647a0b5a0..20334a125 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/MainFragment.kt @@ -1,20 +1,20 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main import android.os.Bundle import androidx.annotation.DrawableRes -import androidx.annotation.IdRes import androidx.annotation.StringRes import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceCategory import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import org.fcitx.fcitx5.android.utils.addCategory import org.fcitx.fcitx5.android.utils.addPreference +import org.fcitx.fcitx5.android.utils.navigateWithAnim class MainFragment : PaddingPreferenceFragment() { @@ -33,10 +33,10 @@ class MainFragment : PaddingPreferenceFragment() { private fun PreferenceCategory.addDestinationPreference( @StringRes title: Int, @DrawableRes icon: Int, - @IdRes destination: Int + route: SettingsRoute ) { addPreference(title, icon = icon) { - findNavController().navigate(destination) + navigateWithAnim(route) } } @@ -46,44 +46,54 @@ class MainFragment : PaddingPreferenceFragment() { addDestinationPreference( R.string.global_options, R.drawable.ic_baseline_tune_24, - R.id.action_mainFragment_to_globalConfigFragment + SettingsRoute.GlobalConfig ) addDestinationPreference( R.string.input_methods, R.drawable.ic_baseline_language_24, - R.id.action_mainFragment_to_imListFragment + SettingsRoute.InputMethodList ) addDestinationPreference( R.string.addons, R.drawable.ic_baseline_extension_24, - R.id.action_mainFragment_to_addonListFragment + SettingsRoute.AddonList ) } addCategory("Android") { addDestinationPreference( - R.string.keyboard, + R.string.theme, + R.drawable.ic_baseline_palette_24, + SettingsRoute.Theme + ) + addDestinationPreference( + R.string.virtual_keyboard, R.drawable.ic_baseline_keyboard_24, - R.id.action_mainFragment_to_keyboardSettingsFragment + SettingsRoute.VirtualKeyboard ) addDestinationPreference( - R.string.theme, - R.drawable.ic_baseline_palette_24, - R.id.action_mainFragment_to_themeFragment + R.string.candidates_window, + R.drawable.ic_baseline_list_alt_24, + SettingsRoute.CandidatesWindow ) addDestinationPreference( R.string.clipboard, R.drawable.ic_clipboard, - R.id.action_mainFragment_to_clipboardSettingsFragment + SettingsRoute.Clipboard + ) + addDestinationPreference( + R.string.emoji_and_symbols, + R.drawable.ic_baseline_emoji_symbols_24, + SettingsRoute.Symbol ) addDestinationPreference( R.string.plugins, R.drawable.ic_baseline_android_24, - R.id.action_mainFragment_to_pluginFragment + SettingsRoute.Plugin ) addDestinationPreference( R.string.advanced, R.drawable.ic_baseline_more_horiz_24, - R.id.action_mainFragment_to_advancedSettingsFragment + SettingsRoute.Advanced ) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt index 181795f62..c5a03a16f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/PluginFragment.kt @@ -5,8 +5,11 @@ package org.fcitx.fcitx5.android.ui.main import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -27,34 +30,91 @@ import org.fcitx.fcitx5.android.utils.addPreference class PluginFragment : PaddingPreferenceFragment() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + private var firstRun = true + + private lateinit var synced: DataManager.PluginSet + private lateinit var detected: DataManager.PluginSet + + private val packageChangeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + refreshPreferencesWhenNeeded() + } + } + + private fun DataManager.whenSynced(block: () -> Unit) { lifecycleScope.launch { - val needsReload = if (DataManager.synced) { - val (newPluginsToLoad, _) = DataManager.detectPlugins() - newPluginsToLoad != DataManager.getLoadedPlugins() - } else { - DataManager.waitSynced() - false + if (!synced) { + suspendCancellableCoroutine { + if (synced) { + it.resumeWith(Result.success(Unit)) + } else { + addOnNextSyncedCallback { + it.resumeWith(Result.success(Unit)) + } + } + } } - preferenceScreen = createPreferenceScreen(needsReload) + block.invoke() + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + DataManager.whenSynced { + synced = DataManager.getSyncedPluginSet() + detected = DataManager.detectPlugins() + preferenceScreen = createPreferenceScreen() } } - private fun createPreferenceScreen(needsReload: Boolean): PreferenceScreen = + private fun refreshPreferencesWhenNeeded() { + DataManager.whenSynced { + val newDetected = DataManager.detectPlugins() + if (detected != newDetected) { + detected = newDetected + preferenceScreen = createPreferenceScreen() + } + } + } + + override fun onResume() { + super.onResume() + requireContext().registerReceiver(packageChangeReceiver, IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addDataScheme("package") + }) + /** + * [onResume] got called after [onCreatePreferences] when the fragment is created and + * shown for the first time + */ + if (firstRun) { + firstRun = false + return + } + // try refresh plugin list when the user navigate back from other apps + refreshPreferencesWhenNeeded() + } + + override fun onPause() { + super.onPause() + requireContext().unregisterReceiver(packageChangeReceiver) + } + + private fun createPreferenceScreen(): PreferenceScreen = preferenceManager.createPreferenceScreen(requireContext()).apply { - if (needsReload) { + if (synced != detected) { addPreference(R.string.plugin_needs_reload, icon = R.drawable.ic_baseline_info_24) { DataManager.addOnNextSyncedCallback { - // recreate the plugin list - // we only check new plugins on the first creation - preferenceScreen = createPreferenceScreen(false) + synced = DataManager.getSyncedPluginSet() + detected = DataManager.detectPlugins() + preferenceScreen = createPreferenceScreen() } // DataManager.sync and and restart fcitx FcitxDaemon.restartFcitx() } } - val loaded = DataManager.getLoadedPlugins() - val failed = DataManager.getFailedPlugins() + val (loaded, failed) = synced if (loaded.isEmpty() && failed.isEmpty()) { // use PreferenceCategory to show a divider below the "reload" preference addCategory(R.string.no_plugins) { @@ -112,15 +172,6 @@ class PluginFragment : PaddingPreferenceFragment() { } } - private suspend fun DataManager.waitSynced() = suspendCancellableCoroutine { - if (synced) - it.resumeWith(Result.success(Unit)) - else - addOnNextSyncedCallback { - it.resumeWith(Result.success(Unit)) - } - } - private fun startPluginAboutActivity(pkg: String): Boolean { val ctx = requireContext() val pm = ctx.packageManager @@ -148,4 +199,4 @@ class PluginFragment : PaddingPreferenceFragment() { return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt index debe663f7..c15898d70 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/FcitxPreferenceFragment.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings @@ -13,11 +13,11 @@ import androidx.preference.isEmpty import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.daemon.FcitxConnection -import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.ui.common.PaddingPreferenceFragment import org.fcitx.fcitx5.android.ui.common.withLoadingDialog import org.fcitx.fcitx5.android.ui.main.MainViewModel @@ -39,14 +39,13 @@ abstract class FcitxPreferenceFragment : PaddingPreferenceFragment() { private val fcitx: FcitxConnection get() = viewModel.fcitx - fun requireStringArg(key: String) = - requireArguments().getString(key) - ?: throw IllegalStateException("No $key found in bundle") - private fun save() { if (!configLoaded) return - fcitx.launchOnReady { - saveConfig(it, raw["cfg"]) + // launch "saveConfig" job under supervisorJob scope + scope.launch { + fcitx.runOnReady { + saveConfig(this, raw["cfg"]) + } } } @@ -57,7 +56,7 @@ abstract class FcitxPreferenceFragment : PaddingPreferenceFragment() { // prevent "back" from navigating away from this Fragment when it's still saving override fun handleOnBackPressed() { lifecycleScope.withLoadingDialog(requireContext(), R.string.saving) { - // complete the parent job and wait for all children + // complete the parent job and wait all "saveConfig" jobs to finish supervisorJob.complete() supervisorJob.join() scope.cancel() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt index 3283d0853..6ce291c4c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/ListFragment.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings @@ -19,15 +19,18 @@ import org.fcitx.fcitx5.android.ui.common.DynamicListUi import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor import org.fcitx.fcitx5.android.utils.config.ConfigType -import org.fcitx.fcitx5.android.utils.parcelable +import org.fcitx.fcitx5.android.utils.lazyRoute class ListFragment : Fragment() { - private val descriptor: ConfigDescriptor<*, out List<*>> by lazy { - requireArguments().parcelable(ARG_DESC)!! + val args by lazyRoute() + + private val descriptor: ConfigDescriptor<*, *> by lazy { + args.desc } + private val cfg: RawConfig by lazy { - requireArguments().parcelable(ARG_CFG)!! + args.cfg } private val viewModel: MainViewModel by activityViewModels() @@ -50,7 +53,7 @@ class ListFragment : Fragment() { ) } is ConfigDescriptor.ConfigList -> { - val ty = descriptor.type as ConfigType.TyList + val ty = descriptor.ty as ConfigType.TyList when (ty.subtype) { // does a list of booleans make sense? ConfigType.TyBool -> { @@ -158,8 +161,6 @@ class ListFragment : Fragment() { } companion object { - const val ARG_DESC = "desc" - const val ARG_CFG = "cfg" val supportedSubtypes = listOf( ConfigType.TyEnum, ConfigType.TyString, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt index 357539273..7778e316d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PinyinCustomPhraseFragment.kt @@ -111,6 +111,7 @@ class PinyinCustomPhraseFragment : Fragment(), OnItemChangedListener { + private val args by lazyRoute() + private val viewModel: MainViewModel by activityViewModels() private lateinit var launcher: ActivityResultLauncher @@ -105,8 +108,8 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener(INTENT_DATA_URI) - ?.let { importFromUri(it) } + @SuppressLint("UseKtx") + args.uri?.let { importFromUri(Uri.parse(it)) } super.onViewCreated(view, savedInstanceState) } @@ -117,7 +120,7 @@ class PinyinDictionaryFragment : Fragment(), OnItemChangedListener - general(context, fragmentManager, cfg, screen, d, store, save) - } - } - + // TODO: needs some error handling + val topLevelDesc = ConfigDescriptor.parseTopLevel(desc).getOrElse { throw it } + screen.title = topLevelDesc.name + topLevelDesc.values.forEach { + general(context, fragmentManager, cfg.findByName(it.name), screen, it, store, save) + } return screen } private fun general( context: Context, fragmentManager: FragmentManager, - cfg: RawConfig, + cfg: RawConfig?, screen: PreferenceScreen, descriptor: ConfigDescriptor<*, *>, store: PreferenceDataStore, @@ -86,8 +78,9 @@ object PreferenceScreenFactory { ) { // Hide key related configs - if (hideKeyConfig && ConfigType.pretty(descriptor.type).contains("Key")) + if (hideKeyConfig && ConfigType.pretty(descriptor.ty).contains("Key")) { return + } if (descriptor is ConfigCustom) { custom(context, fragmentManager, cfg, screen, descriptor, save) @@ -96,106 +89,73 @@ object PreferenceScreenFactory { fun stubPreference() = Preference(context).apply { summary = - "${context.getString(R.string.unimplemented_type)} '${ConfigType.pretty(descriptor.type)}'" + "${context.getString(R.string.unimplemented_type)} '${ConfigType.pretty(descriptor.ty)}'" + } + + fun navigate(route: T): Boolean { + return try { + fragmentManager.primaryNavigationFragment!!.navigateWithAnim(route) + true + } catch (e: Exception) { + Timber.w("Unable to navigate(route=$route): $e") + false + } } fun pinyinDictionary() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_pinyinDictionaryFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_pinyinDictionaryFragment - else -> throw IllegalStateException("Can not navigate to pinyin dictionary from current fragment") - } - currentFragment.findNavController().navigate(action) - true + navigate(SettingsRoute.PinyinDict("")) } } fun punctuationEditor(title: String, lang: String?) = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_punctuationEditorFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_punctuationEditorFragment - else -> throw IllegalStateException("Can not navigate to punctuation editor from current fragment") - } - currentFragment.findNavController().navigate( - action, - bundleOf( - PunctuationEditorFragment.TITLE to title, - PunctuationEditorFragment.LANG to lang - ) - ) - true + navigate(SettingsRoute.Punctuation(title, lang)) } } fun quickPhraseEditor() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_quickPhraseListFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_quickPhraseListFragment - else -> throw IllegalStateException("Can not navigate to quick phrase editor from current fragment") - } - currentFragment.findNavController().navigate(action) - true + navigate(SettingsRoute.QuickPhraseList) } } fun tableInputMethod() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - currentFragment.findNavController() - .navigate(R.id.action_addonConfigFragment_to_tableInputMethodFragment) - true + navigate(SettingsRoute.TableInputMethods) } } fun pinyinCustomPhrase() = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_pinyinCustomPhraseFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_pinyinCustomPhraseFragment - else -> throw IllegalStateException("Can not navigate to custom phrase editor from current fragment") - } - currentFragment.findNavController().navigate(action) - true + navigate(SettingsRoute.PinyinCustomPhrase) } } - fun rimeUserDataDir(title: String): Preference = object : Preference(context) { - init { - setOnPreferenceClickListener { - AlertDialog.Builder(context) - .setTitle(title) - .setMessage(R.string.open_rime_user_data_dir) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok) { _, _ -> - try { - context.startActivity(buildDocumentsProviderIntent()) - } catch (e: Exception) { - context.toast(e) - } - } - .show() - true - } - } - - // make it a hidden option, because of compatibility issues - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - holder.itemView.setOnLongClickListener { + fun rimeUserDataDir(title: String): Preference = LongClickPreference(context).apply { + setOnPreferenceClickListener { + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(R.string.open_rime_user_data_dir) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> try { - context.startActivity(buildPrimaryStorageIntent("data/rime")) + context.startActivity(buildDocumentsProviderIntent()) } catch (e: Exception) { context.toast(e) } - true + } + .show() + true + } + + // make it a hidden option, because of compatibility issues + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setOnPreferenceLongClickListener { + try { + context.startActivity(buildPrimaryStorageIntent("data/rime")) + } catch (e: Exception) { + context.toast(e) } } } @@ -203,25 +163,12 @@ object PreferenceScreenFactory { fun listPreference(subtype: ConfigType<*>): Preference = object : Preference(context) { override fun onClick() { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is GlobalConfigFragment -> R.id.action_globalConfigFragment_to_listFragment - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_listFragment - is AddonConfigFragment -> R.id.action_addonConfigFragment_to_listFragment - else -> throw IllegalStateException("Can not navigate to listFragment from current fragment") - } - currentFragment.findNavController().navigate( - action, - bundleOf( - ListFragment.ARG_CFG to cfg[descriptor.name], - ListFragment.ARG_DESC to descriptor, - ) - ) + navigate(SettingsRoute.ListConfig(cfg ?: RawConfig(), descriptor)) fragmentManager.setFragmentResultListener( descriptor.name, - currentFragment + fragmentManager.primaryNavigationFragment!! ) { _, v -> - cfg[descriptor.name].subItems = v.parcelableArray(descriptor.name) + cfg?.subItems = v.parcelableArray(descriptor.name) if (callChangeListener(null)) { notifyChanged() } @@ -230,30 +177,19 @@ object PreferenceScreenFactory { }.apply { if (subtype == ConfigType.TyKey) { summaryProvider = SummaryProvider { - val str = cfg[descriptor.name].subItems?.joinToString("\n") { + val keys = cfg?.subItems?.joinToString("\n") { Key.parse(it.value).localizedString - } ?: "" - str.ifEmpty { context.getString(R.string.none) } + } + if (keys.isNullOrEmpty()) context.getString(R.string.none) else keys } } } fun addonConfigPreference(addon: String) = Preference(context).apply { setOnPreferenceClickListener { - val currentFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment)!! - val action = when (currentFragment) { - is AddonConfigFragment -> R.id.action_addonConfigFragment_self - is InputMethodConfigFragment -> R.id.action_imConfigFragment_to_addonConfigFragment - else -> throw IllegalStateException("Can not navigate to addonConfigFragment from current fragment") - } - currentFragment.findNavController().navigate( - action, - bundleOf( - AddonConfigFragment.ARG_UNIQUE_NAME to addon, - AddonConfigFragment.ARG_NAME to (descriptor.description ?: descriptor.name) - ) + navigate( + SettingsRoute.AddonConfig(descriptor.description ?: descriptor.name, addon) ) - true } } @@ -309,8 +245,8 @@ object PreferenceScreenFactory { summaryProvider = FcitxKeyPreference.SimpleSummaryProvider descriptor.defaultValue?.let { setDefaultValue(it) } } - is ConfigList -> if (descriptor.type.subtype in ListFragment.supportedSubtypes) - listPreference(descriptor.type.subtype) + is ConfigList -> if (descriptor.ty.subtype in ListFragment.supportedSubtypes) + listPreference(descriptor.ty.subtype) else stubPreference() is ConfigString -> EditTextPreference(context).apply { @@ -343,12 +279,12 @@ object PreferenceScreenFactory { private fun custom( context: Context, fragmentManager: FragmentManager, - cfg: RawConfig, + cfg: RawConfig?, screen: PreferenceScreen, descriptor: ConfigCustom, save: () -> Unit ) { - val subStore = FcitxRawConfigStore(cfg[descriptor.name]) + val subStore = FcitxRawConfigStore(cfg ?: RawConfig()) val subPref = PreferenceCategory(context).apply { key = descriptor.name title = descriptor.description ?: descriptor.name @@ -356,16 +292,8 @@ object PreferenceScreenFactory { isIconSpaceReserved = false } screen.addPreference(subPref) - descriptor.customTypeDef!!.values.forEach { - general( - context, - fragmentManager, - cfg[descriptor.name], - screen, - it, - subStore, - save - ) + descriptor.customTypeDef?.values?.forEach { + general(context, fragmentManager, cfg?.findByName(it.name), screen, it, subStore, save) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt index 470c5d5d0..3b0149d3c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/PunctuationEditorFragment.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings @@ -15,6 +15,7 @@ import org.fcitx.fcitx5.android.data.punctuation.PunctuationMapEntry import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.utils.NaiveDustman +import org.fcitx.fcitx5.android.utils.lazyRoute import org.fcitx.fcitx5.android.utils.materialTextInput import org.fcitx.fcitx5.android.utils.onPositiveButtonClick import org.fcitx.fcitx5.android.utils.str @@ -26,6 +27,8 @@ import splitties.views.setPaddingDp class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener { + private val args by lazyRoute() + private lateinit var lang: String private lateinit var keyDesc: String private lateinit var mappingDesc: String @@ -60,7 +63,7 @@ class PunctuationEditorFragment : ProgressFragment(), OnItemChangedListener { + private val args by lazyRoute() - private lateinit var ui: BaseDynamicListUi + private val quickPhrase: QuickPhrase by lazy { + args.param.quickPhrase + } - private lateinit var quickPhrase: QuickPhrase + private lateinit var ui: BaseDynamicListUi private val dustman = NaiveDustman() override suspend fun initialize(): View { - quickPhrase = requireArguments().serializable(ARG)!! val initialEntries = withContext(Dispatchers.IO) { quickPhrase.loadData() } @@ -110,6 +113,7 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener { initSettingsButton = { entry -> visibility = if (!entry.isEnabled) View.GONE else View.VISIBLE fun edit() { - findNavController().navigate( - R.id.action_quickPhraseListFragment_to_quickPhraseEditFragment, - bundleOf(QuickPhraseEditFragment.ARG to entry) - ) + navigateWithAnim(SettingsRoute.QuickPhraseEdit(entry)) parentFragmentManager.setFragmentResultListener( QuickPhraseEditFragment.RESULT, this@QuickPhraseListFragment ) { _, _ -> + if (entry is BuiltinQuickPhrase) + entry.evaluateOverride() ui.updateItem(ui.indexItem(entry), entry) // editor changed file content dustman.forceDirty() @@ -102,16 +101,12 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { icon = R.drawable.ic_baseline_expand_more_24 setOnClickListener { PopupMenu(requireContext(), this).apply { - menu.add(getString(R.string.edit)).setOnMenuItemClickListener { - edit() - true - } - menu.add(getString(R.string.reset)).setOnMenuItemClickListener { + menu.item(R.string.edit) { edit() } + menu.item(R.string.reset) { entry.deleteOverride() ui.updateItem(ui.indexItem(entry), entry) // not sure if the content changes dustman.forceDirty() - true } show() } @@ -214,7 +209,7 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { getText(R.string.quickphrase_editor), NotificationManager.IMPORTANCE_HIGH ).apply { description = CHANNEL_ID } - notificationManager.createNotificationChannel(channel) + requireContext().notificationManager.createNotificationChannel(channel) } } @@ -269,7 +264,7 @@ class QuickPhraseListFragment : Fragment(), OnItemChangedListener { resetDustman() // save the reference to NotificationManager, in case we need to cancel notification // after Fragment detached - val nm = notificationManager + val nm = requireContext().notificationManager lifecycleScope.launch(NonCancellable + Dispatchers.IO) { if (busy.compareAndSet(false, true)) { val id = RELOAD_ID++ diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/SettingsRoute.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/SettingsRoute.kt new file mode 100644 index 000000000..ffc4db630 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/SettingsRoute.kt @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main.settings + +import android.net.Uri +import android.os.Parcelable +import androidx.navigation.NavController +import androidx.navigation.NavType +import androidx.navigation.createGraph +import androidx.navigation.fragment.fragment +import androidx.savedstate.SavedState +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.RawConfig +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhrase +import org.fcitx.fcitx5.android.ui.main.AboutFragment +import org.fcitx.fcitx5.android.ui.main.DeveloperFragment +import org.fcitx.fcitx5.android.ui.main.LicensesFragment +import org.fcitx.fcitx5.android.ui.main.MainFragment +import org.fcitx.fcitx5.android.ui.main.PluginFragment +import org.fcitx.fcitx5.android.ui.main.settings.addon.AddonConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.addon.AddonListFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.AdvancedSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.CandidatesSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.ClipboardSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.KeyboardSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.behavior.SymbolSettingsFragment +import org.fcitx.fcitx5.android.ui.main.settings.global.GlobalConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.im.InputMethodConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.im.InputMethodListFragment +import org.fcitx.fcitx5.android.ui.main.settings.theme.ThemeFragment +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor +import org.fcitx.fcitx5.android.utils.parcelable +import kotlin.reflect.typeOf + +@Parcelize +sealed class SettingsRoute : Parcelable { + + /* ========== Index ========== */ + + @Serializable + data object Index : SettingsRoute() + + /* ========== Fcitx ========== */ + + @Serializable + data object GlobalConfig : SettingsRoute() + + @Serializable + data object InputMethodList : SettingsRoute() + + @Serializable + data class InputMethodConfig(val name: String, val uniqueName: String) : SettingsRoute() + + @Serializable + data object AddonList : SettingsRoute() + + @Serializable + data class AddonConfig(val name: String, val uniqueName: String) : SettingsRoute() + + /* ========== Android ========== */ + + @Serializable + data object Theme : SettingsRoute() + + @Serializable + data object VirtualKeyboard : SettingsRoute() + + @Serializable + data object CandidatesWindow : SettingsRoute() + + @Serializable + data object Clipboard : SettingsRoute() + + @Serializable + data object Symbol : SettingsRoute() + + @Serializable + data object Plugin : SettingsRoute() + + @Serializable + data object Advanced : SettingsRoute() + + @Serializable + data object Developer : SettingsRoute() + + @Serializable + data object License : SettingsRoute() + + @Serializable + data object About : SettingsRoute() + + /* ========== External ========== */ + + @Serializable + data class ListConfig(val params: Params) : SettingsRoute() { + @Parcelize + @Serializable + data class Params(val cfg: RawConfig, val desc: ConfigDescriptor<*, *>) : Parcelable { + companion object { + // https://developer.android.com/guide/navigation/design/kotlin-dsl#custom-types + val NavType = object : NavType(isNullableAllowed = false) { + override fun put(bundle: SavedState, key: String, value: Params) { + bundle.putParcelable(key, value) + } + + override fun get(bundle: SavedState, key: String): Params? { + return bundle.parcelable(key) + } + + override fun serializeAsValue(value: Params): String { + // Serialized values must always be Uri encoded + return Uri.encode(Json.encodeToString(value)) + } + + override fun parseValue(value: String): Params { + // Navigation decodes the string before passing it to parseValue() + return Json.decodeFromString(value) + } + } + } + } + + constructor(cfg: RawConfig, desc: ConfigDescriptor<*, *>) : this(Params(cfg, desc)) + + val desc: ConfigDescriptor<*, *> + get() = params.desc + val cfg: RawConfig + get() = params.cfg + } + + @Serializable + data class PinyinDict(val uri: String? = null) : SettingsRoute() { + constructor(uri: Uri) : this(uri.toString()) + } + + @Serializable + data class Punctuation(val title: String, val lang: String? = null) : SettingsRoute() + + @Serializable + data object QuickPhraseList : SettingsRoute() + + @Serializable + data class QuickPhraseEdit(val param: Param) : SettingsRoute() { + constructor(quickPhrase: QuickPhrase) : this(Param(quickPhrase)) + + @Serializable + @Parcelize + data class Param( + val quickPhrase: QuickPhrase + ) : Parcelable { + companion object { + val NavType = object : NavType(isNullableAllowed = false) { + override fun put(bundle: SavedState, key: String, value: Param) { + bundle.putParcelable(key, value) + } + + override fun get(bundle: SavedState, key: String): Param? { + return bundle.parcelable(key) + } + + override fun serializeAsValue(value: Param): String { + return Uri.encode(Json.encodeToString(value)) + } + + override fun parseValue(value: String): Param { + return Json.decodeFromString(value) + } + } + } + } + } + + @Serializable + data object TableInputMethods : SettingsRoute() + + @Serializable + data object PinyinCustomPhrase : SettingsRoute() + + companion object { + fun createGraph(controller: NavController) = controller.createGraph(Index) { + val ctx = controller.context + + /* ========== Index ========== */ + + fragment { + label = ctx.getString(R.string.app_name) + } + + /* ========== Fcitx ========== */ + + fragment() + fragment { + label = ctx.getString(R.string.input_methods) + } + fragment() + fragment { + label = ctx.getString(R.string.addons) + } + fragment() + + /* ========== Android ========== */ + + fragment { + label = ctx.getString(R.string.theme) + } + fragment { + label = ctx.getString(R.string.virtual_keyboard) + } + fragment { + label = ctx.getString(R.string.candidates_window) + } + fragment { + label = ctx.getString(R.string.clipboard) + } + fragment { + label = ctx.getString(R.string.emoji_and_symbols) + } + fragment { + label = ctx.getString(R.string.plugins) + } + fragment { + label = ctx.getString(R.string.advanced) + } + fragment { + label = ctx.getString(R.string.developer) + } + fragment { + label = ctx.getString(R.string.license) + } + fragment { + label = ctx.getString(R.string.about) + } + + /* ========== External ========== */ + + fragment( + typeMap = mapOf(typeOf() to ListConfig.Params.NavType) + ) + fragment { + label = ctx.getString(R.string.pinyin_dict) + } + fragment() + fragment { + label = ctx.getString(R.string.quickphrase_editor) + } + fragment( + typeMap = mapOf(typeOf() to QuickPhraseEdit.Param.NavType) + ) + fragment { + label = ctx.getString(R.string.table_im) + } + fragment() + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt index d9c47c729..505952ffb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/TableInputMethodFragment.kt @@ -135,7 +135,7 @@ class TableInputMethodFragment : Fragment(), OnItemChangedListener() + + override fun getPageTitle(): String = args.name override suspend fun obtainConfig(fcitx: FcitxAPI): RawConfig { - val addon = requireStringArg(ARG_UNIQUE_NAME) + val addon = args.uniqueName val raw = fcitx.getAddonConfig(addon) if (addon == "table") { - val desc = raw["desc"]["TableGlobalConfig"] - val androidTable = RawConfig( - "AndroidTable", subItems = arrayOf( - RawConfig("Type", "External"), - RawConfig("Description", getString(R.string.manage_table_im)) + // append android specific "Manage Table Input Methods" to config of table addon + raw.findByName("desc")?.findByName("TableGlobalConfig")?.let { + it.subItems = (it.subItems ?: emptyArray()) + RawConfig( + "AndroidTable", subItems = arrayOf( + RawConfig("Type", "External"), + RawConfig("Description", getString(R.string.manage_table_im)) + ) ) - ) - desc.subItems = (desc.subItems ?: arrayOf()) + androidTable + } } return raw } override suspend fun saveConfig(fcitx: FcitxAPI, newConfig: RawConfig) { - fcitx.setAddonConfig(requireStringArg(ARG_UNIQUE_NAME), newConfig) - } - - companion object { - const val ARG_UNIQUE_NAME = "addon" - const val ARG_NAME = "addon_" + fcitx.setAddonConfig(args.uniqueName, newConfig) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt index f17ddc539..88b6bc91a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/addon/AddonListFragment.kt @@ -1,14 +1,12 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.addon import android.view.View import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf -import androidx.navigation.findNavController import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.AddonInfo import org.fcitx.fcitx5.android.core.FcitxAPI @@ -17,6 +15,8 @@ import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.CheckBoxListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.settings.ProgressFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.navigateWithAnim class AddonListFragment : ProgressFragment(), OnItemChangedListener { @@ -120,12 +120,8 @@ class AddonListFragment : ProgressFragment(), OnItemChangedListener { entry.uniqueName != "clipboard" ) View.VISIBLE else View.INVISIBLE setOnClickListener { - it.findNavController().navigate( - R.id.action_addonListFragment_to_addonConfigFragment, - bundleOf( - AddonConfigFragment.ARG_UNIQUE_NAME to entry.uniqueName, - AddonConfigFragment.ARG_NAME to entry.displayName - ) + navigateWithAnim( + SettingsRoute.AddonConfig(entry.displayName, entry.uniqueName) ) } }, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt index 2dccd4cd7..acedfa2e7 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/AdvancedSettingsFragment.kt @@ -1,9 +1,10 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.behavior +import android.os.Build import android.os.Bundle import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -25,6 +26,8 @@ import org.fcitx.fcitx5.android.ui.common.withLoadingDialog import org.fcitx.fcitx5.android.ui.main.MainViewModel import org.fcitx.fcitx5.android.utils.AppUtil import org.fcitx.fcitx5.android.utils.addPreference +import org.fcitx.fcitx5.android.utils.buildDocumentsProviderIntent +import org.fcitx.fcitx5.android.utils.buildPrimaryStorageIntent import org.fcitx.fcitx5.android.utils.formatDateTime import org.fcitx.fcitx5.android.utils.importErrorDialog import org.fcitx.fcitx5.android.utils.iso8601UTCDateTime @@ -66,12 +69,8 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( } withContext(Dispatchers.Main) { AppUtil.showRestartNotification(ctx) - ctx.toast( - getString( - R.string.user_data_imported, - formatDateTime(metadata.exportTime) - ) - ) + val exportTime = formatDateTime(metadata.exportTime) + ctx.toast(getString(R.string.user_data_imported, exportTime)) } } catch (e: Exception) { // re-start fcitx in case importing failed @@ -102,8 +101,25 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( } override fun onPreferenceUiCreated(screen: PreferenceScreen) { + val ctx = requireContext() + screen.addPreference( + R.string.browse_user_data_dir, + onClick = { + try { + ctx.startActivity(buildDocumentsProviderIntent()) + } catch (e: Exception) { + ctx.toast(e) + } + }, + onLongClick = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ({ + try { + ctx.startActivity(buildPrimaryStorageIntent()) + } catch (e: Exception) { + ctx.toast(e) + } + }) else null + ) screen.addPreference(R.string.export_user_data) { - val ctx = requireContext() lifecycleScope.launch { lifecycleScope.withLoadingDialog(ctx) { viewModel.fcitx.runOnReady { @@ -115,7 +131,6 @@ class AdvancedSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance( } } screen.addPreference(R.string.import_user_data) { - val ctx = requireContext() AlertDialog.Builder(ctx) .setIconAttribute(android.R.attr.alertDialogIcon) .setTitle(R.string.import_user_data) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/CandidatesSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/CandidatesSettingsFragment.kt new file mode 100644 index 000000000..c3ddc0c79 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/CandidatesSettingsFragment.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main.settings.behavior + +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment + +class CandidatesSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance().candidates) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/SymbolSettingsFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/SymbolSettingsFragment.kt new file mode 100644 index 000000000..3c7a0ff40 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/behavior/SymbolSettingsFragment.kt @@ -0,0 +1,11 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.ui.main.settings.behavior + +import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceFragment + +class SymbolSettingsFragment : ManagedPreferenceFragment(AppPrefs.getInstance().symbols) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt index 0e8b7e571..d211f3e5d 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodConfigFragment.kt @@ -1,26 +1,25 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.im import org.fcitx.fcitx5.android.core.FcitxAPI import org.fcitx.fcitx5.android.core.RawConfig import org.fcitx.fcitx5.android.ui.main.settings.FcitxPreferenceFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.lazyRoute class InputMethodConfigFragment : FcitxPreferenceFragment() { - override fun getPageTitle(): String = requireStringArg(ARG_NAME) + val args by lazyRoute() + + override fun getPageTitle(): String = args.name override suspend fun obtainConfig(fcitx: FcitxAPI): RawConfig { - return fcitx.getImConfig(requireStringArg(ARG_UNIQUE_NAME)) + return fcitx.getImConfig(args.uniqueName) } override suspend fun saveConfig(fcitx: FcitxAPI, newConfig: RawConfig) { - fcitx.setImConfig(requireStringArg(ARG_UNIQUE_NAME), newConfig) - } - - companion object { - const val ARG_UNIQUE_NAME = "im" - const val ARG_NAME = "im_" + fcitx.setImConfig(args.uniqueName, newConfig) } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt index 481b822c9..922510962 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/im/InputMethodListFragment.kt @@ -1,21 +1,20 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.ui.main.settings.im import android.os.Build import android.view.View -import androidx.core.os.bundleOf -import androidx.navigation.findNavController -import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.InputMethodEntry -import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.core.SubtypeManager +import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.DynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.settings.ProgressFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute +import org.fcitx.fcitx5.android.utils.navigateWithAnim class InputMethodListFragment : ProgressFragment(), OnItemChangedListener { @@ -43,12 +42,8 @@ class InputMethodListFragment : ProgressFragment(), OnItemChangedListener setOnClickListener { - it.findNavController().navigate( - R.id.action_imListFragment_to_imConfigFragment, - bundleOf( - InputMethodConfigFragment.ARG_UNIQUE_NAME to entry.uniqueName, - InputMethodConfigFragment.ARG_NAME to entry.displayName - ) + navigateWithAnim( + SettingsRoute.InputMethodConfig(entry.displayName, entry.uniqueName), ) } }, diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt index 56fdfacf9..c056b8547 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/CustomThemeActivity.kt @@ -5,15 +5,14 @@ package org.fcitx.fcitx5.android.ui.main.settings.theme import android.annotation.SuppressLint -import android.app.Activity import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable +import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.view.Menu @@ -29,15 +28,10 @@ import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar -import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope -import com.canhub.cropper.CropImageContract -import com.canhub.cropper.CropImageContractOptions -import com.canhub.cropper.CropImageOptions -import com.canhub.cropper.CropImageView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @@ -46,11 +40,14 @@ import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeFilesManager import org.fcitx.fcitx5.android.data.theme.ThemePreset import org.fcitx.fcitx5.android.ui.common.withLoadingDialog +import org.fcitx.fcitx5.android.ui.main.CropImageActivity.CropContract +import org.fcitx.fcitx5.android.ui.main.CropImageActivity.CropOption +import org.fcitx.fcitx5.android.ui.main.CropImageActivity.CropResult import org.fcitx.fcitx5.android.utils.DarkenColorFilter +import org.fcitx.fcitx5.android.utils.item import org.fcitx.fcitx5.android.utils.parcelable import splitties.dimensions.dp import splitties.resources.color -import splitties.resources.drawable import splitties.resources.resolveThemeAttribute import splitties.resources.styledColor import splitties.resources.styledDrawable @@ -218,11 +215,11 @@ class CustomThemeActivity : AppCompatActivity() { private lateinit var theme: Theme.Custom private class BackgroundStates { - lateinit var launcher: ActivityResultLauncher + lateinit var launcher: ActivityResultLauncher var srcImageExtension: String? = null var srcImageBuffer: ByteArray? = null - var tempImageFile: File? = null var cropRect: Rect? = null + var cropRotation: Int = 0 lateinit var croppedBitmap: Bitmap lateinit var filteredDrawable: BitmapDrawable lateinit var srcImageFile: File @@ -242,21 +239,15 @@ class CustomThemeActivity : AppCompatActivity() { background: Theme.Custom.CustomBackground, darkKeys: Boolean ) { - theme = if (darkKeys) - ThemePreset.TransparentLight.deriveCustomBackground( - theme.name, - background.croppedFilePath, - background.srcFilePath, - brightnessSeekBar.progress, - background.cropRect - ) else - ThemePreset.TransparentDark.deriveCustomBackground( - theme.name, - background.croppedFilePath, - background.srcFilePath, - brightnessSeekBar.progress, - background.cropRect - ) + val template = if (darkKeys) ThemePreset.TransparentLight else ThemePreset.TransparentDark + theme = template.deriveCustomBackground( + theme.name, + background.croppedFilePath, + background.srcFilePath, + brightnessSeekBar.progress, + background.cropRect, + background.cropRotation + ) previewUi.setTheme(theme, filteredDrawable) } @@ -269,6 +260,7 @@ class CustomThemeActivity : AppCompatActivity() { croppedImageFile = File(it.croppedFilePath) srcImageFile = File(it.srcFilePath) cropRect = it.cropRect + cropRotation = it.cropRotation croppedBitmap = BitmapFactory.decodeFile(it.croppedFilePath) filteredDrawable = BitmapDrawable(resources, croppedBitmap) } @@ -312,29 +304,27 @@ class CustomThemeActivity : AppCompatActivity() { whenHasBackground { background -> brightnessSeekBar.progress = background.brightness variantSwitch.isChecked = !theme.isDark - launcher = registerForActivityResult(CropImageContract()) { - if (!it.isSuccessful) { - if (newCreated) - cancel() - else - return@registerForActivityResult - } else { - if (newCreated) { - srcImageExtension = MimeTypeMap.getSingleton() - .getExtensionFromMimeType(contentResolver.getType(it.originalUri!!)) - srcImageBuffer = - contentResolver.openInputStream(it.originalUri!!)!! - .use { x -> x.readBytes() } + launcher = registerForActivityResult(CropContract()) { + when (it) { + CropResult.Fail -> { + if (newCreated) { + cancel() + } + } + is CropResult.Success -> { + if (newCreated) { + srcImageExtension = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(contentResolver.getType(it.srcUri)) + srcImageBuffer = + contentResolver.openInputStream(it.srcUri)!! + .use { x -> x.readBytes() } + } + cropRect = it.rect + cropRotation = it.rotation + croppedBitmap = it.bitmap + filteredDrawable = BitmapDrawable(resources, croppedBitmap) + updateState() } - cropRect = it.cropRect!! - croppedBitmap = Bitmap.createScaledBitmap( - it.getBitmap(this@CustomThemeActivity)!!, - previewUi.intrinsicWidth, - previewUi.intrinsicHeight, - true - ) - filteredDrawable = BitmapDrawable(resources, croppedBitmap) - updateState() } } cropLabel.setOnClickListener { @@ -377,37 +367,19 @@ class CustomThemeActivity : AppCompatActivity() { } private fun BackgroundStates.launchCrop(w: Int, h: Int) { - if (tempImageFile == null || tempImageFile?.exists() != true) { - tempImageFile = File.createTempFile("cropped", ".png", cacheDir) - } - launcher.launch( - CropImageContractOptions( - uri = srcImageFile.takeIf { it.exists() }?.toUri(), - CropImageOptions( - initialCropWindowRectangle = cropRect, - guidelines = CropImageView.Guidelines.ON_TOUCH, - borderLineColor = Color.WHITE, - borderLineThickness = dp(1f), - borderCornerColor = Color.WHITE, - borderCornerOffset = 0f, - imageSourceIncludeGallery = true, - imageSourceIncludeCamera = false, - aspectRatioX = w, - aspectRatioY = h, - fixAspectRatio = true, - customOutputUri = tempImageFile!!.toUri(), - outputCompressFormat = Bitmap.CompressFormat.PNG, - cropMenuCropButtonIcon = R.drawable.ic_baseline_done_24, - showProgressBar = true, - progressBarColor = styledColor(android.R.attr.colorAccent), - activityMenuIconColor = styledColor(android.R.attr.colorControlNormal), - activityMenuTextColor = styledColor(android.R.attr.colorForeground), - activityBackgroundColor = styledColor(android.R.attr.colorBackground), - toolbarColor = styledColor(android.R.attr.colorPrimary), - toolbarBackButtonColor = styledColor(android.R.attr.colorControlNormal) + if (newCreated) { + launcher.launch(CropOption.New(w, h)) + } else { + launcher.launch( + CropOption.Edit( + width = w, + height = h, + Uri.fromFile(srcImageFile), + initialRect = cropRect, + initialRotation = cropRotation ) ) - ) + } } @SuppressLint("SetTextI18n") @@ -419,11 +391,8 @@ class CustomThemeActivity : AppCompatActivity() { } private fun cancel() { - whenHasBackground { - tempImageFile?.delete() - } setResult( - Activity.RESULT_CANCELED, + RESULT_CANCELED, Intent().apply { putExtra(RESULT, null as BackgroundResult?) } ) finish() @@ -433,7 +402,6 @@ class CustomThemeActivity : AppCompatActivity() { lifecycleScope.withLoadingDialog(this) { whenHasBackground { withContext(Dispatchers.IO) { - tempImageFile?.delete() croppedImageFile.delete() croppedImageFile.outputStream().use { croppedBitmap.compress(Bitmap.CompressFormat.PNG, 100, it) @@ -452,14 +420,15 @@ class CustomThemeActivity : AppCompatActivity() { } } setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().apply { var newTheme = theme whenHasBackground { newTheme = theme.copy( backgroundImage = it.copy( brightness = brightnessSeekBar.progress, - cropRect = cropRect + cropRect = cropRect, + cropRotation = cropRotation ) ) } @@ -477,7 +446,7 @@ class CustomThemeActivity : AppCompatActivity() { private fun delete() { setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().apply { putExtra(RESULT, BackgroundResult.Deleted(theme.name)) } @@ -498,26 +467,14 @@ class CustomThemeActivity : AppCompatActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { if (!newCreated) { - menu.add(R.string.delete).apply { - icon = drawable(R.drawable.ic_baseline_delete_24)!!.apply { - setTint(color(R.color.red_400)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - promptDelete() - true - } + val iconTint = color(R.color.red_400) + menu.item(R.string.save, R.drawable.ic_baseline_delete_24, iconTint, true) { + promptDelete() } } - menu.add(R.string.save).apply { - icon = drawable(R.drawable.ic_baseline_done_24)!!.apply { - setTint(styledColor(android.R.attr.colorControlNormal)) - } - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - setOnMenuItemClickListener { - done() - true - } + val iconTint = styledColor(android.R.attr.colorControlNormal) + menu.item(R.string.save, R.drawable.ic_baseline_check_24, iconTint, true) { + done() } return true } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt index 518f448ce..b8c048caf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/KeyboardPreviewUi.kt @@ -1,10 +1,10 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ + package org.fcitx.fcitx5.android.ui.main.settings.theme -import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.graphics.Color @@ -17,10 +17,12 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import org.fcitx.fcitx5.android.data.prefs.AppPrefs +import org.fcitx.fcitx5.android.data.prefs.ManagedPreference import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.data.theme.ThemePrefs.NavbarBackground import org.fcitx.fcitx5.android.input.keyboard.TextKeyboard +import org.fcitx.fcitx5.android.utils.navbarFrameHeight import splitties.dimensions.dp import splitties.views.backgroundColor import splitties.views.dsl.constraintlayout.below @@ -71,9 +73,13 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { return ctx.dp(value) } - private val navbarBackground by ThemeManager.prefs.navbarBackground + private val navbarBackground = ThemeManager.prefs.navbarBackground private val keyBorder by ThemeManager.prefs.keyBorder + private val navbarBkgChangeListener = ManagedPreference.OnChangeListener { _, _ -> + recalculateSize() + } + private val bkg = imageView { scaleType = ImageView.ScaleType.CENTER_CROP } @@ -103,11 +109,17 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { super.onAttachedToWindow() recalculateSize() onSizeMeasured?.invoke(intrinsicWidth, intrinsicHeight) + navbarBackground.registerOnChangeListener(navbarBkgChangeListener) } override fun onConfigurationChanged(newConfig: Configuration?) { recalculateSize() } + + override fun onDetachedFromWindow() { + navbarBackground.unregisterOnChangeListener(navbarBkgChangeListener) + super.onDetachedFromWindow() + } } var onSizeMeasured: ((Int, Int) -> Unit)? = null @@ -124,16 +136,6 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { return w to (h * hPercent / 100) } - /** - * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3221 - * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3059 - */ - private fun navbarHeight() = ctx.resources.run { - @SuppressLint("DiscouragedApi") - val id = getIdentifier("navigation_bar_frame_height", "dimen", "android") - if (id > 0) getDimensionPixelSize(id) else 0 - } - init { val (w, h) = keyboardWindowAspectRatio() keyboardWidth = w @@ -155,15 +157,15 @@ class KeyboardPreviewUi(override val ctx: Context, val theme: Theme) : Ui { // extra bottom padding intrinsicHeight += keyboardBottomPaddingPx // windowInsets navbar padding - if (navbarBackground == NavbarBackground.Full) { + if (navbarBackground.getValue() == NavbarBackground.Full) { ViewCompat.getRootWindowInsets(root)?.also { // IME window has different navbar height when system navigation in "gesture navigation" mode // thus the inset from Activity root window is unreliable if (it.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom > 0 || // in case navigation hint was hidden ... - it.getInsets(WindowInsetsCompat.Type.systemGestures()).bottom > 0 + it.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()).bottom > 0 ) { - intrinsicHeight += navbarHeight() + intrinsicHeight += ctx.navbarFrameHeight() } } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt index dab226ba0..c8702b3eb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/theme/ThemeListAdapter.kt @@ -127,6 +127,9 @@ abstract class ThemeListAdapter : RecyclerView.Adapter= Build.VERSION_CODES.S && theme is Theme.Monet) View.VISIBLE else View.GONE + imageTintList = foregroundTint + } checkMark.imageTintList = foregroundTint } @@ -115,8 +132,8 @@ class ThemeThumbnailUi(override val ctx: Context) : Ui { checkMark.imageResource = when (state) { State.Normal -> 0 State.Selected -> R.drawable.ic_baseline_check_24 - State.LightMode -> R.drawable.ic_sharp_light_mode_24 - State.DarkMode -> R.drawable.ic_sharp_mode_night_24 + State.LightMode -> R.drawable.ic_baseline_light_mode_24 + State.DarkMode -> R.drawable.ic_baseline_dark_mode_24 } } } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt index 8f7e291b5..a425df01c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/AppUtil.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.utils @@ -10,15 +10,11 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import android.os.Bundle -import androidx.annotation.IdRes import androidx.core.app.NotificationCompat -import androidx.core.os.bundleOf -import androidx.navigation.NavDeepLinkBuilder import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.ui.main.ClipboardEditActivity import org.fcitx.fcitx5.android.ui.main.MainActivity -import org.fcitx.fcitx5.android.ui.main.settings.im.InputMethodConfigFragment +import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute import kotlin.system.exitProcess object AppUtil { @@ -29,39 +25,25 @@ object AppUtil { } } - private fun launchMainToDest(context: Context, @IdRes dest: Int, arguments: Bundle? = null) { - NavDeepLinkBuilder(context) - .setComponentName(MainActivity::class.java) - .setGraph(R.navigation.settings_nav) - .addDestination(dest, arguments) - .createTaskStackBuilder() - /** - * [androidx.core.app.TaskStackBuilder.getIntents] would add unwanted flags - * [Intent.FLAG_ACTIVITY_CLEAR_TASK] and [Intent.FLAG_ACTIVITY_TASK_ON_HOME] - * so we must launch the Intent by ourselves - */ - .editIntentAt(0)?.apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) - context.startActivity(this) - } + private fun launchMainToDest(context: Context, route: SettingsRoute) { + context.startActivity { + action = Intent.ACTION_RUN + putExtra(MainActivity.EXTRA_SETTINGS_ROUTE, route) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } } fun launchMainToKeyboard(context: Context) = - launchMainToDest(context, R.id.keyboardSettingsFragment) + launchMainToDest(context, SettingsRoute.VirtualKeyboard) fun launchMainToInputMethodList(context: Context) = - launchMainToDest(context, R.id.imListFragment) + launchMainToDest(context, SettingsRoute.InputMethodList) fun launchMainToThemeList(context: Context) = - launchMainToDest(context, R.id.themeFragment) + launchMainToDest(context, SettingsRoute.Theme) fun launchMainToInputMethodConfig(context: Context, uniqueName: String, displayName: String) = - launchMainToDest( - context, R.id.imConfigFragment, bundleOf( - InputMethodConfigFragment.ARG_NAME to displayName, - InputMethodConfigFragment.ARG_UNIQUE_NAME to uniqueName - ) - ) + launchMainToDest(context, SettingsRoute.InputMethodConfig(displayName, uniqueName)) fun launchClipboardEdit(context: Context, id: Int, lastEntry: Boolean = false) { context.startActivity { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Array.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Array.kt new file mode 100644 index 000000000..7a675e2a4 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Array.kt @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +fun Array.includes(element: T?): Boolean { + return indexOf(element) >= 0 +} + +fun IntArray.includes(element: Int): Boolean { + return indexOf(element) >= 0 +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt index bde19478a..a6d63087f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Bundle.kt @@ -5,40 +5,35 @@ package org.fcitx.fcitx5.android.utils +import android.os.Build import android.os.Bundle import android.os.Parcelable import java.io.Serializable inline fun Bundle.serializable(key: String): T? { - @Suppress("DEPRECATION") - return getSerializable(key) as? T -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getSerializable(key, T::class.java) -// } else { -// @Suppress("DEPRECATION") -// getSerializable(key) as? T -// } + // https://issuetracker.google.com/issues/240585930#comment6 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getSerializable(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getSerializable(key) as? T + } } inline fun Bundle.parcelable(key: String): T? { - // https://issuetracker.google.com/issues/240585930#comment6 - @Suppress("DEPRECATION") - return getParcelable(key) as? T -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelable(key, T::class.java) -// } else { -// @Suppress("DEPRECATION") -// getParcelable(key) as? T -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelable(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getParcelable(key) as? T + } } inline fun Bundle.parcelableArray(key: String): Array? { - @Suppress("DEPRECATION", "UNCHECKED_CAST") - return getParcelableArray(key) as? Array -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelableArray(key, T::class.java) -// } else { -// @Suppress("DEPRECATION", "UNCHECKED_CAST") -// getParcelableArray(key) as? Array -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelableArray(key, T::class.java) + } else { + @Suppress("DEPRECATION", "UNCHECKED_CAST") + getParcelableArray(key) as? Array + } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipData.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipData.kt new file mode 100644 index 000000000..2bc89546f --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ClipData.kt @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.ClipData +import android.os.Build + +fun ClipData.timestamp() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + description.timestamp +} else { + System.currentTimeMillis() +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt index b1a9099a4..83c25a2e9 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceInfo.kt @@ -6,6 +6,7 @@ package org.fcitx.fcitx5.android.utils import android.content.Context import android.content.res.Configuration +import android.graphics.Point import android.os.Build import org.fcitx.fcitx5.android.BuildConfig @@ -20,8 +21,16 @@ object DeviceInfo { appendLine("Model (product): ${Build.MODEL} (${Build.PRODUCT})") appendLine("Manufacturer: ${Build.MANUFACTURER}") appendLine("Tags: ${Build.TAGS}") + @Suppress("DEPRECATION") // we really want the physical display size + val size = Point().also { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.display!! + } else { + context.windowManager.defaultDisplay + }.getRealSize(it) + } + appendLine("Screen Size: ${size.x} x ${size.y}") val metrics = context.resources.displayMetrics - appendLine("Screen Size: ${metrics.widthPixels} x ${metrics.heightPixels}") appendLine("Screen Density: ${metrics.density}") appendLine( "Screen orientation: ${ @@ -33,10 +42,13 @@ object DeviceInfo { } }" ) + appendLine("--------- Package Info") + val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) + appendLine("Package Name: ${pkgInfo.packageName}") + appendLine("Version Code: ${pkgInfo.versionCodeCompat}") + appendLine("Version Name: ${pkgInfo.versionName}") appendLine("--------- Build Info") - appendLine("Package Name: ${BuildConfig.APPLICATION_ID}") - appendLine("Version Code: ${BuildConfig.VERSION_CODE}") - appendLine("Version Name: ${Const.versionName}") + appendLine("Build Type: ${BuildConfig.BUILD_TYPE}") appendLine("Build Time: ${iso8601UTCDateTime(BuildConfig.BUILD_TIME)}") appendLine("Build Git Hash: ${BuildConfig.BUILD_GIT_HASH}") } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt index 2175f8b47..3c894744f 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/DeviceUtil.kt @@ -38,4 +38,12 @@ object DeviceUtil { getSystemProperty("ro.vivo.os.version").isNotEmpty() } + val isHonorMagicOS: Boolean by lazy { + getSystemProperty("ro.magic.systemversion").isNotEmpty() + } + + val isFlyme: Boolean by lazy { + Build.DISPLAY.lowercase().contains("flyme") + } + } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt index 94f706895..f5368506b 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt @@ -7,6 +7,7 @@ package org.fcitx.fcitx5.android.utils import android.content.res.ColorStateList import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable @@ -14,19 +15,27 @@ import android.graphics.drawable.StateListDrawable import android.graphics.drawable.shapes.OvalShape import androidx.annotation.ColorInt -fun rippleDrawable(@ColorInt color: Int) = - RippleDrawable(ColorStateList.valueOf(color), null, ColorDrawable(Color.WHITE)) +fun rippleDrawable( + @ColorInt color: Int, + mask: Drawable = ColorDrawable(Color.WHITE) +): Drawable = RippleDrawable(ColorStateList.valueOf(color), null, mask) -fun borderlessRippleDrawable(@ColorInt color: Int, r: Int = RippleDrawable.RADIUS_AUTO) = - RippleDrawable(ColorStateList.valueOf(color), null, null).apply { - radius = r - } +fun borderlessRippleDrawable( + @ColorInt color: Int, + r: Int = RippleDrawable.RADIUS_AUTO +): Drawable = RippleDrawable(ColorStateList.valueOf(color), null, null).apply { + radius = r +} -fun pressHighlightDrawable(@ColorInt color: Int) = StateListDrawable().apply { +fun pressHighlightDrawable( + @ColorInt color: Int +): Drawable = StateListDrawable().apply { addState(intArrayOf(android.R.attr.state_pressed), ColorDrawable(color)) } -fun circlePressHighlightDrawable(@ColorInt color: Int) = StateListDrawable().apply { +fun circlePressHighlightDrawable( + @ColorInt color: Int +): Drawable = StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_pressed), ShapeDrawable(OvalShape()).apply { paint.color = color } @@ -37,7 +46,7 @@ fun borderDrawable( width: Int, @ColorInt stroke: Int, @ColorInt background: Int = Color.TRANSPARENT -) = GradientDrawable().apply { +): Drawable = GradientDrawable().apply { setStroke(width, stroke) setColor(background) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt index 8a55716f8..508e316eb 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/EventStateMachine.kt @@ -9,7 +9,7 @@ import timber.log.Timber class EventStateMachine, B : EventStateMachine.BooleanStateKey>( private val initialState: State, - private val externalBooleanStates: MutableMap = mutableMapOf() + private val externalBooleanStates: MutableMap ) { interface BooleanStateKey { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Fragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Fragment.kt new file mode 100644 index 000000000..f4d1a84ec --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Fragment.kt @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.toRoute + +fun Fragment.navigateWithAnim(route: T) { + findNavController().navigateWithAnim(route) +} + +inline fun Fragment.lazyRoute() = lazy { + findNavController().getBackStackEntry().toRoute() +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt index bc7082fb1..f2ec6ae3c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputConnection.kt @@ -5,6 +5,7 @@ package org.fcitx.fcitx5.android.utils +import android.os.Build import android.view.inputmethod.InputConnection fun InputConnection.withBatchEdit(block: InputConnection.() -> Unit) { @@ -12,3 +13,21 @@ fun InputConnection.withBatchEdit(block: InputConnection.() -> Unit) { block.invoke(this) endBatchEdit() } + +fun InputConnection.monitorCursorAnchor(enable: Boolean = true): Boolean { + if (!enable) { + requestCursorUpdates(0) + return false + } + var scheduled = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + scheduled = requestCursorUpdates( + InputConnection.CURSOR_UPDATE_MONITOR, + InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS or InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER + ) + } + if (!scheduled) { + scheduled = requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR) + } + return scheduled +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodService.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodService.kt new file mode 100644 index 000000000..d5d2a9eda --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodService.kt @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +@file:Suppress("DEPRECATION") + +package org.fcitx.fcitx5.android.utils + +import android.inputmethodservice.InputMethodService +import android.os.Build +import android.view.inputmethod.InputMethodManager + +fun InputMethodService.forceShowSelf() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + requestShowSelf(InputMethodManager.SHOW_FORCED) + } else { + inputMethodManager.showSoftInputFromInputMethod( + window.window!!.attributes.token, + InputMethodManager.SHOW_FORCED + ) + } +} + +fun InputMethodService.switchToNextIME() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + switchToNextInputMethod(false) + } else { + inputMethodManager.switchToNextInputMethod(window.window!!.attributes.token, false) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt index 747421c32..3f3f9d305 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/InputMethodUtil.kt @@ -34,7 +34,7 @@ object InputMethodUtil { it.packageName == BuildConfig.APPLICATION_ID && it.serviceName == serviceName } ?: false } else { - getSecureSettings(Settings.Secure.DEFAULT_INPUT_METHOD) == componentName + getSecureSettings(Settings.Secure.DEFAULT_INPUT_METHOD) == componentName } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt index 75f4609f7..d1b41167c 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Intent.kt @@ -15,25 +15,21 @@ import org.fcitx.fcitx5.android.BuildConfig inline fun Intent.parcelable(key: String): T? { // https://issuetracker.google.com/issues/240585930#comment6 - @Suppress("DEPRECATION") - return getParcelableExtra(key) as? T -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelableExtra(key, T::class.java) -// } else { -// @Suppress("DEPRECATION") -// getParcelableExtra(key) as? T -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelableExtra(key, T::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(key) as? T + } } inline fun Intent.parcelableArray(key: String): Array? { - @Suppress("DEPRECATION", "UNCHECKED_CAST") - return getParcelableArrayExtra(key) as? Array -// return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// getParcelableArrayExtra(key, T::class.java) -// } else { -// @Suppress("DEPRECATION", "UNCHECKED_CAST") -// getParcelableArrayExtra(key) as? Array -// } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getParcelableArrayExtra(key, T::class.java) + } else { + @Suppress("DEPRECATION", "UNCHECKED_CAST") + getParcelableArrayExtra(key) as? Array + } } @RequiresApi(Build.VERSION_CODES.Q) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Menu.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Menu.kt new file mode 100644 index 000000000..5a032bc5a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Menu.kt @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.res.ColorStateList +import android.os.Build +import android.view.Menu +import android.view.MenuItem +import android.view.SubMenu +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import splitties.resources.drawable + +fun MenuItem.setup( + @DrawableRes icon: Int, + @ColorInt iconTint: Int, + showAsAction: Boolean, + onClick: Function0? +): MenuItem { + if (icon != 0 && iconTint != 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + iconTintList = ColorStateList.valueOf(iconTint) + setIcon(icon) + } else { + setIcon(appContext.drawable(icon)?.apply { setTint(iconTint) }) + } + } + if (showAsAction) { + setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + } + if (onClick != null) { + setOnMenuItemClickListener { + // return false only when the actual callback returns false + onClick.invoke() != false + } + } + return this +} + +fun Menu.item( + @StringRes title: Int, + @DrawableRes icon: Int = 0, + @ColorInt iconTint: Int = 0, + showAsAction: Boolean = false, + onClick: Function0? = null +): MenuItem { + val item = add(title).setup(icon, iconTint, showAsAction, onClick) + return item +} + +fun Menu.item( + title: CharSequence, + @DrawableRes icon: Int = 0, + @ColorInt iconTint: Int = 0, + showAsAction: Boolean = false, + onClick: Function0? = null +): MenuItem { + val item = add(title).setup(icon, iconTint, showAsAction, onClick) + return item +} + +fun Menu.subMenu( + @StringRes title: Int, + @DrawableRes icon: Int, + @ColorInt iconTint: Int, + showAsAction: Boolean = false, + initSubMenu: SubMenu.() -> Unit +): SubMenu { + val sub = addSubMenu(title) + sub.item.setup(icon, iconTint, showAsAction, null) + initSubMenu.invoke(sub) + return sub +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/NavController.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavController.kt new file mode 100644 index 000000000..eacfb41da --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavController.kt @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import androidx.navigation.NavController +import androidx.navigation.navOptions +import androidx.navigation.ui.R + +fun NavController.navigateWithAnim(route: T) { + navigate(route, navOptions { + anim { + enter = R.animator.nav_default_enter_anim + exit = R.animator.nav_default_exit_anim + popEnter = R.animator.nav_default_pop_enter_anim + popExit = R.animator.nav_default_pop_exit_anim + } + }) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/NavigationBarHeight.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavigationBarHeight.kt new file mode 100644 index 000000000..704de2491 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/NavigationBarHeight.kt @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import splitties.dimensions.dp + +private const val FALLBACK_NAVBAR_HEIGHT = 48 + +/** + * android.R.dimen.navigation_bar_frame_height + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3221 + * https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r48/services/core/java/com/android/server/wm/DisplayPolicy.java#3059 + */ +fun Context.navbarFrameHeight(): Int { + @SuppressLint("DiscouragedApi") + val resId = resources.getIdentifier("navigation_bar_frame_height", "dimen", "android") + return try { + resources.getDimensionPixelSize(resId) + } catch (e: Resources.NotFoundException) { + dp(FALLBACK_NAVBAR_HEIGHT) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/PackageInfo.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/PackageInfo.kt new file mode 100644 index 000000000..4ec6c4e93 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/PackageInfo.kt @@ -0,0 +1,17 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.content.pm.PackageInfo +import android.os.Build + +val PackageInfo.versionCodeCompat: Long + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode + } else { + @Suppress("DEPRECATION") + versionCode.toLong() + } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt index 171223d5f..7349edccf 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/PreferenceScreen.kt @@ -1,15 +1,17 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2024 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.utils +import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceGroup import androidx.preference.PreferenceScreen +import androidx.preference.PreferenceViewHolder import splitties.resources.drawable import splitties.resources.styledColor @@ -22,34 +24,40 @@ fun PreferenceScreen.addCategory(title: String, block: PreferenceCategory.() -> } fun PreferenceScreen.addCategory(@StringRes title: Int, block: PreferenceCategory.() -> Unit) { - val ctx = context - addCategory(ctx.getString(title), block) + addCategory(context.getString(title), block) } -fun PreferenceGroup.addPreference( +fun Preference.setup( title: String, summary: String? = null, @DrawableRes icon: Int? = null, onClick: (() -> Unit)? = null ) { - addPreference(Preference(context).apply { - isSingleLineTitle = false - setTitle(title) - setSummary(summary) - if (icon == null) { - isIconSpaceReserved = false - } else { - setIcon(context.drawable(icon)?.apply { - setTint(context.styledColor(android.R.attr.colorControlNormal)) - }) - } - onClick?.also { - setOnPreferenceClickListener { _ -> - it.invoke() - true - } + isSingleLineTitle = false + setTitle(title) + setSummary(summary) + if (icon == null) { + isIconSpaceReserved = false + } else { + setIcon(context.drawable(icon)?.apply { + setTint(context.styledColor(android.R.attr.colorControlNormal)) + }) + } + onClick?.also { + setOnPreferenceClickListener { _ -> + it.invoke() + true } - }) + } +} + +fun PreferenceGroup.addPreference( + title: String, + summary: String? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null +) { + addPreference(Preference(context).apply { setup(title, summary, icon, onClick) }) } fun PreferenceGroup.addPreference( @@ -58,8 +66,7 @@ fun PreferenceGroup.addPreference( @DrawableRes icon: Int? = null, onClick: (() -> Unit)? = null ) { - val ctx = context - addPreference(ctx.getString(title), summary, icon, onClick) + addPreference(context.getString(title), summary, icon, onClick) } fun PreferenceGroup.addPreference( @@ -68,6 +75,34 @@ fun PreferenceGroup.addPreference( @DrawableRes icon: Int? = null, onClick: (() -> Unit)? = null ) { - val ctx = context - addPreference(ctx.getString(title), summary?.let(ctx::getString), icon, onClick) + addPreference(context.getString(title), summary?.let(context::getString), icon, onClick) +} + +class LongClickPreference(context: Context) : Preference(context) { + private var onLongClick: (() -> Unit)? = null + + fun setOnPreferenceLongClickListener(callback: (() -> Unit)? = null) { + onLongClick = callback + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + holder.itemView.setOnLongClickListener { + onLongClick?.invoke() + true + } + } +} + +fun PreferenceGroup.addPreference( + @StringRes title: Int, + @StringRes summary: Int? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + addPreference(LongClickPreference(context).apply { + setup(context.getString(title), summary?.let { context.getString(it) }, icon, onClick) + setOnPreferenceLongClickListener(onLongClick) + }) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Settings.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Settings.kt new file mode 100644 index 000000000..f315f334a --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Settings.kt @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.provider.Settings + +fun errInvalidType(cls: Class<*>): Nothing { + throw IllegalArgumentException("Invalid settings type ${cls.name}") +} + +inline fun getGlobalSettings(name: String): T { + return when (T::class.java) { + String::class.java -> Settings.Global.getString(appContext.contentResolver, name) + Float::class.javaObjectType -> Settings.Global.getFloat(appContext.contentResolver, name, 0f) + Long::class.javaObjectType -> Settings.Global.getLong(appContext.contentResolver, name, 0L) + Int::class.javaObjectType -> Settings.Global.getInt(appContext.contentResolver, name, 0) + else -> errInvalidType(T::class.java) + } as T +} + +inline fun getSecureSettings(name: String): T { + return when (T::class.java) { + String::class.java -> Settings.Secure.getString(appContext.contentResolver, name) + Float::class.javaObjectType -> Settings.Secure.getFloat(appContext.contentResolver, name, 0f) + Long::class.javaObjectType -> Settings.Secure.getLong(appContext.contentResolver, name, 0L) + Int::class.javaObjectType -> Settings.Secure.getInt(appContext.contentResolver, name, 0) + else -> errInvalidType(T::class.java) + } as T +} + +inline fun getSystemSettings(name: String): T { + return when (T::class.java) { + String::class.java -> Settings.System.getString(appContext.contentResolver, name) + Float::class.javaObjectType -> Settings.System.getFloat(appContext.contentResolver, name, 0f) + Long::class.javaObjectType -> Settings.System.getLong(appContext.contentResolver, name, 0L) + Int::class.javaObjectType -> Settings.System.getInt(appContext.contentResolver, name, 0) + else -> errInvalidType(T::class.java) + } as T +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt index 3f51923e9..32d8baf24 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Splitties.kt @@ -10,9 +10,9 @@ import android.util.TypedValue import android.view.View import androidx.annotation.AttrRes import androidx.constraintlayout.widget.ConstraintLayout -import androidx.fragment.app.Fragment import splitties.experimental.InternalSplittiesApi import splitties.resources.withResolvedThemeAttribute +import splitties.views.dsl.core.Ui @OptIn(InternalSplittiesApi::class) fun Context.styledFloat(@AttrRes attrRes: Int) = withResolvedThemeAttribute(attrRes) { @@ -26,7 +26,7 @@ fun Context.styledFloat(@AttrRes attrRes: Int) = withResolvedThemeAttribute(attr inline fun View.styledFloat(@AttrRes attrRes: Int) = context.styledFloat(attrRes) @Suppress("NOTHING_TO_INLINE") -inline fun Fragment.styledFloat(@AttrRes attrRes: Int) = context!!.styledFloat(attrRes) +inline fun Ui.styledFloat(@AttrRes attrRes: Int) = ctx.styledFloat(attrRes) inline val ConstraintLayout.LayoutParams.unset get() = ConstraintLayout.LayoutParams.UNSET diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt index bcf02cc5b..2dfb02b24 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemService.kt @@ -11,9 +11,9 @@ import android.media.AudioManager import android.os.UserManager import android.os.Vibrator import android.os.storage.StorageManager +import android.view.WindowManager import android.view.inputmethod.InputMethodManager import androidx.core.content.getSystemService -import androidx.fragment.app.Fragment val Context.audioManager get() = getSystemService()!! @@ -27,14 +27,14 @@ val Context.inputMethodManager val Context.notificationManager get() = getSystemService()!! -val Fragment.notificationManager - get() = requireContext().notificationManager - val Context.storageManager get() = getSystemService()!! val Context.vibrator get() = getSystemService()!! +val Context.windowManager + get() = getSystemService()!! + val Context.userManager get() = getSystemService()!! diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemSettings.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemSettings.kt deleted file mode 100644 index a7aebabcf..000000000 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/SystemSettings.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors - */ - -package org.fcitx.fcitx5.android.utils - -import android.provider.Settings - -fun isSystemSettingEnabled(key: String): Boolean { - return try { - Settings.System.getInt(appContext.contentResolver, key) == 1 - } catch (e: Exception) { - false - } -} - -fun getSecureSettings(name: String): String? { - return Settings.Secure.getString(appContext.contentResolver, name) -} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt index 4ad2452e9..521358ef1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigDescriptor.kt @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: LGPL-2.1-or-later - * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors + * SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors */ package org.fcitx.fcitx5.android.utils.config @@ -9,17 +9,22 @@ import arrow.core.Either import arrow.core.flatMap import arrow.core.raise.either import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue +import kotlinx.serialization.Serializable import org.fcitx.fcitx5.android.core.RawConfig +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.Companion.parse +import org.fcitx.fcitx5.android.utils.config.ConfigDescriptor.ConfigList.ConfigListValue +@Serializable sealed class ConfigDescriptor : Parcelable { abstract val name: String - abstract val type: ConfigType + // `type` is reserved in JSON serialization, so we use `ty` instead + abstract val ty: ConfigType abstract val description: String? abstract val defaultValue: U? abstract val tooltip: String? @Parcelize + @Serializable data class ConfigTopLevelDef( val name: String, val values: List>, @@ -27,12 +32,14 @@ sealed class ConfigDescriptor : Parcelable { ) : Parcelable @Parcelize + @Serializable data class ConfigCustomTypeDef( val name: String, val values: List> ) : Parcelable @Parcelize + @Serializable data class ConfigInt( override val name: String, override val description: String? = null, @@ -41,46 +48,50 @@ sealed class ConfigDescriptor : Parcelable { val intMax: Int?, val intMin: Int?, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyInt } @Parcelize + @Serializable data class ConfigString( override val name: String, override val description: String? = null, override val defaultValue: String? = null, override val tooltip: String? = null, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyString } @Parcelize + @Serializable data class ConfigBool( override val name: String, override val description: String? = null, override val defaultValue: Boolean? = null, override val tooltip: String? = null, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyBool } @Parcelize + @Serializable data class ConfigKey( override val name: String, override val description: String? = null, override val defaultValue: String? = null, override val tooltip: String? = null, ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyKey } @Parcelize + @Serializable data class ConfigEnum( override val name: String, override val description: String? = null, @@ -89,15 +100,16 @@ sealed class ConfigDescriptor : Parcelable { val entries: List, val entriesI18n: List? ) : ConfigDescriptor() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyEnum } @Parcelize + @Serializable data class ConfigCustom( override val name: String, - override val type: ConfigType.TyCustom, + override val ty: ConfigType.TyCustom, override val description: String? = null, override val tooltip: String? = null, // will be filled in parseTopLevel @@ -108,21 +120,40 @@ sealed class ConfigDescriptor : Parcelable { } @Parcelize + @Serializable data class ConfigList( override val name: String, - override val type: ConfigType.TyList, + override val ty: ConfigType.TyList, override val description: String? = null, override val tooltip: String? = null, /** - * [Any?] is used for a union type. See [parse] for details. + * [ConfigListValue] is used for a union type. See [parse] for details. */ - override val defaultValue: @RawValue List? = null, - ) : ConfigDescriptor>() + override val defaultValue: List? = null, + ) : ConfigDescriptor>() { + @Serializable + @Parcelize + sealed interface ConfigListValue : Parcelable { + @Serializable + @Parcelize + data class BoolValue(val value: Boolean) : ConfigListValue + @Serializable + @Parcelize + data class IntValue(val value: Int) : ConfigListValue + @Serializable + @Parcelize + data class KeyValue(val value: String) : ConfigListValue + @Serializable + @Parcelize + data class StringValue(val value: String) : ConfigListValue + } + } /** * Specialized [ConfigList] for enum */ @Parcelize + @Serializable data class ConfigEnumList( override val name: String, override val description: String? = null, @@ -132,11 +163,12 @@ sealed class ConfigDescriptor : Parcelable { val entriesI18n: List? ) : ConfigDescriptor>() { - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyList(ConfigType.TyEnum) } @Parcelize + @Serializable data class ConfigExternal( override val name: String, override val description: String? = null, @@ -157,7 +189,7 @@ sealed class ConfigDescriptor : Parcelable { AndroidTable } - override val type: ConfigType + override val ty: ConfigType get() = ConfigType.TyExternal override val defaultValue: Nothing? get() = null @@ -234,7 +266,11 @@ sealed class ConfigDescriptor : Parcelable { raw.intMax, raw.intMin ) - ConfigType.TyKey -> ConfigKey(raw.name, raw.description, raw.defaultValue) + ConfigType.TyKey -> ConfigKey( + raw.name, + raw.description, + raw.defaultValue + ) is ConfigType.TyList -> if (it.subtype == ConfigType.TyEnum) { val entries = raw.enum ?: raise(ParseException.NoEnumFound(raw)) @@ -254,10 +290,18 @@ sealed class ConfigDescriptor : Parcelable { raw.tooltip, raw.findByName("DefaultValue")?.subItems?.map { ele -> when (it.subtype) { - ConfigType.TyBool -> ele.value.toBoolean() - ConfigType.TyInt -> ele.value.toInt() - ConfigType.TyKey -> ele.value - ConfigType.TyString -> ele.value + ConfigType.TyBool -> ConfigListValue.BoolValue( + ele.value.toBoolean() + ) + ConfigType.TyInt -> ConfigListValue.IntValue( + ele.value.toInt() + ) + ConfigType.TyKey -> ConfigListValue.KeyValue( + ele.value + ) + ConfigType.TyString -> ConfigListValue.StringValue( + ele.value + ) ConfigType.TyEnum -> error("Impossible!") else -> raise(ParseException.BadFormList(it)) } @@ -302,7 +346,7 @@ sealed class ConfigDescriptor : Parcelable { val parsed = parse(it).bind() if (parsed is ConfigCustom) parsed.customTypeDef = customTypeDef.find { cTy -> - cTy.name == parsed.type.typeName + cTy.name == parsed.ty.typeName } parsed } ?: listOf() diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt index c7503f77a..aaf23b3a6 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/config/ConfigType.kt @@ -8,30 +8,40 @@ import android.os.Parcelable import arrow.core.Either import arrow.core.raise.either import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +@Serializable sealed class ConfigType : Parcelable { @Parcelize + @Serializable data object TyInt : ConfigType() @Parcelize + @Serializable data object TyString : ConfigType() @Parcelize + @Serializable data object TyBool : ConfigType() @Parcelize + @Serializable data object TyKey : ConfigType() @Parcelize + @Serializable data object TyEnum : ConfigType() @Parcelize + @Serializable data object TyExternal : ConfigType() @Parcelize + @Serializable data class TyCustom(val typeName: String) : ConfigType() @Parcelize + @Serializable data class TyList(val subtype: ConfigType<*>) : ConfigType() companion object : ConfigParser, Companion.UnknownConfigTypeException> { diff --git a/app/src/main/play/contact-email.txt b/app/src/main/play/contact-email.txt new file mode 100644 index 000000000..9d663e3ab --- /dev/null +++ b/app/src/main/play/contact-email.txt @@ -0,0 +1 @@ +fcitx5-android@googlegroups.com \ No newline at end of file diff --git a/app/src/main/play/contact-website.txt b/app/src/main/play/contact-website.txt new file mode 100644 index 000000000..925e50655 --- /dev/null +++ b/app/src/main/play/contact-website.txt @@ -0,0 +1 @@ +https://fcitx5-android.github.io \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/app/src/main/play/listings/en-US/full-description.txt similarity index 89% rename from fastlane/metadata/android/en-US/full_description.txt rename to app/src/main/play/listings/en-US/full-description.txt index 7c8c26922..6a8f5ca18 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/app/src/main/play/listings/en-US/full-description.txt @@ -12,6 +12,7 @@
  • Japanese (via Anthy Plugin)
  • Korean (via Hangul Plugin)
  • Sinhala (via Sayura Plugin)
  • +
  • Thai (via Thai Plugin)
  • Generic (via RIME Plugin, supports importing custom schemas)
  • Features @@ -23,9 +24,4 @@
  • Popup preview on key press
  • Long press popup keyboard for convenient symbol input
  • Symbol and Emoji picker
  • - -Work in progress -
      -
    • Customizable keyboard layout
    • -
    • More input methods
    \ No newline at end of file diff --git a/app/src/main/play/listings/en-US/graphics/icon/icon.png b/app/src/main/play/listings/en-US/graphics/icon/icon.png new file mode 120000 index 000000000..5fce37599 --- /dev/null +++ b/app/src/main/play/listings/en-US/graphics/icon/icon.png @@ -0,0 +1 @@ +../../../../../res/mipmap-xxxhdpi/ic_launcher.png \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/short_description.txt b/app/src/main/play/listings/en-US/short-description.txt similarity index 100% rename from fastlane/metadata/android/en-US/short_description.txt rename to app/src/main/play/listings/en-US/short-description.txt diff --git a/app/src/main/play/listings/en-US/title.txt b/app/src/main/play/listings/en-US/title.txt new file mode 100644 index 000000000..3b1ab936c --- /dev/null +++ b/app/src/main/play/listings/en-US/title.txt @@ -0,0 +1 @@ +Fcitx5 \ No newline at end of file diff --git a/fastlane/metadata/android/ru/full_description.txt b/app/src/main/play/listings/ru/full-description.txt similarity index 80% rename from fastlane/metadata/android/ru/full_description.txt rename to app/src/main/play/listings/ru/full-description.txt index 55f4e17d3..73e86f4e2 100644 --- a/fastlane/metadata/android/ru/full_description.txt +++ b/app/src/main/play/listings/ru/full-description.txt @@ -12,7 +12,8 @@
  • Японский (через плагин Anthy)
  • Корейский (через плагин Hangul)
  • Сингальский (через плагин Sayura)
  • -
  • Универсальный (плагин Zhongzhouyun, поддерживает импорт пользовательских решений)
  • +
  • Тайский (через плагин Thai)
  • +
  • Универсальный (плагин RIME, поддерживает импорт пользовательских решений)
  • Функции
      @@ -24,8 +25,3 @@
    • Длительное нажатие вызывает клавиатуру для быстрого ввода знаков препинания.
    • Выбор символов и эмодзи
    -Функции в разработке -
      -
    • Настраиваемая раскладка клавиатуры
    • -
    • Дополнительные методы ввода
    • -
    diff --git a/fastlane/metadata/android/ru/short_description.txt b/app/src/main/play/listings/ru/short-description.txt similarity index 100% rename from fastlane/metadata/android/ru/short_description.txt rename to app/src/main/play/listings/ru/short-description.txt diff --git a/app/src/main/play/listings/ru/title.txt b/app/src/main/play/listings/ru/title.txt new file mode 100644 index 000000000..3b1ab936c --- /dev/null +++ b/app/src/main/play/listings/ru/title.txt @@ -0,0 +1 @@ +Fcitx5 \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/app/src/main/play/listings/zh-CN/full-description.txt similarity index 89% rename from fastlane/metadata/android/zh-CN/full_description.txt rename to app/src/main/play/listings/zh-CN/full-description.txt index 3386615c1..e75c4d1c8 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/app/src/main/play/listings/zh-CN/full-description.txt @@ -12,6 +12,7 @@
  • 日语(Anthy 插件)
  • 韩语(Hangul 插件)
  • 僧伽罗语(Sayura 插件)
  • +
  • 泰语(Thai 插件)
  • 通用(中州韵插件,支持导入自定义方案)
  • 已实现的功能 @@ -23,9 +24,4 @@
  • 按键弹出预览
  • 长按弹出键盘以快速输入标点符号
  • 标点符号、Emoji 表情和颜文字选择器
  • - -开发中的功能 -
      -
    • 自定义键盘布局
    • -
    • 更多输入法引擎
    \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/app/src/main/play/listings/zh-CN/short-description.txt similarity index 100% rename from fastlane/metadata/android/zh-CN/short_description.txt rename to app/src/main/play/listings/zh-CN/short-description.txt diff --git a/app/src/main/play/listings/zh-CN/title.txt b/app/src/main/play/listings/zh-CN/title.txt new file mode 100644 index 000000000..21a4d87d7 --- /dev/null +++ b/app/src/main/play/listings/zh-CN/title.txt @@ -0,0 +1 @@ +小企鹅输入法 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/21.txt b/app/src/main/play/release-notes/en-US/21.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/21.txt rename to app/src/main/play/release-notes/en-US/21.txt diff --git a/fastlane/metadata/android/en-US/changelogs/31.txt b/app/src/main/play/release-notes/en-US/31.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/31.txt rename to app/src/main/play/release-notes/en-US/31.txt diff --git a/fastlane/metadata/android/en-US/changelogs/42.txt b/app/src/main/play/release-notes/en-US/42.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/42.txt rename to app/src/main/play/release-notes/en-US/42.txt diff --git a/fastlane/metadata/android/en-US/changelogs/54.txt b/app/src/main/play/release-notes/en-US/54.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/54.txt rename to app/src/main/play/release-notes/en-US/54.txt diff --git a/fastlane/metadata/android/en-US/changelogs/64.txt b/app/src/main/play/release-notes/en-US/64.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/64.txt rename to app/src/main/play/release-notes/en-US/64.txt diff --git a/fastlane/metadata/android/en-US/changelogs/74.txt b/app/src/main/play/release-notes/en-US/74.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/74.txt rename to app/src/main/play/release-notes/en-US/74.txt diff --git a/app/src/main/play/release-notes/en-US/84.txt b/app/src/main/play/release-notes/en-US/84.txt new file mode 100644 index 000000000..f2438b8ee --- /dev/null +++ b/app/src/main/play/release-notes/en-US/84.txt @@ -0,0 +1,60 @@ +# 0.1.0 - Candidates Window for Physical Keyboard + +## Highlights + +- The input method will show a floating candidates window and hide virtual keyboard when you start typing with a physical keyboard +- Adopt fcitx5 "candidate action" API, to pin candidates in Pinyin/Shuangpin and forget words in RIME +- The application now targets Android API 35, and handles navigation bar or system gesture insets more reliably +- The application has been renamed to "小企鹅输入法" in Chinese, and "Fcitx5" for non-Chinese languages + +### New plugins + +- Thai, this is finally possible since scancode is sent to fcitx along with the keysym + +### Notable changes + +- Removed some bundled table input methods that nobody would use: 晚风、冰蝉全息、仓颉(简体中文) +Some actually useful ones can be found in our F-Droid repo: https://f5a.torus.icu/fdroid/repo/ , or updater: https://github.com/fcitx5-android/fcitx5-android-updater +- "Advanced - Ignore system cursor position" has been disabled by default, it should be stable enough +- "Theme - Navigation bar background" now defaults to "Keyboard background image" on Oreo+ devices + +### Build process improvements + +- Removed many unnecessary files in APK, eg. baseline.prof, vcsInfo, dependenciesInfo, kotlin-tooling-metadata.json ... +- Make use of AGP's splits.abi and signingConfig feature, and make prefab related tasks run more reliably + +## New features + +- Add mapping to other brace characters on top of current "(" & ")" +- Adopt fcitx5 candidate action API +- Show text instead of indeterminate progress bar when animation disabled +- Swipe down voice input / expand candidate button to hide keyboard +- Add option to perform haptic feedback on keyup +- Configurable clipboard entry radius +- Apply keyBorder prefs to Text Editing and Symbol Picker +- Allow cursor to move out of preedit in androidkeyboard +- Send keycode/scancode to fcitx +- Reset caps lock state after switching input method +- Allow uninstalling plugin from AboutActivity +- Refresh PluginFragment on resume/package change +- Show floating CandidatesView for hardware keyboard +- Disable word hint for physical keyboard by default +- Option to show CandidatesView by input device + +## Bug fixes + +- Toolbar would became blank when trigger and exit unicode addon right after changing theme +- Remove discouraged degree celsius/fahrenheit symbols in symbol picker +- Only perform long press haptic feedback when the pressed key has long press action +- Fix composing state tracking when interrupting input +- Fix toolbar title reset when rotating screen +- Fix undoing consecutive deletions in ClipboardWindow +- Fix first backspace swipe after initialization +- Write physical display size instead of some random size without navbar when exporting logs +- Fix crash when opening table addon config while it's not loaded +- Disable "CanceledOnTouchOutside" for complex dialogs to avoid it being dismissed by accident +- Disable menu group divider on Honor MagicOS devices +- Hide PopupMenu icon on Flyme because of layout issues +- Hopefully fixes crash on some devices when longpress "P" in landscape mode +- Fix some English strings +- Fix navbar insets detection on some devices diff --git a/app/src/main/play/release-notes/en-US/94.txt b/app/src/main/play/release-notes/en-US/94.txt new file mode 100644 index 000000000..e60f4a843 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/94.txt @@ -0,0 +1,43 @@ +# 0.1.1 - Bug Fixes and Improvements + +This is a rather small release focusing on improving stability, so don't expect many exciting new features ... + +## Highlights + +- New option to enable "Haptic feedback on key repeat" +- New theme properties for candidate label/text/comment color +- Click to turn pages/select candidates when using CandidatesView for physical keyboard + +### Notable changes + +- "Follow system day/night theme" has been enabled by default, which should be stable enough + +### Build process improvements + +- Removed all symlinks across git submodules to simplify the build process on Windows + +## New features + +- Register a BroadcastReceiver to restart fcitx instance externally +- Add a shortcut in developer settings to restart fcitx instance +- New option to ignore system WindowInsets +- Enable "follow_system_dark_mode" by default +- Make ClearURLs compliant with the JavaScript implementation +- Add option to open DocumentsUI and browse user data dir +- Add option "Haptic feedback on key repeat" +- Add theme properties for candidate label/text/comment color +- Make CandidatesView touchable +- Add content description to buttons on toolbar and TextEditing window +- Always prepend user input as androidkeyboard candidate + +## Bug fixes + +- Fix edge-to-edge in plugin's AboutActivity +- Fix ExpandedCandidateWindow self-detach on predict candidates +- Fix potential null pointer dereference +- Workaround duplicated onPrimaryClipChanged callback +- Try follow system "Vibration & haptics" settings +- Apply fcitx input filter to paged candidates +- Improve CandidatesView positioning when monitoring cursor anchor fails +- Send key with KeyStates.Virtual on space swipe +- Workaround Samsung One UI 7.0 navbar coloring diff --git a/app/src/main/play/release-notes/en-US/default.txt b/app/src/main/play/release-notes/en-US/default.txt new file mode 120000 index 000000000..5f5cca6e1 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/default.txt @@ -0,0 +1 @@ +84.txt \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sharp_mode_night_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_next_24.xml similarity index 58% rename from app/src/main/res/drawable/ic_sharp_mode_night_24.xml rename to app/src/main/res/drawable/ic_baseline_arrow_next_24.xml index da75edbd0..6416cf711 100644 --- a/app/src/main/res/drawable/ic_sharp_mode_night_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_next_24.xml @@ -1,9 +1,10 @@ + android:pathData="M10,17l5,-5 -5,-5v10z" /> diff --git a/app/src/main/res/drawable/ic_baseline_arrow_prev_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_prev_24.xml new file mode 100644 index 000000000..522f84e25 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_prev_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml new file mode 100644 index 000000000..3fa1ea95d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_auto_awesome_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_dark_mode_24.xml b/app/src/main/res/drawable/ic_baseline_dark_mode_24.xml new file mode 100644 index 000000000..10cccb646 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_dark_mode_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flip_24.xml b/app/src/main/res/drawable/ic_baseline_flip_24.xml new file mode 100644 index 000000000..8dbbb325f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flip_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sharp_light_mode_24.xml b/app/src/main/res/drawable/ic_baseline_light_mode_24.xml similarity index 100% rename from app/src/main/res/drawable/ic_sharp_light_mode_24.xml rename to app/src/main/res/drawable/ic_baseline_light_mode_24.xml diff --git a/app/src/main/res/drawable/ic_baseline_list_alt_24.xml b/app/src/main/res/drawable/ic_baseline_list_alt_24.xml new file mode 100644 index 000000000..fd5d86e2a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_list_alt_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_rotate_right_24.xml b/app/src/main/res/drawable/ic_baseline_rotate_right_24.xml new file mode 100644 index 000000000..116c1ddc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_rotate_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_share_24.xml b/app/src/main/res/drawable/ic_baseline_share_24.xml new file mode 100644 index 000000000..d38ab1c98 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_share_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2eeac4d25..8abb7224c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -25,7 +25,6 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/toolbar" - app:navGraph="@navigation/settings_nav" /> + app:layout_constraintTop_toBottomOf="@id/toolbar" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/settings_nav.xml b/app/src/main/res/navigation/settings_nav.xml deleted file mode 100644 index 8310c908e..000000000 --- a/app/src/main/res/navigation/settings_nav.xml +++ /dev/null @@ -1,308 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 494f6dfbe..c225eeb5b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,7 +1,7 @@ - Fcitx5 für Android - Fcitx5 für Android (Debug) + Fcitx5 + Fcitx5 (Debug) Speichern Eingabemethoden Eingabemethode hinzufügen @@ -56,7 +56,7 @@ Löschen Zwischenablage Tastaturhöhe - Tastatur + Tastatur Version Git Hash erzeugen Quelltext diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6c6d67205..39b18a1f3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,7 +1,7 @@ - Fcitx5 para Android - Fcitx5 para Android (depuración) + Fcitx5 + Fcitx5 (depuración) Guardar Métodos de entrada Añadir método de entrada @@ -42,7 +42,7 @@ Eliminar Portapapeles Altura de teclado - Teclado + Teclado Versión Código fuente Normativa de privacidad diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a8d7a256e..21b9d6001 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,7 +1,7 @@ - アンドロイド用 Fcitx5 - アンドロイド用 Fcitx5 (デバッグ) + Fcitx5 + Fcitx5 (デバッグ) 保存 インプットメソッド インプットメソッドの追加 @@ -20,7 +20,7 @@ キー押下時の振動の大きさ 追加 編集 - 取り消す + 元に戻す ランタイムデータを削除して同期する 現在のデータディレクトリを削除し、アプリにバンドルされているアセットから同期する インプットメソッドを有効にする @@ -61,7 +61,7 @@ 削除 クリップボード キーボードの高さ - キーボード + ソフトウェアキーボード クリップボード候補のタイムアウト バージョン ビルド時間 @@ -153,8 +153,8 @@ 利用可能な設定オプションがありません。もしかしたらこのアドオンはfcitxの起動時に無効になっているかもしれません 縦画面時 横画面時 - キーボードボタンの余白 - キーボード側面の空間 + キーボードの下部余白 + キーボードの側面余白 アドオンを無効にする %1$s を無効にする場合 : %1$s を無効にする @@ -206,7 +206,7 @@ 固定されていないすべてのアイテムを削除しますか? %1$d 個のアイテムを削除しました テーマを削除 - 本当に\"%1$d\"テーマを削除しますか? + 本当に\"%1$s\"テーマを削除しますか? ヒープダンプを取得する プラグイン 読み込みました @@ -249,4 +249,9 @@ 次のインプットメゾッドアプリに切り替える 空にできません %1$s を空にできません + 右回転 + 反転 + 左右反転 + 上下反転 + 切り取り diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1074eb1e8..ca797e48d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,7 +1,7 @@ - 안드로이드용 Fcitx5 - 안드로이드용 Fcitx5(디버그) + Fcitx5 + Fcitx5 (디버그) 저장 입력기 입력기 추가 @@ -55,7 +55,7 @@ 삭제 클립보드 키보드 높이 - 키보드 + 키보드 버전 빌드 시간 Git 해시 빌드 diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 985ab70c1..e616379fa 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -10,13 +10,6 @@ @color/grey_400 - - - -