diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml new file mode 100644 index 000000000..fa8a00b01 --- /dev/null +++ b/.github/workflows/native-tests.yml @@ -0,0 +1,38 @@ +name: Native tests + +# Builds and runs the native C++ engine unit tests (app/src/main/jni/tests) on a plain Linux +# host via the standalone CMake build (jni/CMakeLists.txt) — no AOSP platform tree needed. This +# covers the dictionary/suggest/geometry engine that the JVM/Robolectric suite cannot reach. +on: + push: + branches: [dev] + paths: ['app/src/main/jni/**'] + pull_request: + branches: [dev, main] + paths: ['app/src/main/jni/**'] + +jobs: + native-host-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # find_package(JNI) needs JDK headers (jni.h + linux/jni_md.h); setup-java sets JAVA_HOME. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Install build tools + run: sudo apt-get update && sudo apt-get install -y cmake g++ + + - name: Configure + run: cmake -S app/src/main/jni -B build-host-tests -DCMAKE_BUILD_TYPE=Release + + - name: Build native tests + run: cmake --build build-host-tests -j"$(nproc)" + + - name: Run native unit tests + # FormatUtilsTest.TestDetectFormatVersion is quarantined: a host-toolchain anomaly in code + # that works on-device (the suite had never run in CI before). Tracked in #80. + run: ctest --test-dir build-host-tests --output-on-failure -E 'FormatUtilsTest\.TestDetectFormatVersion' diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml deleted file mode 100644 index a3f5715c1..000000000 --- a/.github/workflows/update-badges.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Update README Badges - -on: - schedule: - - cron: '0 0 * * *' # Midnight UTC - workflow_dispatch: - -permissions: - contents: write - -jobs: - update-badges: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Fetch GitHub stats - id: stats - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - REPO="LeanBitLab/HeliboardL" - - # Latest version - VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' | sed 's/^v//' 2>/dev/null || echo "N/A") - echo "version=$VERSION" >> $GITHUB_OUTPUT - - # Total downloads - DOWNLOADS=$(gh api repos/$REPO/releases --jq '[.[].assets[]?.download_count] | add // 0' 2>/dev/null || echo "0") - echo "downloads=$DOWNLOADS" >> $GITHUB_OUTPUT - - # Stars - STARS=$(gh api repos/$REPO --jq '.stargazers_count' 2>/dev/null || echo "0") - echo "stars=$STARS" >> $GITHUB_OUTPUT - - - name: Generate badge SVGs - env: - VERSION: ${{ steps.stats.outputs.version }} - DOWNLOADS: ${{ steps.stats.outputs.downloads }} - STARS: ${{ steps.stats.outputs.stars }} - run: | - mkdir -p docs/badges - - # Format numbers with commas - DOWNLOADS_FMT=$(printf "%'d" "$DOWNLOADS" 2>/dev/null || echo "$DOWNLOADS") - STARS_FMT=$(printf "%'d" "$STARS" 2>/dev/null || echo "$STARS") - - # Download version badge - cat > docs/badges/download.svg << EOF - VersionVersionv${VERSION}v${VERSION} - EOF - - # Downloads count badge - cat > docs/badges/downloads.svg << EOF - DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} - EOF - - # Stars badge - cat > docs/badges/stars.svg << EOF - StarsStars${STARS_FMT}${STARS_FMT} - EOF - - echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" - - - name: Update README badge URLs - run: | - # Replace shields.io URLs with local badge paths - sed -i 's|https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download\&style=for-the-badge\&color=7C4DFF|docs/badges/download.svg|g' README.md - sed -i 's|https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge\&color=7C4DFF\&label=Downloads|docs/badges/downloads.svg|g' README.md - sed -i 's|https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge\&color=7C4DFF|docs/badges/stars.svg|g' README.md - - - name: Commit changes - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add docs/badges/ README.md - git diff --staged --quiet || git commit -m "chore: update README badges [skip ci]" - git push diff --git a/AGENTS.md b/AGENTS.md index 095031ad7..516b7e7b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,3 +96,18 @@ Before merging a non-trivial change — correctness-sensitive input/dictionary l - Use `effort: high` for correctness/design passes; lower tiers for quick sanity checks. - Treat its output as adversarial input, not gospel: it sees the conversation but not tool/scout internals, so verify its claims against the code before acting (it has caught real bugs and unverified assertions in this repo's PRs). - Especially worth running before merging changes to `InputLogic`, `DictionaryFacilitatorImpl`/`Suggest`, or anything touching the two-thumb/spacing state machine. + +## Project Board & Issue Tracking +The roadmap lives in GitHub Project #3 ("Two-Thumb & Keyboard Roadmap", `gh project … --owner AsafMah`), with a `Status` field (`Todo` / `In Progress` / `Done`) and epics (`[Epic]` issues) parenting sub-issues. **Keep it current as you work — it is the single source of truth, not a chat promise:** +- When you **open a PR** for an issue, set that issue (and its PR, once added) to **In Progress**, and bump the parent epic to **In Progress** if it was `Todo`. +- When a PR **merges** (and its issue closes), move both the issue and PR to **Done**; if every sub-issue of an epic is `Done`, move the epic to `Done`. +- **Add** any issue/PR you create to the project, and close issues a merged PR resolves (use `Fixes #N` in the PR body, or `gh issue close` if the squash/merge message only referenced `(#N)`). +- Field/option IDs for scripting: project `PVT_kwHOAGIGz84BZwMC`, Status field `PVTSSF_lAHOAGIGz84BZwMCzhUsrio` (Todo `f75ad846`, In Progress `47fc9ee4`, Done `98236657`); set via `gh project item-edit --id --field-id --single-select-option-id --project-id `. +This convention is loaded every session, so any agent (and future-you) is expected to follow it without being re-told. + +## Changelog & Releases +Keep `CHANGELOG.md` current — it is LeanTypeDual's own history, not a per-line provenance log. +- **Every user-facing or notable change** gets a line under `## [Unreleased]` (or the in-progress version), grouped `Added` / `Changed` / `Fixed` / `Reliability & testing`, with the `(#N)` issue/PR ref. Internal-only refactors go under `Changed`/`Reliability`; do not enumerate them in the user-facing fastlane note. +- **Provenance is coarse, not per-entry.** Do NOT tag each line ours/LeanType/HeliBoard. When upstream code is merged in, add a single `Upstream` marker line under that release (e.g. `Upstream — merged HeliBoard 3.9`). Everything not under an `Upstream` marker is original to this fork by default. The fork-only feature set lives in the README, not the changelog. +- **Versioning:** SemVer `versionName` in `app/build.gradle.kts`; `versionCode` follows `major*1000 + minor*100 + patch*10` (e.g. `3.9.0` → `3900`). On release, also add `fastlane/metadata/android/en-US/changelogs/.txt` (terse, user-facing bullets only). Release chores: `tools/release.py`. +- On cutting a release, rename `[Unreleased]` to the version + date and start a fresh `[Unreleased]`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..956fc56e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,109 @@ +# Changelog + +All notable changes to **LeanTypeDual** are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +> **Lineage & provenance.** LeanTypeDual is a fork of +> [LeanBitLab/LeanType](https://github.com/LeanBitLab) (the AI layer), which is itself a fork of +> [Helium314/HeliBoard](https://github.com/Helium314/HeliBoard) (the keyboard engine), based on +> AOSP/OpenBoard. Rather than re-list every inherited HeliBoard/LeanType version, this changelog +> records **LeanTypeDual's own releases**. Points where upstream code was merged in are noted as +> **`Upstream`** markers; everything else is original to this fork. + +## [Unreleased] + +## [3.9.0] - 2026-06-10 + +### Added +- **HCESAR keyboard layout** for Latin-script subtypes. (#74) +- **Touchpad edge-scroll** — holding a finger near the touchpad edge auto-repeats cursor movement + with acceleration. (#74) +- **Toolbar: swipe down to hide the keyboard.** (#74) +- **Toolbar: show only the toolbar when a hardware keyboard is connected.** (#74) +- **Undo-word toolbar key** — reverts the last committed word back to its suggestion + alternatives. (#35) +- **Pointer-trace recorder** (opt-in) — captures gesture traces + keyboard geometry to JSON for + debugging/recognition work. (#20) + +### Changed +- **Graduated trust for newly-learned words** — a just-learned word is held below real-dictionary + suggestions until you've used it a few times, reducing premature autocorrect to half-typed + words. (#39) +- Two-thumb down-swipe shortcut popup now tiles its icons proportionally across the usable + row. (#36) +- README status badges switched to live shields.io badges (auto-updating; no CI). (#76) +- Backspace bookkeeping consolidated into a single, unit-tested `BackspaceUnitStack` (internal + refactor, behaviour-preserving). (#31) + +### Reliability & testing +- **Native C++ engine tests now run in CI.** A standalone host build (`app/src/main/jni/CMakeLists.txt`) + compiles and runs the dictionary/suggest/geometry gtest suite on every change to the native + engine — coverage the JVM/Robolectric tests cannot reach. (#78) +- Added a golden-master **backspace regression corpus** to the JVM suite, and the **unit-test gate + is now blocking** on every PR. (#21, #12) + +## [3.8.6] - 2026-06 +### Added +- Flag learned/typed words that aren't in a dictionary; long-press to **Add** or **Block** them, + plus a new **Blocklist** settings screen. +### Changed +- Two-thumb: the down-swipe shortcut popup now aligns to the letter row (swiping down on a key + selects the icon above it). +### Fixed +- Two-thumb ghost-merge: a deleted or cancelled gesture trail no longer fuses into the next swipe. + +## [3.8.5] +### Added +- Enable or disable individual dictionaries (built-in and custom) in settings. +### Fixed +- Toolbar key customization toggles not persisting. +- Emoji-search keyboard not splitting in landscape when split keyboard is enabled. + +## [3.8.4] +### Added +- Double-tap touchpad gesture to delete selected words. +- Clipboard screenshot compression toggle; duplicate screenshots prevented. +- Text Expander: backspace-to-revert, and `%cursor%` / `%greeting%` / `%tomorrow%` / list + placeholders (with optional custom count, e.g. `%list_5%`). +### Changed +- Gboard dictionary import performance; settings/editor performance, stability and memory. +### Fixed +- Missing words on import; corrupted imports (ZIP signatures pre-verified, streams closed). + +## [3.8.3] +### Added +- Custom "Clear clipboard" toolbar key icon styles (bin, sweep, slanted, legacy). +- Custom drawable picker highlights in the Customize Icons grid; instant icon updates without + restart; quick clipboard-item clear on long-press. +### Fixed +- Swipe-to-delete clipboard crash; pinned-section styling. +- Double-space-period countdown cancellation on Korean & combiner layouts. + +## [3.8.2] +### Added +- **Text Expander** with placeholders (`%clipboard%`, `%day%`, `%time12%`, …) and a guide. +- Customizable tags for Custom AI Keys (themed capsules). +- Option to fold pinned clipboard items by default. +- Redesigned Sponsor dialog. +### Fixed +- F-Droid reproducible-build packaging discrepancy. +- Large clipboard-text truncation (native paste); clipboard suggestion in split-toolbar mode. + +## [3.8.1] +### Added +- Fine-grained vibration strength (amplitude) control for keypress haptics. +- "Clear All" + confirmation in the personal-dictionary settings screen. +### Fixed +- `Resources$NotFoundException` crash from obsolete custom-icon overrides. +- Spacebar cursor-move and delete swipe in the emoji-search input field. +- Center-crop scaling for custom keyboard background images (no more squishing). + +## Baseline + +`Upstream` — Forked from **LeanBitLab/LeanType** (AI proofreading/translation, floating keyboard, +custom AI keys) on top of **HeliBoard 3.8.x** (the keyboard engine: dictionaries, layouts, +multilingual typing, glide typing, clipboard history, themes). LeanTypeDual ships as a distinct app +(`com.asafmah.leantypedual`) and adds, on top of that base, **two-thumb (dual-thumb) typing** and +the per-release changes above. See the [README](README.md) for the full feature set. diff --git a/README.md b/README.md index e7702a486..dc4901dd7 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,44 @@ -# LeanType +# LeanTypeDual - LeanType Banner + LeanTypeDual Banner -[![Download](docs/badges/download.svg)](https://github.com/LeanBitLab/HeliboardL/releases/latest) [![Downloads](docs/badges/downloads.svg)](https://github.com/LeanBitLab/HeliboardL/releases) [![Stars](docs/badges/stars.svg)](https://github.com/LeanBitLab/HeliboardL/stargazers) +[![Download](https://img.shields.io/github/v/release/AsafMah/LeanType?label=Download&style=for-the-badge&color=7C4DFF)](https://github.com/AsafMah/LeanType/releases/latest) [![Downloads](https://img.shields.io/github/downloads/AsafMah/LeanType/total?style=for-the-badge&color=7C4DFF&label=Downloads)](https://github.com/AsafMah/LeanType/releases) [![Stars](https://img.shields.io/github/stars/AsafMah/LeanType?style=for-the-badge&color=7C4DFF)](https://github.com/AsafMah/LeanType/stargazers) -**LeanType** is a fork of [HeliBoard](https://github.com/Helium314/HeliBoard) - a privacy-conscious and customizable open-source keyboard based on AOSP/OpenBoard. +**LeanTypeDual** is a privacy-conscious, customizable keyboard built for **two-thumb typing** with **opt-in AI**. It is a fork of [LeanBitLab/LeanType](https://github.com/LeanBitLab) (which layers AI proofreading & translation on top), itself a fork of [HeliBoard](https://github.com/Helium314/HeliBoard) — the AOSP/OpenBoard-based engine that does dictionaries, layouts, multilingual & glide typing, themes and clipboard history. -This fork adds **optional AI-powered features** using Gemini, Groq, and OpenAI-compatible APIs, offering a hybrid experience: a private, offline core with opt-in cloud intelligence. +The **"Dual"** is **dual-thumb gesture typing**: glide with both thumbs at once and the keyboard fuses the trails into words. On top of that it keeps a private, offline core with opt-in cloud intelligence, and ships in three privacy tiers. It installs as a **distinct app** (`com.asafmah.leantypedual`) so it can run side-by-side with the upstream keyboards. -## What's New in LeanType +## What makes LeanTypeDual different -- **[🤖 Multi-Provider AI](docs/FEATURES.md#supported-ai-providers)** - Proofread using **Gemini**, **Groq** (Llama 3, Mixtral), or **OpenAI-compatible** providers. Supports dynamic fetching of latest models directly from providers. +### ✌️ Two-thumb (dual-thumb) typing — the namesake feature +Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates multiple simultaneous gesture trails into a single word (a Nintype-style flow) instead of forcing one-finger-at-a-time swipes. It has a dedicated tuning screen — combining-mode grace timing, tap-promotion, fragment backspace (pop the last swiped fragment), multi-part word recognition, customizable autospace, and an opt-in typing-insight overlay that visualizes the gesture join. *(Gesture typing requires the gesture library — see Download.)* + +### On top of that — LeanType's AI layer and quality-of-life features + +- **[🤖 Multi-Provider AI](docs/FEATURES.md#supported-ai-providers)** - Proofread using **Gemini**, **Groq** (Llama 3, Mixtral), or **OpenAI-compatible** providers, with dynamic fetching of the latest models. - **[🛡️ Offline AI](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using ONNX models (Offline build only). -- **🌐 AI Translation** - Translate selected text directly using your chosen AI provider, with a separate model selector. -- **[🧠 Custom AI Keys](docs/FEATURES.md#4-custom-ai-keys--keywords)** - Assign custom prompts, personas (#editor, #proofread), and custom text labels/tags (showing as themed capsules) to 10 customizable toolbar keys. -- **📝 Text Expander** - Built-in expansion tool supporting custom shortcuts and dynamic template variables (date, time, clipboard, custom placeholders). -- **🪟 Floating Keyboard** - Detach the keyboard into a draggable window for seamless multitasking. Includes a persistent mode option to keep the keyboard floating. -- **⌨️ Dual Toolbar / Split Suggestions** - Option to split suggestions and toolbar for easier access. -- **🖱️ Touchpad Mode** - Swipe spacebar up to toggle touchpad with custom sensitivity controls, including full-screen laptop-style touchpad mode. +- **🌐 AI Translation** - Translate selected text using your chosen provider, with a separate model selector. +- **[🧠 Custom AI Keys](docs/FEATURES.md#4-custom-ai-keys--keywords)** - Assign custom prompts, personas (#editor, #proofread), and labels/tags (themed capsules) to 10 customizable toolbar keys. +- **📝 Text Expander** - Shortcut → expansion with dynamic placeholders (`%clipboard%`, `%day%`, `%time12%`, `%cursor%`, lists), backspace-to-revert, and a guide. +- **🧠 Smarter learned words** - *graduated trust* keeps a just-learned word below real-dictionary suggestions until you've used it a few times (no premature autocorrect to half-typed words); flag unknown words to **Add** or **Block** them via a Blocklist screen. +- **↩️ Undo word** - a toolbar key that reverts the last committed word back to its suggestion alternatives. +- **🗂️ Per-dictionary control** - enable or disable individual built-in and custom dictionaries. +- **🪟 Floating Keyboard** - Detach the keyboard into a draggable, resizable window (true OS-level overlay), with an optional persistent mode. +- **⌨️ Dual Toolbar / Split Suggestions** - Split the suggestion strip and toolbar for easier reach. +- **🖱️ Touchpad Mode** - Swipe the spacebar up for a cursor touchpad with sensitivity controls and edge-scroll acceleration, including a full-screen laptop-style mode. - **🎨 Modern UI** - "Squircle" key backgrounds, refined icons, and polished aesthetics. -- **🔄 Google Dictionary Import** - Easily import your personal dictionary words. -- **⚙️ Enhanced Customization** - Force auto-capitalization toggle, reorganized settings, and more. -- **🕵️ Clear Incognito Mode** - Distinct "Hat & Glasses" icon for clear visibility. -- **🔍 Clipboard Search & Undo** - Search through your clipboard history directly from the toolbar, undo accidental item deletions, and fold/collapse pinned items by default to save space. -- **📸 Screenshot Suggestion & Clipboard** - Suggests recently taken screenshots for quick sharing via the suggestion strip and saves them to your clipboard history. -- **🔎 Emoji Search** - Search for emojis by name. *Requires loading an Emoji Dictionary.* -- **🔒 Privacy Choices** - Choose **Standard** (Opt-in AI), **Offline** (Hard-disabled network, offline model load), or **Offline Lite** (Minimalist, no AI) versions. +- **🔄 Google Dictionary Import** - Import your personal dictionary words. +- **🔍 Clipboard Search & Undo** - Search clipboard history from the toolbar, undo accidental deletions, and fold pinned items by default. +- **📸 Screenshot Suggestion & Clipboard** - Recently-taken screenshots are offered in the suggestion strip and saved to clipboard history. +- **🔎 Emoji Search** - Search emojis by name. *Requires loading an Emoji Dictionary.* +- **⚙️ Enhanced Customization** - Force auto-capitalization, fine-grained haptics, distinct incognito icon, reorganized settings, and more. +- **🔒 Privacy Choices** - Choose **Standard** (opt-in AI), **Offline** (network hard-disabled, offline model), or **Offline Lite** (no AI, ~20 MB). @@ -54,17 +61,12 @@ This fork adds **optional AI-powered features** using Gemini, Groq, and OpenAI-c - @@ -149,8 +151,9 @@ See [LICENSE](/LICENSE) file. - [AOSP Keyboard](https://android.googlesource.com/platform/packages/inputmethods/LatinIME/) - All [HeliBoard Contributors](https://github.com/Helium314/HeliBoard/graphs/contributors) -### LeanType -- Built with ❤️ by [LeanBitLab](https://github.com/LeanBitLab) +### This fork +- **LeanTypeDual** — two-thumb typing and the changes in [CHANGELOG.md](CHANGELOG.md), by [AsafMah](https://github.com/AsafMah) +- **[LeanType](https://github.com/LeanBitLab)** (the AI proofreading/translation layer) — by LeanBitLab ## 🛡️ LeanBitLab Ecosystem @@ -163,7 +166,7 @@ Check out our other projects: Building and maintaining privacy-focused, offline AI apps takes time and resources (test devices, server costs, etc.). -If you love LeanType, please consider supporting the project! +If you love LeanTypeDual, please consider supporting the project! Sponsor on GitHub @@ -173,4 +176,4 @@ Your support keeps the code **100% Free and Open Source**. --- -*LeanType • Privacy-focused keyboard with AI enhancements* +*LeanTypeDual • Two-thumb typing • privacy-focused, with opt-in AI* diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6381b7a6..0ac2eef83 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "com.asafmah.leantypedual" minSdk = 21 targetSdk = 35 - versionCode = 3860 - versionName = "3.8.6" + versionCode = 3900 + versionName = "3.9.0" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/app/src/main/assets/layouts/main/hcesar.json b/app/src/main/assets/layouts/main/hcesar.json new file mode 100644 index 000000000..dbbb11caf --- /dev/null +++ b/app/src/main/assets/layouts/main/hcesar.json @@ -0,0 +1,66 @@ +[ + [ + { "label": "h" }, + { "label": "c" }, + { "label": "e" }, + { "label": "s" }, + { "label": "a" }, + { "label": "r" }, + { "label": "o" }, + { "label": "p" }, + { "label": "z" }, + { "$": "shift_state_selector", + "shifted": { "label": "«" }, + "default": { + "label": ",", + "popup": { + "relevant": [ + { "label": "}"}, + { "label": "0" }, + { "label": ";" }, + { "label": "!" }, + { "label": "!fixedColumnOrder!4" } + ] + } + } + } + ], + [ + { "label": "q" }, + { "label": "t" }, + { "label": "d" }, + { "label": "i" }, + { "label": "n" }, + { "label": "u" }, + { "label": "l" }, + { "label": "m" }, + { "label": "x" }, + { "$": "shift_state_selector", + "shifted": { "label": "»" }, + "default": { + "label": ".", + "popup": { + "relevant": [ + { "label": "/" }, + { "label": ":" }, + {"label": "?" }, + { "label": "!fixedColumnOrder!3" } + ] + } + } + } + ], + [ + { "label": "ç" }, + { "label": "j" }, + { "label": "b" }, + { "label": "f" }, + { "label": "v" }, + { "label": "g" }, + { "label": "k" } + ], + [ + { "label": "y" }, + { "label": "w" } + ] +] diff --git a/app/src/main/java/helium314/keyboard/accessibility/KeyCodeDescriptionMapper.kt b/app/src/main/java/helium314/keyboard/accessibility/KeyCodeDescriptionMapper.kt index 6dd313836..75168f4be 100644 --- a/app/src/main/java/helium314/keyboard/accessibility/KeyCodeDescriptionMapper.kt +++ b/app/src/main/java/helium314/keyboard/accessibility/KeyCodeDescriptionMapper.kt @@ -36,6 +36,7 @@ internal class KeyCodeDescriptionMapper private constructor() { put(KeyCode.ACTION_PREVIOUS, R.string.spoken_description_action_previous) put(KeyCode.JOIN_NEXT, R.string.spoken_description_join_next) put(KeyCode.FORCE_NEXT_SPACE, R.string.spoken_description_force_next_space) + put(KeyCode.UNDO_WORD, R.string.spoken_description_undo_word) put(KeyCode.EMOJI, R.string.spoken_description_emoji) // Because the upper-case and lower-case mappings of the following letters is depending on // the locale, the upper case descriptions should be defined here. The lower case diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 19e7bde5f..3fab62c84 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -331,8 +331,9 @@ public boolean isImeSuppressedByHardwareKeyboard( private void setMainKeyboardFrame( @NonNull final SettingsValues settingsValues, @NonNull final KeyboardSwitchState toggleState) { - final int visibility = isImeSuppressedByHardwareKeyboard(settingsValues, toggleState) ? View.GONE - : View.VISIBLE; + final boolean suppressKeyboard = isImeSuppressedByHardwareKeyboard(settingsValues, toggleState) + || (settingsValues.mShowOnlyToolbarWithHardwareKeyboard && settingsValues.mHasHardwareKeyboard); + final int visibility = suppressKeyboard ? View.GONE : View.VISIBLE; final int stripVisibility = settingsValues.mToolbarMode == ToolbarMode.HIDDEN ? View.GONE : View.VISIBLE; mStripContainer.setVisibility(stripVisibility); PointerTracker.switchTo(mKeyboardView); diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index c642688db..96399c446 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -619,13 +619,13 @@ protected void onDetachedFromWindow() { @Nullable public PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, @NonNull final PointerTracker tracker) { - return showPopupKeysKeyboard(key, tracker, false, PopupKeysKeyboardView.NO_ROW_ALIGN); + return showPopupKeysKeyboard(key, tracker, false, PopupKeysKeyboardView.NO_ROW_ALIGN, 0); } @Nullable private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, @NonNull final PointerTracker tracker, final boolean belowSourceKey, - final int rowAlignedLeftX) { + final int rowAlignedLeftX, final int fixedKeyWidth) { final PopupKeySpec[] popupKeys = key.getPopupKeys(); if (popupKeys == null) { return null; @@ -646,7 +646,7 @@ private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, final PopupKeysKeyboard.Builder builder = new PopupKeysKeyboard.Builder( getContext(), key, getKeyboard(), isSinglePopupKeyWithPreview, mKeyPreviewDrawParams.getVisibleWidth(), - mKeyPreviewDrawParams.getVisibleHeight(), newLabelPaint(key)); + mKeyPreviewDrawParams.getVisibleHeight(), newLabelPaint(key), fixedKeyWidth); popupKeysKeyboard = builder.build(); mPopupKeysKeyboardCache.put(key, popupKeysKeyboard); } @@ -698,18 +698,26 @@ public PopupKeysPanel showShortcutRowKeyboard(@NonNull final Key key, if (popupParentKey == null) { return null; } - // Align the shortcut popup's icons to the source letter row so the swiped key maps to the - // icon directly above it, instead of re-anchoring on the swiped key (which the irregular - // shift/backspace keys at the row edges throw off). Row left = leftmost normal key in the row. + // Align the shortcut popup's icons across the source letter row so a swipe maps PROPORTIONALLY + // to the icon above it: the N icons tile the usable-keys row width (each = rowWidth/N), pinned + // to the row's left edge. This avoids re-anchoring on the swiped key (which the irregular + // shift/backspace keys at the row edges throw off). int rowLeftX = Integer.MAX_VALUE; + int rowRightX = Integer.MIN_VALUE; for (final Key rowKey : keyboard.getSortedKeys()) { if (rowKey.getY() == key.getY() && rowKey.getBackgroundType() == Key.BACKGROUND_TYPE_NORMAL && !rowKey.isModifier() && !rowKey.isSpacer()) { rowLeftX = Math.min(rowLeftX, rowKey.getX()); + rowRightX = Math.max(rowRightX, rowKey.getX() + rowKey.getWidth()); } } - if (rowLeftX == Integer.MAX_VALUE) rowLeftX = key.getX(); - return showPopupKeysKeyboard(popupParentKey, tracker, belowSourceKey, rowLeftX); + if (rowLeftX == Integer.MAX_VALUE) { + rowLeftX = key.getX(); + rowRightX = key.getX() + key.getWidth(); + } + final int iconCount = popupParentKey.getPopupKeys().length; + final int proportionalKeyWidth = iconCount > 0 ? (rowRightX - rowLeftX) / iconCount : 0; + return showPopupKeysKeyboard(popupParentKey, tracker, belowSourceKey, rowLeftX, proportionalKeyWidth); } public boolean isInDraggingFinger() { diff --git a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java index 645e420bb..3016f9337 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java @@ -261,7 +261,7 @@ public static class Builder extends KeyboardBuilder { */ public Builder(final Context context, final Key key, final Keyboard keyboard, final boolean isSinglePopupKeyWithPreview, final int keyPreviewVisibleWidth, - final int keyPreviewVisibleHeight, final Paint paintToMeasure) { + final int keyPreviewVisibleHeight, final Paint paintToMeasure, final int fixedKeyWidth) { super(context, new PopupKeysKeyboardParams()); mParams.mId = keyboard.mId; readAttributes(keyboard.mPopupKeysTemplate); @@ -283,6 +283,11 @@ public Builder(final Context context, final Key key, final Keyboard keyboard, // adjusted with their bottom paddings deducted. keyWidth = keyPreviewVisibleWidth; rowHeight = keyPreviewVisibleHeight + mParams.mVerticalGap; + } else if (fixedKeyWidth > 0) { + // Shortcut-row popup: force each icon to rowWidth/iconCount so the icons tile the + // usable-keys row and a swipe maps proportionally across it. + keyWidth = fixedKeyWidth; + rowHeight = keyboard.mMostCommonKeyHeight; } else { final float padding = context.getResources().getDimension( R.dimen.config_popup_keys_keyboard_key_horizontal_padding) @@ -292,7 +297,7 @@ public Builder(final Context context, final Key key, final Keyboard keyboard, rowHeight = keyboard.mMostCommonKeyHeight; } final int dividerWidth; - if (key.needsDividersInPopupKeys()) { + if (fixedKeyWidth <= 0 && key.needsDividersInPopupKeys()) { dividerWidth = (int)(keyWidth * DIVIDER_RATIO); } else { dividerWidth = 0; diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index ef5a2a54d..87460447d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -9,6 +9,8 @@ import android.view.MotionEvent; import android.view.LayoutInflater; import android.view.View; +import android.os.Handler; +import android.os.Looper; import android.widget.LinearLayout; import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; @@ -59,6 +61,29 @@ public interface TouchpadListener { private static final int SCROLL_THRESHOLD = 40; + // Edge-scroll acceleration constants (mirrors HeliBoard's TouchpadHandler) + private static final float EDGE_THRESHOLD_PERCENTAGE = 0.1f; + private static final float EDGE_ACCELERATION_FACTOR = 0.95f; + private static final int MIN_EDGE_ACCELERATION_DELAY = 20; + + // Edge-scroll state + private final Handler mEdgeScrollHandler = new Handler(Looper.getMainLooper()); + private boolean mIsEdgeScrolling = false; + private int mEdgeScrollDirection = KeyCode.UNSPECIFIED; + private long mEdgeScrollDelay = 200; + + private final Runnable mEdgeScrollRunnable = new Runnable() { + @Override + public void run() { + if (mIsEdgeScrolling && mListener != null) { + mListener.onCursorMove(mEdgeScrollDirection, mSelectionMode); + mEdgeScrollDelay = Math.max(MIN_EDGE_ACCELERATION_DELAY, + (long) (mEdgeScrollDelay * EDGE_ACCELERATION_FACTOR)); + mEdgeScrollHandler.postDelayed(this, mEdgeScrollDelay); + } + } + }; + public TouchpadView(Context context) { super(context); init(context); @@ -187,35 +212,46 @@ private void setupTouchSurface() { mScrollAccY += SCROLL_THRESHOLD; } } else if (mIsDragging && pointerCount == 1) { - float deltaX = event.getX() - mLastTouchX; - float deltaY = event.getY() - mLastTouchY; - mLastTouchX = event.getX(); - mLastTouchY = event.getY(); - - mAccX += deltaX; - mAccY += deltaY; - - int sensitivity = Settings.getValues().mTouchpadSensitivity; - // Base threshold is 110 for normal slow cursor, but 70 for fast selection - int baseThreshold = mSelectionMode ? 70 : 110; - int threshold = baseThreshold - (int) (sensitivity * 0.6f); - if (threshold < 10) threshold = 10; - - while (mAccX >= threshold) { - if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_RIGHT, mSelectionMode); - mAccX -= threshold; - } - while (mAccX <= -threshold) { - if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_LEFT, mSelectionMode); - mAccX += threshold; - } - while (mAccY >= threshold) { - if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_DOWN, mSelectionMode); - mAccY -= threshold; - } - while (mAccY <= -threshold) { - if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_UP, mSelectionMode); - mAccY += threshold; + float x = event.getX(); + float y = event.getY(); + float deltaX = x - mLastTouchX; + float deltaY = y - mLastTouchY; + mLastTouchX = x; + mLastTouchY = y; + + // Edge-scroll acceleration: when pref enabled and touch is near an edge, + // start a repeating accelerating cursor-move instead of normal tracking. + if (Settings.getValues().mTouchpadEdgeScroll && handleEdgeScrolling(x, y)) { + mAccX = 0; + mAccY = 0; + } else { + stopEdgeScrolling(); + + mAccX += deltaX; + mAccY += deltaY; + + int sensitivity = Settings.getValues().mTouchpadSensitivity; + // Base threshold is 110 for normal slow cursor, but 70 for fast selection + int baseThreshold = mSelectionMode ? 70 : 110; + int threshold = baseThreshold - (int) (sensitivity * 0.6f); + if (threshold < 10) threshold = 10; + + while (mAccX >= threshold) { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_RIGHT, mSelectionMode); + mAccX -= threshold; + } + while (mAccX <= -threshold) { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_LEFT, mSelectionMode); + mAccX += threshold; + } + while (mAccY >= threshold) { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_DOWN, mSelectionMode); + mAccY -= threshold; + } + while (mAccY <= -threshold) { + if (mListener != null) mListener.onCursorMove(KeyCode.ARROW_UP, mSelectionMode); + mAccY += threshold; + } } } return true; @@ -223,6 +259,7 @@ private void setupTouchSurface() { case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mIsDragging = false; + stopEdgeScrolling(); mIsTwoFingerScroll = false; mIsTwoFingerTap = false; if (mSelectionMode) { @@ -244,4 +281,41 @@ private void setupTouchSurface() { return true; }); } + + /** Returns true and starts/continues edge scrolling if (x, y) is within the edge threshold. */ + private boolean handleEdgeScrolling(float x, float y) { + int w = mTouchpadSurface.getWidth(); + int h = mTouchpadSurface.getHeight(); + int thresholdX = (int) (w * EDGE_THRESHOLD_PERCENTAGE); + int thresholdY = (int) (h * EDGE_THRESHOLD_PERCENTAGE); + + int direction; + if (y <= thresholdY) { + direction = KeyCode.ARROW_UP; + } else if (y >= h - thresholdY) { + direction = KeyCode.ARROW_DOWN; + } else if (x <= thresholdX) { + direction = KeyCode.ARROW_LEFT; + } else if (x >= w - thresholdX) { + direction = KeyCode.ARROW_RIGHT; + } else { + return false; + } + + if (mIsEdgeScrolling && mEdgeScrollDirection == direction) { + return true; // already running in this direction + } + stopEdgeScrolling(); + mEdgeScrollDirection = direction; + mIsEdgeScrolling = true; + int sensitivity = Settings.getValues().mTouchpadSensitivity; + mEdgeScrollDelay = 300L - (long) (sensitivity * 2.5f); + mEdgeScrollHandler.post(mEdgeScrollRunnable); + return true; + } + + private void stopEdgeScrolling() { + mIsEdgeScrolling = false; + mEdgeScrollHandler.removeCallbacks(mEdgeScrollRunnable); + } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPageKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPageKeyboardView.java index 45b85afc8..473e6a379 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPageKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPageKeyboardView.java @@ -201,7 +201,7 @@ private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key) { Keyboard popupKeysKeyboard = mPopupKeysKeyboardCache.get(key); if (popupKeysKeyboard == null) { final PopupKeysKeyboard.Builder builder = new PopupKeysKeyboard.Builder( - getContext(), key, getKeyboard(), false, 0, 0, newLabelPaint(key)); + getContext(), key, getKeyboard(), false, 0, 0, newLabelPaint(key), 0); popupKeysKeyboard = builder.build(); mPopupKeysKeyboardCache.put(key, popupKeysKeyboard); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 43e139e30..4fdc0600a 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -181,6 +181,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.PAGE_END -> R.drawable.ic_page_end ToolbarKey.JOIN_NEXT -> R.drawable.ic_close ToolbarKey.FORCE_NEXT_SPACE -> R.drawable.ic_plus // no rounded variant exists + ToolbarKey.UNDO_WORD -> R.drawable.ic_edit ToolbarKey.SPLIT -> R.drawable.ic_ime_switcher ToolbarKey.PROOFREAD -> R.drawable.ic_proofread ToolbarKey.TRANSLATE -> R.drawable.ic_translate @@ -262,6 +263,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.PAGE_END -> R.drawable.ic_page_end ToolbarKey.JOIN_NEXT -> R.drawable.ic_close ToolbarKey.FORCE_NEXT_SPACE -> R.drawable.ic_plus // no rounded variant exists + ToolbarKey.UNDO_WORD -> R.drawable.ic_edit ToolbarKey.SPLIT -> R.drawable.ic_ime_switcher ToolbarKey.PROOFREAD -> R.drawable.ic_proofread ToolbarKey.TRANSLATE -> R.drawable.ic_translate @@ -343,6 +345,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.PAGE_END -> R.drawable.ic_page_end_rounded ToolbarKey.JOIN_NEXT -> R.drawable.ic_close_rounded ToolbarKey.FORCE_NEXT_SPACE -> R.drawable.ic_plus + ToolbarKey.UNDO_WORD -> R.drawable.ic_edit // ic_edit has no rounded variant ToolbarKey.SPLIT -> R.drawable.ic_ime_switcher ToolbarKey.PROOFREAD -> R.drawable.ic_proofread_rounded ToolbarKey.TRANSLATE -> R.drawable.ic_translate_rounded diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index 943896739..82dbcf2b7 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -96,6 +96,7 @@ object KeyCode { const val TOGGLE_FORCE_AUTO_CAP = -248 const val JOIN_NEXT = -249 const val FORCE_NEXT_SPACE = -250 + const val UNDO_WORD = -251 const val URI_COMPONENT_TLD = -255 @@ -212,7 +213,7 @@ object KeyCode { VOICE_INPUT, LANGUAGE_SWITCH, SETTINGS, DELETE, ALPHA, SYMBOL, EMOJI, CLIPBOARD, CLIPBOARD_CUT, UNDO, REDO, ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT, CLIPBOARD_COPY, CLIPBOARD_PASTE, CLIPBOARD_SELECT_ALL, CLIPBOARD_SELECT_WORD, TOGGLE_INCOGNITO_MODE, TOGGLE_AUTOCORRECT, TOGGLE_AUTOSPACE, - TOGGLE_AUTO_CAP, TOGGLE_FORCE_AUTO_CAP, JOIN_NEXT, FORCE_NEXT_SPACE, FORWARD_DELETE, MOVE_START_OF_LINE, MOVE_END_OF_LINE, + TOGGLE_AUTO_CAP, TOGGLE_FORCE_AUTO_CAP, JOIN_NEXT, FORCE_NEXT_SPACE, UNDO_WORD, FORWARD_DELETE, MOVE_START_OF_LINE, MOVE_END_OF_LINE, MOVE_START_OF_PAGE, MOVE_END_OF_PAGE, SHIFT, CAPS_LOCK, MULTIPLE_CODE_POINTS, UNSPECIFIED, CTRL, ALT, FN, CLIPBOARD_CLEAR_HISTORY, NUMPAD, IME_HIDE_UI, CTRL_LOCK, ALT_LOCK, FN_LOCK, diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index bea3a62a9..a6195d678 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -564,6 +564,11 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { includeAtLeastTwoWordSuggestions(suggestionResults, suggestionsArray, composedData.mTypedWord) + // Graduated trust (#39): runs LAST (after session boost) so it can't be undone, and caps + // rather than scales so the guarantee holds regardless of native score magnitudes. + if (Settings.getValues().mGraduatedTrust) + applyGraduatedTrust(suggestionResults) + return suggestionResults } @@ -640,6 +645,42 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } + /** + * Graduated trust (#39): cap an uncurated learned word (one in no real dictionary) just below the + * best real-dictionary candidate until it has been confirmed by enough repetitions. This + * guarantees a single misfire can't out-rank a real word with better geometry, while a + * deliberately repeated new word still learns and, once confirmed, keeps its full score. Runs + * after session boost so it can't be undone, and only when a real candidate exists to protect. + */ + private fun applyGraduatedTrust(results: SuggestionResults) { + var maxRealScore = Int.MIN_VALUE + for (info in results) { + when (info.mSourceDict?.mDictType) { + Dictionary.TYPE_MAIN, Dictionary.TYPE_CONTACTS, Dictionary.TYPE_APPS, Dictionary.TYPE_USER -> + if (info.mScore > maxRealScore) maxRealScore = info.mScore + } + } + if (maxRealScore == Int.MIN_VALUE) return // no real word to protect — keep new words offerable + + val toRemove = mutableListOf() + val capped = mutableListOf() + for (info in results) { + if (info.mSourceDict?.mDictType != Dictionary.TYPE_USER_HISTORY) continue + if (info.mScore < maxRealScore) continue // already ranked below a real word + val word = info.mWord + if (word.length <= 1) continue + if (dictionaryGroups.any { it.isInNonHistoryDictionary(word) }) continue // curated, trust it + val freq = dictionaryGroups.maxOf { it.getSubDict(Dictionary.TYPE_USER_HISTORY)?.getFrequency(word) ?: -1 } + if (!shouldPenalizeUnconfirmedWord(true, freq)) continue + toRemove.add(info) + capped.add(SuggestedWordInfo(info.mWord, info.mPrevWordsContext, maxRealScore - 1, + info.mKindAndFlags, info.mSourceDict, info.mIndexOfTouchPointOfSecondWord, + info.mAutoCommitFirstWordConfidence)) + } + for (item in toRemove) results.remove(item) + for (item in capped) results.add(item) + } + // Spell checker is using this, and has its own instance of DictionaryFacilitatorImpl, // meaning that it always has default mConfidence. So we cannot choose to only check preferred // locale, and instead simply return true if word is in any of the available dictionaries @@ -726,6 +767,18 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // Multiplier to convert session boost values into score-space (native scores are ~1_000_000) private const val BOOST_SCORE_MULTIPLIER = 1000f + // Graduated trust (#39): a non-dictionary learned word (one not in main/contacts/apps/the + // user's personal dict) that has not yet been confirmed by enough repetitions must not + // out-rank a real dictionary word with better geometry. Until then it is capped just below + // the best real candidate (see applyGraduatedTrust); once repeated past the threshold it + // keeps full score, so deliberate new words still get learned (slowly). History frequency + // rises with use (~111 after 2 uses, ~120 after 3), so 120 ≈ 3 confirmations. Tunable. + private const val GRAD_TRUST_CONFIRM_FREQUENCY = 120 + + /** Graduated-trust decision (#39): penalize an uncurated learned word until it is confirmed. */ + fun shouldPenalizeUnconfirmedWord(isUncurated: Boolean, historyFrequency: Int): Boolean = + isUncurated && historyFrequency < GRAD_TRUST_CONFIRM_FREQUENCY + private fun createSubDict( dictType: String, context: Context, locale: Locale, dictFile: File?, dictNamePrefix: String ): ExpandableBinaryDictionary? { diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index cec4c8537..a670093a7 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1799,6 +1799,11 @@ public void removeExternalSuggestions() { mHandler.postResumeSuggestions(false); } + @Override + public void onSwipeDownOnToolbar() { + requestHideSelf(0); + } + private void loadKeyboard() { // Since we are switching languages, the most urgent thing is to let the // keyboard graphics diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/BackspaceUnitStack.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/BackspaceUnitStack.java new file mode 100644 index 000000000..52d05e702 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/BackspaceUnitStack.java @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.inputlogic; + +import java.util.ArrayList; +import java.util.List; + +/** + * One revertible input-unit stack for backspace (issue #31). + * + *

Consolidates the length bookkeeping that drives fragment- and whole-word-backspace, which + * was previously three separate fields scattered across {@link InputLogic}: + *

    + *
  • Composing side — the cumulative fragment boundaries of the active + * composing word (one strictly-increasing entry per recorded gesture/tap fragment).
  • + *
  • Committed side — the total length and per-fragment lengths of the last + * committed gesture word, so a backspace right after commit can pop the last fragment + * or delete the whole word.
  • + *
+ * + *

This class owns only the unit-length bookkeeping. The editor side effects (composing-text + * updates, {@code deleteTextBeforeCursor}, stats) and the policy decisions (when to record/pop) + * stay in {@link InputLogic}. Behaviour is identical to the pre-extraction code; the value is a + * single, separately unit-testable home for the corruption-prone boundary math. + */ +final class BackspaceUnitStack { + + /** Composing side: cumulative fragment boundaries (strictly increasing word lengths). */ + private final ArrayList mComposingBoundaries = new ArrayList<>(); + + /** Committed side: total length of the last committed gesture word (0 = none / tap-only). */ + private int mCommittedLength; + /** Committed side: per-fragment lengths of the last committed gesture word. */ + private final ArrayList mCommittedFragmentLengths = new ArrayList<>(); + + // ===== composing side ===== + + boolean hasComposingBoundaries() { + return !mComposingBoundaries.isEmpty(); + } + + /** + * Record a fragment boundary at the given composing-word length. No-op for a non-positive + * length or a duplicate of the current top (the same fragment appended twice in quick + * succession). + */ + void recordComposingBoundary(final int len) { + if (len <= 0) return; + if (!mComposingBoundaries.isEmpty() + && mComposingBoundaries.get(mComposingBoundaries.size() - 1) == len) { + return; + } + mComposingBoundaries.add(len); + } + + /** Drop all composing boundaries. Call after committing / resetting the composing word. */ + void clearComposing() { + if (!mComposingBoundaries.isEmpty()) mComposingBoundaries.clear(); + } + + /** + * Pop the most-recent composing fragment, given the current composing-word length. + * + *

Stale boundaries past {@code currentLen} are trimmed first. Returns the new word length + * the composing word should shrink to: the previous boundary (or {@code 0} for a + * single-fragment word) when the top marker is the current fragment end, or the top boundary + * itself as a defensive fallback when the current fragment end was never recorded. Returns + * {@code -1} when there is no fragment to pop (caller should fall through to char-delete). + */ + int popComposingFragment(final int currentLen) { + if (mComposingBoundaries.isEmpty()) return -1; + while (!mComposingBoundaries.isEmpty() + && mComposingBoundaries.get(mComposingBoundaries.size() - 1) > currentLen) { + mComposingBoundaries.remove(mComposingBoundaries.size() - 1); + } + if (mComposingBoundaries.isEmpty()) return -1; + final int lastBoundary = mComposingBoundaries.get(mComposingBoundaries.size() - 1); + if (lastBoundary == currentLen) { + // Top marker is the end of the current fragment: pop it and shrink to the previous + // marker, or to 0 for a single-fragment word. + mComposingBoundaries.remove(mComposingBoundaries.size() - 1); + return mComposingBoundaries.isEmpty() + ? 0 + : mComposingBoundaries.get(mComposingBoundaries.size() - 1); + } + // Defensive fallback for words whose current fragment end was not recorded. + return lastBoundary; + } + + /** + * Build the per-fragment lengths (deltas) for a composing word of {@code currentLen} that is + * about to be committed: a delta per in-range boundary, plus a trailing fragment for any tail + * past the last boundary. + */ + ArrayList fragmentLengthsForCommit(final int currentLen) { + final ArrayList fragmentLengths = new ArrayList<>(); + if (currentLen <= 0) return fragmentLengths; + int previousBoundary = 0; + for (int i = 0; i < mComposingBoundaries.size(); ++i) { + final int boundary = mComposingBoundaries.get(i); + if (boundary <= previousBoundary || boundary > currentLen) continue; + fragmentLengths.add(boundary - previousBoundary); + previousBoundary = boundary; + } + if (previousBoundary < currentLen) { + fragmentLengths.add(currentLen - previousBoundary); + } + return fragmentLengths; + } + + // ===== committed side ===== + + int committedLength() { + return mCommittedLength; + } + + /** A defensive copy of the committed fragment lengths (snapshot before a pop). */ + ArrayList copyCommittedFragmentLengths() { + return new ArrayList<>(mCommittedFragmentLengths); + } + + /** Replace the committed gesture word: its total length and per-fragment lengths. */ + void setCommitted(final int length, final List fragmentLengths) { + mCommittedLength = length; + mCommittedFragmentLengths.clear(); + mCommittedFragmentLengths.addAll(fragmentLengths); + } + + /** Replace just the committed fragment lengths (after popping one fragment off the top). */ + void setCommittedFragmentLengths(final List fragmentLengths) { + mCommittedFragmentLengths.clear(); + mCommittedFragmentLengths.addAll(fragmentLengths); + } + + /** Reset all committed-gesture state (total length + fragment lengths). */ + void clearCommitted() { + mCommittedLength = 0; + if (!mCommittedFragmentLengths.isEmpty()) mCommittedFragmentLengths.clear(); + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 975f571a8..b4cec9e3e 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -127,18 +127,11 @@ public final class InputLogic { * because PHANTOM would make the next letter ALSO write a space, double-spacing. */ private boolean mAutospaceJustWritten; - // Two-thumb typing (#1.1, sub-option PREF_GESTURE_FRAGMENT_BACKSPACE): char-offsets that - // mark the END of each "fragment" appended to the current composing word under manual - // spacing. A fragment is one gesture's output OR one tap's letter. With the pref on, - // backspace pops the most recent fragment in one keystroke instead of deleting one - // character at a time. The list is kept in sync with {@code mWordComposer.getTypedWord()}: - // entries past the current length are filtered at read time, and the list is cleared - // outright whenever the composing word is committed or reset. - private final ArrayList mGestureFragmentBoundaries = new ArrayList<>(); - /** Fragment lengths within the most recent gesture-driven commit. The last entry includes - * the trailing autospace if one was inserted. Used by "Delete last fragment" after the - * composing word has already been auto-committed. */ - private final ArrayList mLastGestureCommittedFragmentLengths = new ArrayList<>(); + // Backspace input-unit stack (#31): the single home for the fragment- / whole-word-backspace + // length bookkeeping. Owns both the active composing word's fragment boundaries (one entry + // per recorded gesture/tap fragment, kept in sync with mWordComposer.getTypedWord()) and the + // last committed gesture word's total + per-fragment lengths. See BackspaceUnitStack. + private final BackspaceUnitStack mBackspaceUnits = new BackspaceUnitStack(); // ---- Unified combining-mode state machine ---------------------------------------------- // After every composing-word-extending event (tap of a letter OR gesture completion), @@ -956,25 +949,16 @@ private void recordFragmentBoundaryIfTracking(final SettingsValues sv) { /** Record a fragment boundary at a known composing-word length. */ private void recordFragmentBoundaryIfTracking(final SettingsValues sv, final int len) { if (!shouldTrackFragmentBoundaries(sv)) return; - if (len <= 0) return; - // Don't record duplicates (e.g. the same fragment appended twice in quick succession). - if (!mGestureFragmentBoundaries.isEmpty() - && mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1) == len) { - return; - } - mGestureFragmentBoundaries.add(len); + mBackspaceUnits.recordComposingBoundary(len); } /** Clear all recorded fragment boundaries. Call after committing / resetting the composing word. */ private void clearFragmentBoundaries() { - if (!mGestureFragmentBoundaries.isEmpty()) mGestureFragmentBoundaries.clear(); + mBackspaceUnits.clearComposing(); } private void clearCommittedGestureBackspaceState() { - mLastGestureCommittedLength = 0; - if (!mLastGestureCommittedFragmentLengths.isEmpty()) { - mLastGestureCommittedFragmentLengths.clear(); - } + mBackspaceUnits.clearCommitted(); } // ---- Unified combining-mode helpers -------------------------------------------------- @@ -1047,6 +1031,53 @@ private void enterJoinNextMode(final SettingsValues settingsValues) { } } + /** + * Handles the UNDO_WORD toolbar action: re-opens the last word for re-selection, showing its + * alternatives (the gesture/autocorrect candidates) so a different one can be picked. Most useful + * AFTER you've pressed space to move on — the strip already shows a word's alternatives right + * after it is committed. + *

+ * The candidates live in the committed text's {@link android.text.style.SuggestionSpan}s in the + * EDITOR ({@code mLastComposedWord.mCommittedWord} is a plain string without them). So we move the + * cursor to the end of the last word before the cursor (skipping a trailing space) and post a + * resume — the standard recorrection path then re-acquires that word and its editor spans once + * the cursor has settled. Non-destructive: no text is deleted. No-ops if there is no word before + * the cursor. + * + * @param inputTransaction The transaction in progress. + */ + private void handleUndoWord(final InputTransaction inputTransaction) { + final SettingsValues settingsValues = inputTransaction.getSettingsValues(); + if (!settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces + || !settingsValues.needsToLookupSuggestions() + || mInputLogicHandler.isInBatchInput() + || mConnection.hasSelection()) { + return; + } + final int cursor = mConnection.getExpectedSelectionStart(); + if (cursor <= 0) return; + // Find the end of the last word before the cursor, skipping a trailing separator (the space + // you typed to move on; after a gesture it may be a pending phantom space that is absent). + final CharSequence before = mConnection.getTextBeforeCursor(48, 0); + if (TextUtils.isEmpty(before)) return; + int trailing = 0; + while (before.length() - trailing > 0 + && Character.isWhitespace(before.charAt(before.length() - 1 - trailing))) { + trailing++; + } + if (before.length() - trailing == 0) return; // only whitespace before the cursor + final int wordEnd = cursor - trailing; + // Move the cursor into that word, then resume suggestions for it once the cursor settles. + // restartSuggestionsOnWordTouchedByCursor reads the word's editor SuggestionSpans — the real + // gesture/autocorrect candidates — and shows them in the strip. + if (wordEnd != cursor) { + mConnection.setSelection(wordEnd, wordEnd); + } + mSpaceState = SpaceState.NONE; + mLatinIME.mHandler.postResumeSuggestions(true /* shouldDelay */); + } + + private void resumeWordAtCursorForJoining(final SettingsValues settingsValues) { final TextRange range = mConnection.getWordRangeAtCursor(settingsValues.mSpacingAndPunctuations, settingsValues.mCurrentKeyboardScript); @@ -1130,13 +1161,6 @@ public boolean isInCombiningMode() { * Cleared on any new input that arms combining mode, on cancel, on the next space tap, * and after a suggestion-pick. */ private int mAutoCommitRevertLength; - /** Length of the most recent gesture-driven commit (typed word + autospace). Set in - * {@link #onCombiningGraceExpired} when {@code mWordComposer.isBatchMode()} was true at - * commit time. Consumed by the first backspace tap when - * {@code PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD} is on, deleting the whole word - * + space in one keystroke (unless an autocorrect-revert applies first — that always - * wins). Cleared on any new input that arms combining mode. */ - private int mLastGestureCommittedLength; /** Set in {@link #onPickSuggestionManually} when the picker reverted an auto-committed * word; consumed at the bottom of the same method to insert a visible trailing space so * the cursor lands at "the |" instead of "the|". */ @@ -1253,8 +1277,7 @@ private void onCombiningGraceExpired() { // arm "first backspace deletes the whole word" — see handleBackspaceEvent. Stays // 0 for tap-only commits, where char-by-char delete is the right behavior. if (wordHadGestureFragment) { - mLastGestureCommittedLength = writtenChars; - mLastGestureCommittedFragmentLengths.clear(); + final ArrayList committedFragments = new ArrayList<>(); if (!fragmentLengthsAtCommit.isEmpty()) { final int committedDelta = committedLen - typedWordAtCommit.length(); final int lastIndex = fragmentLengthsAtCommit.size() - 1; @@ -1262,13 +1285,14 @@ private void onCombiningGraceExpired() { fragmentLengthsAtCommit.get(lastIndex) + committedDelta + autospaceChars; if (adjustedLastFragmentLen > 0) { fragmentLengthsAtCommit.set(lastIndex, adjustedLastFragmentLen); - mLastGestureCommittedFragmentLengths.addAll(fragmentLengthsAtCommit); + committedFragments.addAll(fragmentLengthsAtCommit); } else if (writtenChars > 0) { - mLastGestureCommittedFragmentLengths.add(writtenChars); + committedFragments.add(writtenChars); } } else if (writtenChars > 0) { - mLastGestureCommittedFragmentLengths.add(writtenChars); + committedFragments.add(writtenChars); } + mBackspaceUnits.setCommitted(writtenChars, committedFragments); } // "keep_alternatives" — fall through, do nothing. } @@ -1290,7 +1314,7 @@ private boolean tryFragmentBackspace(final SettingsValues sv) { && sv.mGestureFragmentBackspace; if (!legacyTracking && !multipartTracking) return false; if (sv.mCombiningBackspaceDeletesGestureWord) return false; - if (mGestureFragmentBoundaries.isEmpty()) return false; + if (!mBackspaceUnits.hasComposingBoundaries()) return false; if (!mWordComposer.isComposingWord()) { clearFragmentBoundaries(); return false; @@ -1298,26 +1322,8 @@ private boolean tryFragmentBackspace(final SettingsValues sv) { if (mWordComposer.isCursorFrontOrMiddleOfComposingWord()) return false; final int currentLen = mWordComposer.getTypedWord().length(); - // Filter out stale boundaries past the current length. - while (!mGestureFragmentBoundaries.isEmpty() - && mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1) > currentLen) { - mGestureFragmentBoundaries.remove(mGestureFragmentBoundaries.size() - 1); - } - if (mGestureFragmentBoundaries.isEmpty()) return false; - - final int lastBoundary = mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1); - final int newLen; - if (lastBoundary == currentLen) { - // The last marker is the end of the current fragment. Pop it and shrink to the - // previous marker, or to 0 for a single-fragment word. - mGestureFragmentBoundaries.remove(mGestureFragmentBoundaries.size() - 1); - newLen = mGestureFragmentBoundaries.isEmpty() - ? 0 - : mGestureFragmentBoundaries.get(mGestureFragmentBoundaries.size() - 1); - } else { - // Defensive fallback for words whose current fragment end was not recorded. - newLen = lastBoundary; - } + final int newLen = mBackspaceUnits.popComposingFragment(currentLen); + if (newLen < 0) return false; final String oldWord = mWordComposer.getTypedWord(); final String newWord = newLen <= 0 @@ -1345,19 +1351,7 @@ private boolean tryFragmentBackspace(final SettingsValues sv) { } private ArrayList getFragmentLengthsForCommit(final int currentLen) { - final ArrayList fragmentLengths = new ArrayList<>(); - if (currentLen <= 0) return fragmentLengths; - int previousBoundary = 0; - for (int i = 0; i < mGestureFragmentBoundaries.size(); ++i) { - final int boundary = mGestureFragmentBoundaries.get(i); - if (boundary <= previousBoundary || boundary > currentLen) continue; - fragmentLengths.add(boundary - previousBoundary); - previousBoundary = boundary; - } - if (previousBoundary < currentLen) { - fragmentLengths.add(currentLen - previousBoundary); - } - return fragmentLengths; + return mBackspaceUnits.fragmentLengthsForCommit(currentLen); } // TODO: on the long term, this method should become private, but it will be @@ -1666,6 +1660,9 @@ private void handleFunctionalEvent(final Event event, final InputTransaction inp case KeyCode.FORCE_NEXT_SPACE: forceSpaceBeforeNextWord(event, inputTransaction, handler); break; + case KeyCode.UNDO_WORD: + handleUndoWord(inputTransaction); + break; case KeyCode.LANGUAGE_SWITCH: handleLanguageSwitchKey(); break; @@ -2358,9 +2355,9 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu // Combining mode: snapshot the gesture-word-length flag BEFORE cancelCombiningMode // clears it. If non-zero (the previous commit was a gesture), this backspace MIGHT // delete the whole word — see further down, after the autocorrect-revert branch. - final int gestureCommittedLen = mLastGestureCommittedLength; + final int gestureCommittedLen = mBackspaceUnits.committedLength(); final ArrayList gestureCommittedFragmentLengths = - new ArrayList<>(mLastGestureCommittedFragmentLengths); + mBackspaceUnits.copyCommittedFragmentLengths(); // Combining mode: a backspace always cancels the pending commit. The user is // explicitly retracting input; we don't want the timer to fire mid-correction. cancelCombiningMode(); @@ -2528,8 +2525,7 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu mConnection.beginBatchEdit(); mConnection.deleteTextBeforeCursor(gestureCommittedFragmentLen); mConnection.endBatchEdit(); - mLastGestureCommittedFragmentLengths.clear(); - mLastGestureCommittedFragmentLengths.addAll(gestureCommittedFragmentLengths); + mBackspaceUnits.setCommittedFragmentLengths(gestureCommittedFragmentLengths); StatsUtils.onBackspaceWordDelete(gestureCommittedFragmentLen); inputTransaction.setRequiresUpdateSuggestions(); return; @@ -3908,6 +3904,16 @@ public void onUpdateTailBatchInputCompleted(final SettingsValues settingsValues, // commit later. mShiftModeAtGestureStart = WordComposer.CAPS_MODE_OFF; final String composedText = prevTypedWord + batchInputText; + // Trace recorder (A3a): capture gesture trace + committed word for replay debugging. + // At this point composedText is the full word, mWordComposer.getInputPointers() holds + // the gesture trail (merged if multi-part), and the keyboard geometry is available. + if (settingsValues.mRecordInputTraces) { + helium314.keyboard.latin.utils.TraceRecorder.INSTANCE.record( + mLatinIME, + mWordComposer.getInputPointers(), + composedText, + keyboardSwitcher.getKeyboard()); + } if (settingsValues.mGestureDebugDrawPoints) { Log.d(TAG, "batch composed='" + composedText + "'"); } diff --git a/app/src/main/java/helium314/keyboard/latin/settings/DebugSettings.java b/app/src/main/java/helium314/keyboard/latin/settings/DebugSettings.java index 00db92ee7..c8a5e5bfc 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/DebugSettings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/DebugSettings.java @@ -17,6 +17,7 @@ public final class DebugSettings { public static final String PREF_KEY_DUMP_DICT_PREFIX = "dump_dictionaries"; public static final String PREF_SHOW_SUGGESTION_INFOS = "show_suggestion_infos"; + public static final String PREF_RECORD_INPUT_TRACES = "record_input_traces"; private DebugSettings() { // This class is not publicly instantiable. } diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index b8d13107f..280eade97 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -180,6 +180,7 @@ object Defaults { const val PREF_SPACE_TO_CHANGE_LANG = true const val PREF_LANGUAGE_SWIPE_DISTANCE = 5 const val PREF_TOUCHPAD_SENSITIVITY = 50 + const val PREF_TOUCHPAD_EDGE_SCROLL = false const val PREF_FORCE_AUTO_CAPS = false const val PREF_OFFLINE_TEMP = 0.1f // Lower for faster, more deterministic proofreading const val PREF_OFFLINE_TOP_P = 0.5f // Lower for faster token sampling @@ -194,6 +195,7 @@ object Defaults { const val PREF_CLEAR_CLIPBOARD_ICON = "bin" const val PREF_ADD_TO_PERSONAL_DICTIONARY = true const val PREF_FLAG_UNKNOWN_WORDS = true + const val PREF_GRADUATED_TRUST = true @JvmField val PREF_NAVBAR_COLOR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q const val PREF_NARROW_KEY_GAPS = true @@ -213,6 +215,8 @@ object Defaults { const val PREF_AUTO_HIDE_TOOLBAR = true const val PREF_AUTO_HIDE_PINNED_KEYS = true const val PREF_REMEMBER_TOOLBAR_STATE = false + const val PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE = false + const val PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD = false const val PREF_TOOLBAR_EXPANDED = false val PREF_CLIPBOARD_TOOLBAR_KEYS = defaultClipboardToolbarPref const val PREF_ABC_AFTER_EMOJI = false @@ -229,6 +233,7 @@ object Defaults { const val PREF_SHOW_SUGGESTION_INFOS = false const val PREF_FORCE_NON_DISTINCT_MULTITOUCH = false const val PREF_SLIDING_KEY_INPUT_PREVIEW = true + const val PREF_RECORD_INPUT_TRACES = false const val PREF_USER_COLORS = "[]" const val PREF_USER_MORE_COLORS = 0 const val PREF_USER_ALL_COLORS = "" diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index d3836063e..664ea8194 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -209,6 +209,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_SPACE_TO_CHANGE_LANG = "prefs_long_press_keyboard_to_change_lang"; public static final String PREF_LANGUAGE_SWIPE_DISTANCE = "language_swipe_distance"; public static final String PREF_TOUCHPAD_SENSITIVITY = "touchpad_sensitivity"; + public static final String PREF_TOUCHPAD_EDGE_SCROLL = "touchpad_edge_scroll"; public static final String PREF_PERSIST_FLOATING_KEYBOARD = "persist_floating_keyboard"; public static final String PREF_FORCE_AUTO_CAPS = "force_auto_caps"; public static final String PREF_OFFLINE_TEMP = "offline_temp"; @@ -228,6 +229,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_ADD_TO_PERSONAL_DICTIONARY = "add_to_personal_dictionary"; public static final String PREF_FLAG_UNKNOWN_WORDS = "flag_unknown_words"; + public static final String PREF_GRADUATED_TRUST = "graduated_trust"; public static final String PREF_NAVBAR_COLOR = "navbar_color"; public static final String PREF_NARROW_KEY_GAPS = "narrow_key_gaps"; public static final String PREF_NARROW_KEY_GAPS_LEVEL = "narrow_key_gaps_level"; @@ -256,6 +258,8 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_TOOLBAR_MODE = "toolbar_mode"; public static final String PREF_TOOLBAR_HIDING_GLOBAL = "toolbar_hiding_global"; public static final String PREF_SPLIT_TOOLBAR = "split_toolbar"; + public static final String PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE = "toolbar_swipe_down_to_hide"; + public static final String PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD = "only_toolbar_with_hw_keyboard"; // Emoji public static final String PREF_EMOJI_MAX_SDK = "emoji_max_sdk"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 6181ac818..72a9c257c 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -82,6 +82,7 @@ public class SettingsValues { public final int mSpaceSwipeVertical; public final int mLanguageSwipeDistance; public final int mTouchpadSensitivity; + public final boolean mTouchpadEdgeScroll; public final boolean mForceAutoCaps; public final boolean mDeleteSwipeEnabled; public final boolean mShortcutRowsEnabled; @@ -146,6 +147,7 @@ public class SettingsValues { public final boolean mMultipartTapSeedGesture; public final boolean mMultipartRerecognizeTaps; public final boolean mSlidingKeyInputPreviewEnabled; + public final boolean mRecordInputTraces; public final int mKeyLongpressTimeout; public final boolean mEnableEmojiAltPhysicalKey; public final boolean mIsSplitKeyboardEnabled; @@ -154,6 +156,7 @@ public class SettingsValues { public final int mScreenMetrics; public final boolean mAddToPersonalDictionary; public final boolean mFlagUnknownWords; + public final boolean mGraduatedTrust; public final boolean mUseContactsDictionary; public final boolean mUseAppsDictionary; public final boolean mCustomNavBarColor; @@ -169,6 +172,8 @@ public class SettingsValues { public final boolean mAutoHideToolbar; public final boolean mAutoHidePinnedKeys; public final boolean mRememberToolbarState; + public final boolean mToolbarSwipeDownToHide; + public final boolean mShowOnlyToolbarWithHardwareKeyboard; public final boolean mAlphaAfterEmojiInEmojiView; public final boolean mAlphaAfterClipHistoryEntry; public final boolean mAlphaAfterSymbolAndSpace; @@ -239,6 +244,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina mKeyPreviewPopupOn = prefs.getBoolean(Settings.PREF_POPUP_ON, Defaults.PREF_POPUP_ON); mSlidingKeyInputPreviewEnabled = prefs.getBoolean( DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW, Defaults.PREF_SLIDING_KEY_INPUT_PREVIEW); + mRecordInputTraces = prefs.getBoolean( + DebugSettings.PREF_RECORD_INPUT_TRACES, Defaults.PREF_RECORD_INPUT_TRACES); mShowsVoiceInputKey = mInputAttributes.mShouldShowVoiceInputKey; final String languagePref = prefs.getString(Settings.PREF_LANGUAGE_SWITCH_KEY, Defaults.PREF_LANGUAGE_SWITCH_KEY); @@ -422,6 +429,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_LANGUAGE_SWIPE_DISTANCE); mTouchpadSensitivity = prefs.getInt(Settings.PREF_TOUCHPAD_SENSITIVITY, Defaults.PREF_TOUCHPAD_SENSITIVITY); + mTouchpadEdgeScroll = prefs.getBoolean(Settings.PREF_TOUCHPAD_EDGE_SCROLL, + Defaults.PREF_TOUCHPAD_EDGE_SCROLL); mForceAutoCaps = prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS); mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE); mShortcutRowsEnabled = prefs.getBoolean(Settings.PREF_SHORTCUT_ROWS, Defaults.PREF_SHORTCUT_ROWS); @@ -471,6 +480,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_ADD_TO_PERSONAL_DICTIONARY); mFlagUnknownWords = prefs.getBoolean(Settings.PREF_FLAG_UNKNOWN_WORDS, Defaults.PREF_FLAG_UNKNOWN_WORDS); + mGraduatedTrust = prefs.getBoolean(Settings.PREF_GRADUATED_TRUST, + Defaults.PREF_GRADUATED_TRUST); mUseContactsDictionary = SettingsValues.readUseContactsEnabled(prefs, context); mUseAppsDictionary = prefs.getBoolean(Settings.PREF_USE_APPS, Defaults.PREF_USE_APPS); mCustomNavBarColor = prefs.getBoolean(Settings.PREF_NAVBAR_COLOR, Defaults.PREF_NAVBAR_COLOR); @@ -499,6 +510,11 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina && prefs.getBoolean(Settings.PREF_AUTO_HIDE_PINNED_KEYS, Defaults.PREF_AUTO_HIDE_PINNED_KEYS); mRememberToolbarState = prefs.getBoolean(Settings.PREF_REMEMBER_TOOLBAR_STATE, Defaults.PREF_REMEMBER_TOOLBAR_STATE); + mToolbarSwipeDownToHide = prefs.getBoolean(Settings.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE, + Defaults.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE); + mShowOnlyToolbarWithHardwareKeyboard = prefs.getBoolean( + Settings.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD, + Defaults.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD); // Migration: clear any old saved value and reset to default if (!prefs.contains(Settings.PREF_AUTO_HIDE_PINNED_KEYS)) { prefs.edit().putBoolean(Settings.PREF_AUTO_HIDE_PINNED_KEYS, Defaults.PREF_AUTO_HIDE_PINNED_KEYS).apply(); diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index 6a20dd4ca..95e9ea8fd 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -66,6 +66,7 @@ import helium314.keyboard.latin.utils.setToolbarButtonsActivatedState import helium314.keyboard.latin.utils.setToolbarButtonsActivatedStateOnPrefChange import helium314.keyboard.settings.SettingsWithoutKey import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.abs import kotlin.math.min @SuppressLint("InflateParams") @@ -82,6 +83,7 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) fun removeExternalSuggestions() fun addToDictionary(word: String) fun blockWord(word: String) + fun onSwipeDownOnToolbar() } private val moreSuggestionsContainer: View @@ -293,6 +295,11 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) override fun onScroll(down: MotionEvent?, me: MotionEvent, deltaX: Float, deltaY: Float): Boolean { if (down == null) return false val dy = me.y - down.y + val dx = me.x - down.x + if (Settings.getValues().mToolbarSwipeDownToHide && dy > 50.dpToPx(resources) && abs(dy) > abs(dx)) { + listener.onSwipeDownOnToolbar() + return true + } return if (toolbarContainer.visibility != VISIBLE && deltaY > 0 && dy < (-10).dpToPx(resources)) showMoreSuggestions() else false } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 24d55f6f1..de7de8c46 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -281,6 +281,7 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool FORCE_AUTO_CAP -> KeyCode.TOGGLE_FORCE_AUTO_CAP JOIN_NEXT -> KeyCode.JOIN_NEXT FORCE_NEXT_SPACE -> KeyCode.FORCE_NEXT_SPACE + UNDO_WORD -> KeyCode.UNDO_WORD CLEAR_CLIPBOARD -> KeyCode.CLIPBOARD_CLEAR_HISTORY CLOSE_HISTORY -> KeyCode.ALPHA EMOJI -> KeyCode.EMOJI @@ -335,7 +336,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC enum class ToolbarKey { VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, INCOGNITO, TOUCHPAD, AUTOCORRECT, AUTOSPACE, AUTO_CAP, FORCE_AUTO_CAP, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, - PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, JOIN_NEXT, FORCE_NEXT_SPACE, PROOFREAD, TRANSLATE, + PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, JOIN_NEXT, FORCE_NEXT_SPACE, UNDO_WORD, PROOFREAD, TRANSLATE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10 } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TraceRecorder.kt b/app/src/main/java/helium314/keyboard/latin/utils/TraceRecorder.kt new file mode 100644 index 000000000..a160e4397 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/TraceRecorder.kt @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import android.content.Context +import helium314.keyboard.keyboard.Keyboard +import helium314.keyboard.latin.common.Constants.Subtype.ExtraValue.KEYBOARD_LAYOUT_SET +import helium314.keyboard.latin.common.InputPointers +import java.io.File +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Debug recorder for gesture input sessions. + * + * When [helium314.keyboard.latin.settings.DebugSettings.PREF_RECORD_INPUT_TRACES] is enabled, + * each completed gesture/batch-input session is written to a JSON file under + * `/input_traces/trace-.json`. + * + * All file I/O runs on a single-threaded background executor so the input path is never blocked. + * + * ## JSON schema (version 1) + * ```json + * { + * "version": 1, + * "createdAt": , + * "keyboard": { + * "width": , // Keyboard.mOccupiedWidth + * "height": , // Keyboard.mOccupiedHeight + * "mainLayout": "", // subtype KEYBOARD_LAYOUT_SET extra value, or "" + * "locale": "" // keyboard.mId.getLocale().toLanguageTag(), or "" + * }, + * "committedWord": "", // composedText from onUpdateTailBatchInputCompleted, or "" + * "pointers": [ + * { "id": , "x": , "y": , "t": }, + * ... + * ] + * } + * ``` + * + * `pointers` is the [InputPointers] array flattened in index order (0 until + * [InputPointers.getPointerSize]): `getXCoordinates()[i]`, `getYCoordinates()[i]`, + * `getPointerIds()[i]`, `getTimes()[i]`. + * + * For multi-part gestures (two-thumb / combining mode) the pointers reflect whatever + * was in [helium314.keyboard.latin.WordComposer.getInputPointers] at the moment + * [helium314.keyboard.latin.inputlogic.InputLogic.onUpdateTailBatchInputCompleted] computed + * `composedText`; that may be a merged trail rather than a raw single-finger stroke. + */ +object TraceRecorder { + + private val executor: Executor = Executors.newSingleThreadExecutor() + + /** + * Capture one gesture session and schedule an async write to disk. + * + * Must be called from the UI thread after `composedText` is known. + * Copies all mutable data synchronously before returning so the caller's + * arrays/objects can be mutated freely afterwards. + * + * @param context used only for [Context.getFilesDir]; must not be null. + * @param batchPointers the gesture trace (WordComposer.getInputPointers() snapshot). + * @param committedWord the word that will be committed for this batch, or "" if unknown. + * @param keyboard the active keyboard at commit time; may be null (geometry will be 0/empty). + */ + fun record( + context: Context, + batchPointers: InputPointers, + committedWord: String, + keyboard: Keyboard?, + ) { + val size = batchPointers.pointerSize + // Copy arrays immediately on the calling thread — InputPointers is not thread-safe + // and the caller may reset it right after we return. + val xs = batchPointers.xCoordinates.copyOf(size) + val ys = batchPointers.yCoordinates.copyOf(size) + val ids = batchPointers.pointerIds.copyOf(size) + val ts = batchPointers.times.copyOf(size) + + val now = System.currentTimeMillis() + val kbWidth = keyboard?.mOccupiedWidth ?: 0 + val kbHeight = keyboard?.mOccupiedHeight ?: 0 + val mainLayout = keyboard?.mId?.mSubtype?.getExtraValueOf(KEYBOARD_LAYOUT_SET) ?: "" + val locale = keyboard?.mId?.getLocale()?.toLanguageTag() ?: "" + // Capture the application context so we never leak the IME service. + val appContext = context.applicationContext + + executor.execute { + try { + val dir = File(appContext.filesDir, "input_traces") + dir.mkdirs() + val file = File(dir, "trace-$now.json") + file.writeText( + buildJson(now, kbWidth, kbHeight, mainLayout, locale, committedWord, + size, xs, ys, ids, ts) + ) + } catch (_: Exception) { + // Best-effort — never propagate exceptions back onto the input path. + } + } + } + + private fun buildJson( + now: Long, + width: Int, + height: Int, + mainLayout: String, + locale: String, + committedWord: String, + size: Int, + xs: IntArray, + ys: IntArray, + ids: IntArray, + ts: IntArray, + ): String { + val sb = StringBuilder(64 + size * 40) + sb.append("{\"version\":1") + sb.append(",\"createdAt\":").append(now) + sb.append(",\"keyboard\":{") + sb.append("\"width\":").append(width) + sb.append(",\"height\":").append(height) + sb.append(",\"mainLayout\":\"").append(jsonEscape(mainLayout)).append('"') + sb.append(",\"locale\":\"").append(jsonEscape(locale)).append('"') + sb.append('}') + sb.append(",\"committedWord\":\"").append(jsonEscape(committedWord)).append('"') + sb.append(",\"pointers\":[") + for (i in 0 until size) { + if (i > 0) sb.append(',') + sb.append("{\"id\":").append(ids[i]) + sb.append(",\"x\":").append(xs[i]) + sb.append(",\"y\":").append(ys[i]) + sb.append(",\"t\":").append(ts[i]) + sb.append('}') + } + sb.append("]}") + return sb.toString() + } + + private fun jsonEscape(s: String): String { + if (s.none { it == '"' || it == '\\' || it.code < 0x20 }) return s + val sb = StringBuilder(s.length + 4) + for (c in s) { + when { + c == '"' -> sb.append("\\\"") + c == '\\' -> sb.append("\\\\") + c == '\n' -> sb.append("\\n") + c == '\r' -> sb.append("\\r") + c == '\t' -> sb.append("\\t") + c.code < 0x20 -> sb.append("\\u%04x".format(c.code)) + else -> sb.append(c) + } + } + return sb.toString() + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt index 448ceacfa..0ad4a745b 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DebugScreen.kt @@ -40,6 +40,7 @@ fun DebugScreen( DebugSettings.PREF_SHOW_SUGGESTION_INFOS, DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH, DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW, + DebugSettings.PREF_RECORD_INPUT_TRACES, R.string.prefs_dump_dynamic_dicts ) + DictionaryFacilitator.DYNAMIC_DICTIONARY_TYPES.map { DebugSettings.PREF_KEY_DUMP_DICT_PREFIX + it } SearchSettingsScreen( @@ -94,6 +95,9 @@ private fun createDebugSettings(context: Context) = listOf( Setting(context, DebugSettings.PREF_SLIDING_KEY_INPUT_PREVIEW, R.string.sliding_key_input_preview, R.string.sliding_key_input_preview_summary) { def -> SwitchPreference(def, Defaults.PREF_SLIDING_KEY_INPUT_PREVIEW) }, + Setting(context, DebugSettings.PREF_RECORD_INPUT_TRACES, R.string.prefs_record_input_traces, R.string.prefs_record_input_traces_summary) { def -> + SwitchPreference(def, Defaults.PREF_RECORD_INPUT_TRACES) + }, ) + DictionaryFacilitator.DYNAMIC_DICTIONARY_TYPES.map { type -> Setting(context, DebugSettings.PREF_KEY_DUMP_DICT_PREFIX + type, R.string.button_default) { val ctx = LocalContext.current diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index 78f5bee2a..031b6116f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -173,6 +173,41 @@ fun DictionaryScreen( ) } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + + // Graduated Trust Setting + var graduatedTrustEnabled by remember { mutableStateOf(ctx.prefs().getBoolean(Settings.PREF_GRADUATED_TRUST, Defaults.PREF_GRADUATED_TRUST)) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + .clickable { + val newValue = !graduatedTrustEnabled + graduatedTrustEnabled = newValue + ctx.prefs().edit { putBoolean(Settings.PREF_GRADUATED_TRUST, newValue) } + } + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.graduated_trust), + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource(R.string.graduated_trust_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + androidx.compose.material3.Switch( + checked = graduatedTrustEnabled, + onCheckedChange = { + graduatedTrustEnabled = it + ctx.prefs().edit { putBoolean(Settings.PREF_GRADUATED_TRUST, it) } + } + ) + } + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) // Personal Dictionary Setting val prefs = ctx.prefs() diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index 489b036a4..bb9d310e0 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -72,6 +72,7 @@ fun GestureTypingScreen( add(Settings.PREF_SPACE_HORIZONTAL_SWIPE) add(Settings.PREF_SPACE_VERTICAL_SWIPE) add(Settings.PREF_TOUCHPAD_SENSITIVITY) + add(Settings.PREF_TOUCHPAD_EDGE_SCROLL) add(Settings.PREF_DELETE_SWIPE) add(Settings.PREF_SHORTCUT_ROWS) if (prefs.getBoolean(Settings.PREF_SHORTCUT_ROWS, Defaults.PREF_SHORTCUT_ROWS)) { @@ -169,6 +170,9 @@ fun createGestureTypingSettings(context: Context) = listOf( description = { value -> value.toInt().toString() } ) }, + Setting(context, Settings.PREF_TOUCHPAD_EDGE_SCROLL, R.string.touchpad_edge_scroll, R.string.touchpad_edge_scroll_summary) { + SwitchPreference(it, Defaults.PREF_TOUCHPAD_EDGE_SCROLL) + }, Setting(context, Settings.PREF_DELETE_SWIPE, R.string.delete_swipe, R.string.delete_swipe_summary) { SwitchPreference(it, Defaults.PREF_DELETE_SWIPE) }, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index e86fe7958..0b6d128d8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -79,6 +79,8 @@ fun ToolbarScreen( if (toolbarMode == ToolbarMode.EXPANDABLE && !isSplitToolbar) Settings.PREF_AUTO_HIDE_PINNED_KEYS else null, if (toolbarMode == ToolbarMode.EXPANDABLE) Settings.PREF_REMEMBER_TOOLBAR_STATE else null, if (toolbarMode != ToolbarMode.HIDDEN) Settings.PREF_VARIABLE_TOOLBAR_DIRECTION else null, + if (toolbarMode != ToolbarMode.HIDDEN) Settings.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE else null, + if (toolbarMode != ToolbarMode.HIDDEN) Settings.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD else null, ) SearchSettingsScreen( onClickBack = onClickBack, @@ -188,7 +190,17 @@ fun createToolbarSettings(context: Context): List { } KeyboardSwitcher.getInstance().setThemeNeedsReload() } - } + }, + Setting(context, Settings.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE, + R.string.toolbar_swipe_down_to_hide, R.string.toolbar_swipe_down_to_hide_summary) + { + SwitchPreference(it, Defaults.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE) + }, + Setting(context, Settings.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD, + R.string.toolbar_only_with_hw_keyboard, R.string.toolbar_only_with_hw_keyboard_summary) + { + SwitchPreference(it, Defaults.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD) + }, ) } diff --git a/app/src/main/jni/CMakeLists.txt b/app/src/main/jni/CMakeLists.txt new file mode 100644 index 000000000..5068fbf6d --- /dev/null +++ b/app/src/main/jni/CMakeLists.txt @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Standalone HOST build of the native latinime unit tests (issue #78). +# +# This is NOT used by the app build (gradle uses ndkBuild via Android.mk). It exists so the +# existing C++ gtest suite under tests/ can be compiled and run on a plain Linux host (CI) WITHOUT +# an AOSP platform checkout — the legacy run-tests.sh / HostUnitTests.mk require the AOSP build +# system (mmm/lunch/BUILD_HOST_NATIVE_TEST) and cannot run in gradle/NDK CI. +# +# Build & run: +# cmake -S app/src/main/jni -B build-host-tests -DCMAKE_BUILD_TYPE=Release +# cmake --build build-host-tests -j +# ctest --test-dir build-host-tests --output-on-failure +# +# Host build => __ANDROID__ is undefined, so defines.h uses its host fallbacks (no android/log.h). + +cmake_minimum_required(VERSION 3.14) +project(latinime_host_tests CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip +) +FetchContent_MakeAvailable(googletest) + +set(JNI_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +set(SRC_DIR ${JNI_DIR}/src) +set(TEST_DIR ${JNI_DIR}/tests) + +# Some core engine headers (e.g. suggest/core/dictionary/dictionary.h) include for type +# signatures (JNIEnv / jintArray / ... — header-only, the host tests never call the JNI bridge, so +# no libjvm is linked). CI provides a JDK via actions/setup-java -> find_package(JNI). For a local +# host build without a JDK, pass -DLATINIME_JNI_INCLUDE=

(e.g. the Android NDK's jni.h dir). +if(LATINIME_JNI_INCLUDE) + set(JNI_INCLUDE_DIRS ${LATINIME_JNI_INCLUDE}) +else() + find_package(JNI REQUIRED) +endif() + +# Engine core: every src/*.cpp EXCEPT the JNI bridge (com_android_*.cpp / jni_common.cpp need +# jni.h and are not part of the host-testable core — see NativeFileList.mk LATIN_IME_CORE_SRC_FILES). +file(GLOB_RECURSE ENGINE_SRC CONFIGURE_DEPENDS ${SRC_DIR}/*.cpp) +list(FILTER ENGINE_SRC EXCLUDE REGEX "(com_android_inputmethod_|jni_common\\.cpp$)") + +# gtest suite (LATIN_IME_CORE_TEST_FILES). +file(GLOB_RECURSE TEST_SRC CONFIGURE_DEPENDS ${TEST_DIR}/*.cpp) + +add_executable(latinime_host_unittests ${ENGINE_SRC} ${TEST_SRC}) +target_include_directories(latinime_host_unittests PRIVATE ${SRC_DIR} ${TEST_DIR} ${JNI_INCLUDE_DIRS}) +target_compile_options(latinime_host_unittests PRIVATE + -include ${CMAKE_CURRENT_SOURCE_DIR}/host_test_compat.h + -Wno-unused-parameter -Wno-unused-function) +target_link_libraries(latinime_host_unittests PRIVATE gtest gtest_main) + +enable_testing() +include(GoogleTest) +gtest_discover_tests(latinime_host_unittests DISCOVERY_TIMEOUT 60) diff --git a/app/src/main/jni/host_test_compat.h b/app/src/main/jni/host_test_compat.h new file mode 100644 index 000000000..cf2731965 --- /dev/null +++ b/app/src/main/jni/host_test_compat.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Force-included (via -include) ONLY in the standalone host test build (CMakeLists.txt). +// The engine/test sources were written against AOSP's older clang, which pulled several standard +// headers transitively. Modern host g++ is stricter, so a few TUs reference CHAR_BIT / fixed-width +// ints / mem* without a direct include. This shim provides them globally for the host build +// without touching the shared sources (which compile fine under the NDK for the app). +#pragma once + +#include // CHAR_BIT, INT_MAX, ... +#include // int32_t, uint8_t, ... +#include // size_t, ptrdiff_t +#include // memcpy, memset, strlen +#include // snprintf diff --git a/app/src/main/res/values/donottranslate-debug-settings.xml b/app/src/main/res/values/donottranslate-debug-settings.xml index 59653f916..757869007 100644 --- a/app/src/main/res/values/donottranslate-debug-settings.xml +++ b/app/src/main/res/values/donottranslate-debug-settings.xml @@ -16,6 +16,8 @@ Show slide indicator Display visual cue while sliding from Shift or Symbol keys + Record input traces + Write gesture traces to filesDir/input_traces/ for replay debugging Dump dictionary diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index dfd355053..9c49dbffc 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -38,6 +38,7 @@ workman bepo pcqwerty + hcesar @@ -51,6 +52,7 @@ Workman Bépo PC + HCÉSAR diff --git a/app/src/main/res/values/strings-talkback-descriptions.xml b/app/src/main/res/values/strings-talkback-descriptions.xml index 96857b1a1..17803f691 100644 --- a/app/src/main/res/values/strings-talkback-descriptions.xml +++ b/app/src/main/res/values/strings-talkback-descriptions.xml @@ -69,6 +69,8 @@ Join next Force next space + + Undo word Shift enabled diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ebcca15d0..7568f00af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,10 @@ Flag unknown words Mark suggestions that are only learned/typed (not in a dictionary) and offer Add/Block on long-press + + Graduated trust for new words + + A learned word that is not in a dictionary must be repeated a few times before it can outrank a real word — so a single misfire can\'t hijack your typing Add to dictionary @@ -465,6 +469,7 @@ Autospace Join next Force next space + Undo word Disable learning of new words @@ -1296,6 +1301,8 @@ New dictionary: Touchpad mode Touchpad sensitivity + Touchpad edge scrolling + Accelerate cursor movement when holding near the edge of the touchpad Force auto-capitalize Force auto-capitalization Force sentence capitalization on all text fields except passwords @@ -1330,6 +1337,10 @@ New dictionary: Hide pinned keys when the toolbar is expanded Remember toolbar state Keep the toolbar expanded or collapsed between sessions and while typing + Swipe down to hide + Swipe down on the toolbar to hide the keyboard + Only show toolbar with hardware keyboard + When a physical keyboard is connected, show only the toolbar instead of the full on-screen keyboard Close language selector diff --git a/app/src/test/java/helium314/keyboard/latin/DictionaryGroupTest.kt b/app/src/test/java/helium314/keyboard/latin/DictionaryGroupTest.kt index 95fb14df8..747e46761 100644 --- a/app/src/test/java/helium314/keyboard/latin/DictionaryGroupTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/DictionaryGroupTest.kt @@ -142,4 +142,17 @@ class DictionaryGroupTest { removeFromBlacklist.invoke(instance, "blockedword") assertEquals(false, isBlacklisted.invoke(instance, "blockedword")) } + + @Test + fun graduatedTrust_penalizesUnconfirmedUncuratedWordsOnly() { + // C4-smart (#39): an uncurated learned word (not in a real dictionary) below the confirmation + // frequency is penalized, so a single misfire can't out-rank a real word... + assertEquals(true, DictionaryFacilitatorImpl.shouldPenalizeUnconfirmedWord(true, 0)) + // ...but once it has been repeated enough it is trusted (deliberate new words still learn)... + assertEquals(false, DictionaryFacilitatorImpl.shouldPenalizeUnconfirmedWord(true, 10000)) + // ...and a real dictionary word is never penalized regardless of how rarely it was learned. + assertEquals(false, DictionaryFacilitatorImpl.shouldPenalizeUnconfirmedWord(false, 0)) + // (the actual capping of the score below the best real candidate happens in + // applyGraduatedTrust, which needs the native scorer and is covered by on-device testing.) + } } diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt index c55350a58..c5fdd54cc 100644 --- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt @@ -29,6 +29,7 @@ import helium314.keyboard.latin.utils.SubtypeSettings import helium314.keyboard.latin.utils.getTimestampFormatter import helium314.keyboard.latin.utils.prefs import org.junit.runner.RunWith +import org.junit.Ignore import org.mockito.Mockito import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner @@ -1247,6 +1248,291 @@ class InputLogicTest { assertEquals("/48", InputLogic.getInlineEmojiSearchString("2606:127.0.0.1::/48")) // do we want this? } + // ------- #21 backspace corpus --------------------------------------------------- + // Golden-master regression safety net for the #31 backspace refactor. + // Each test documents the behavior CONTRACT it locks; a failing test after refactor + // identifies the regression. Run with: + // gradlew :app:testOfflineDebugUnitTest --tests "helium314.keyboard.latin.InputLogicTest.corpus*" + // + // GESTURE-ONLY behaviors NOT covered here (gesture recognizer needed): + // • Fragment pop on a COMMITTED (post-autospace) gesture word — relies on + // mLastGestureCommittedFragmentLengths; only populated by onCombiningGraceExpired. + // • mWordComposer.isBatchMode() whole-word delete — covered by `remove glide typing + // word on delete` above; batch mode is cleared before our fragment path is taken. + // --------------------------------------------------------------------------------- + + /** + * CONTRACT: DEFAULT mode — backspace removes exactly one character from the composing word. + * Locks the `mWordComposer.applyProcessedEvent(event)` path (~line 2532 in InputLogic.java) + * taken when no combining/fragment prefs are set. + */ + @Test fun `corpus - default mode char-by-char backspace`() { + reset() + chainInput("hello") + assertEquals("hello", composingText) + + functionalKeyPress(KeyCode.DELETE) + assertEquals("hell", textBeforeCursor) + assertEquals("hell", composingText) + + functionalKeyPress(KeyCode.DELETE) + assertEquals("hel", textBeforeCursor) + assertEquals("hel", composingText) + + // drain to empty — no crash, no negative length + functionalKeyPress(KeyCode.DELETE) + functionalKeyPress(KeyCode.DELETE) + functionalKeyPress(KeyCode.DELETE) + assertEquals("", textBeforeCursor) + assertEquals("", text) + } + + /** + * CONTRACT: DEFAULT mode — first backspace after a committed word + trailing space removes + * the space; subsequent backspaces shrink the re-composed word char by char. + */ + @Test fun `corpus - default mode backspace after committed word and space`() { + reset() + chainInput("hello ") + assertEquals("hello ", text) + assertEquals("", composingText) + + // Removes the trailing space and re-composes "hello". + functionalKeyPress(KeyCode.DELETE) + assertEquals("hello", text) + assertEquals("hello", composingText) + + // Shrinks the re-composed word by one char. + functionalKeyPress(KeyCode.DELETE) + assertEquals("hell", text) + assertEquals("hell", composingText) + } + + /** + * CONTRACT: DEFAULT mode — backspace from within multi-word committed text re-composes the + * word immediately left of the cursor and trims it char-by-char; earlier words are NOT touched. + */ + @Test fun `corpus - default mode backspace into committed word recomposes and shrinks`() { + reset() + setText("hello there ") + assertEquals("", composingText) + + // DELETE removes trailing space; "there" is re-composed. + functionalKeyPress(KeyCode.DELETE) + assertEquals("hello there", text) + assertEquals("there", composingText) + + // Second DELETE shrinks "there" → "ther". + functionalKeyPress(KeyCode.DELETE) + assertEquals("hello ther", text) + assertEquals("ther", composingText) + + // "hello " is never touched. + assertTrue(text.startsWith("hello ")) + } + + /** + * CONTRACT: RECOMPOSE CORRUPTION GUARD — combining whole-word delete must NOT mash the + * composing word into the preceding committed text. + * + * The comment at InputLogic.java line ~2519 documents the bug this path fixes: + * deleteTextBeforeCursor(4) on composing "cool" in "This is pretty cool" routes through + * InputConnection.deleteSurroundingText which ignores the composing span and deletes + * committed text BEFORE it, yielding "This is precool". + * The fix: mWordComposer.reset() + commitText("", 1) clears the composing span instead. + * + * Prefs to reach this path (else if at ~line 2515): + * PREF_COMBINING_GRACE_MS > 0, PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = true, + * PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = true. + * The composing word "cool" is typed (not gestured) so !isBatchMode(). + */ + @Test fun `corpus - combining whole-word delete does not produce precool corruption`() { + reset() + latinIME.prefs().edit { + putInt(Settings.PREF_COMBINING_GRACE_MS, 1000) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, true) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT, true) + } + + // Type the full sentence; "cool" is the composing word after the last char. + chainInput("This is pretty cool") + assertEquals("This is pretty cool", textBeforeCursor) + assertEquals("cool", composingText) + + // One backspace: whole composing word "cool" removed cleanly. + functionalKeyPress(KeyCode.DELETE) + assertEquals("This is pretty ", textBeforeCursor) + assertEquals("", composingText) + // CORRUPTION GUARD: if deleteTextBeforeCursor were used instead of reset+commitText("",1), + // this would be "This is precool". + assertFalse(text.contains("precool"), + "Corruption: 'precool' found — indicates deleteTextBeforeCursor was used instead of " + + "mWordComposer.reset()+commitText(\"\",1). See InputLogic.java line ~2519.") + } + + /** + * CONTRACT: COMBINING whole-word delete mid-sentence — composing word removed in full; + * preceding committed words survive intact (no word-mash). + */ + @Test fun `corpus - combining whole-word delete mid-sentence leaves surrounding text intact`() { + reset() + latinIME.prefs().edit { + putInt(Settings.PREF_COMBINING_GRACE_MS, 1000) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, true) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT, true) + } + + // Seed editor with committed text; cursor at end → "world" is re-composed. + setText("hello world") + functionalKeyPress(KeyCode.DELETE) + assertEquals("hello ", textBeforeCursor) + assertFalse(text.contains("helloworld") || text.contains("hellworld"), + "Word mash detected after combining whole-word delete") + } + + /** + * CONTRACT: FRAGMENT BACKSPACE — legacy MANUAL_SPACING mode, tap input. + * With per-tap fragment tracking and gesture-word delete OFF, DELETE pops the last + * fragment of the composing word. For tap input each fragment is one char, so the + * observable effect is a char-by-char shrink. Locks that fragment-mode backspace does + * NOT delete the whole word and does NOT mash into preceding text. + * + * Reaches tryFragmentBackspace: legacy tracking needs MANUAL_SPACING + FRAGMENT_BACKSPACE, + * and DELETES_GESTURE_WORD must be false (else InputLogic ~line 1339 bails to whole-word + * delete — which empties "hello" in one press). Asserts observable text only, not the + * internal boundary list, so it survives the #31 input-unit-stack refactor. + */ + @Test fun `corpus - fragment backspace legacy tap pops one fragment`() { + reset() + latinIME.prefs().edit { + putBoolean(Settings.PREF_GESTURE_MANUAL_SPACING, true) + putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, true) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false) + } + + chainInput("hello") + assertEquals("hello", composingText) + + // Fragment pop of a one-char fragment → "hell" (NOT whole-word delete to ""). + functionalKeyPress(KeyCode.DELETE) + assertEquals("hell", textBeforeCursor) + assertEquals("hell", composingText) + + // Continues shrinking one fragment per press, down to empty, with no word-mash. + functionalKeyPress(KeyCode.DELETE) + assertEquals("hel", composingText) + functionalKeyPress(KeyCode.DELETE) + functionalKeyPress(KeyCode.DELETE) + functionalKeyPress(KeyCode.DELETE) + assertEquals("", textBeforeCursor) + assertEquals("", composingText) + } + + /** + * CONTRACT (gesture-only, NOT JVM-reachable): in multipart combining mode a second + * gesture EXTENDS the composing word, and DELETE pops the whole appended gesture + * fragment atomically (e.g. "technology" → DELETE → "tech"). + * + * @Ignore: the JVM harness cannot simulate combining-mode gesture extension. Two + * successive gestureInput() calls compose two independent batch words, so + * gestureInput("tech") then gestureInput("technology") yields composing + * "techtechnology" (expected: but was:), not an + * extended "technology". Real extension needs native batch timing + combining state + * carried across strokes. Verify on-device; kept as executable contract documentation. + */ + @Ignore("gesture-only: harness cannot simulate combining gesture-extension across strokes") + @Test fun `corpus - fragment backspace pops gesture-sized fragment in multipart combining`() { + reset() + latinIME.prefs().edit { + putInt(Settings.PREF_COMBINING_GRACE_MS, 1000) + putBoolean(Settings.PREF_GESTURE_FRAGMENT_BACKSPACE, true) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, false) + } + gestureInput("tech") + gestureInput("technology") // would extend on-device; appends in the harness + assertEquals("technology", composingText) + + functionalKeyPress(KeyCode.DELETE) + assertEquals("tech", composingText) + + functionalKeyPress(KeyCode.DELETE) + assertEquals("", composingText) + } + + /** + * CONTRACT: CURSOR-FRONT recompose path — when cursor is inside a composing word + * (isCursorFrontOrMiddleOfComposingWord()), backspace first commits the word via + * resetEntireInputState then removes one char from the committed text. No surrounding + * text is damaged. + */ + @Test fun `corpus - cursor in middle of composing word resets then deletes one char`() { + reset() + chainInput("hello") + assertEquals("hello", composingText) + setCursorPosition(2) // cursor between 'e' and first 'l' + + // With cursor inside composing word: reset + delete char at position 2. + // resetEntireInputState commits "hello" then deleteTextBeforeCursor(1) removes 'e'. + functionalKeyPress(KeyCode.DELETE) + assertEquals("hllo", text) + // The word is re-composed after the delete. + assertEquals("hllo", composingText) + } + + /** + * CONTRACT: MONOTONICITY INVARIANT — across a run of backspaces total text length is + * non-increasing and characters from earlier committed words never spontaneously appear. + * Guards against over-deletion or re-insertion bugs at word boundaries. + */ + @Test fun `corpus - monotonicity repeated backspaces never increase text length`() { + reset() + chainInput("hello world") + val initial = text.length + assertTrue(initial > 0) + + var prev = initial + // Stop at empty: the mock IC throws on delete-from-empty (real editors no-op), + // which is not the behavior under test. + var guard = initial + 5 + while (text.isNotEmpty() && guard-- > 0) { + functionalKeyPress(KeyCode.DELETE) + val cur = text.length + assertTrue(cur <= prev, + "Text grew after backspace: $prev → $cur text='$text'") + prev = cur + } + assertEquals("", text) + } + + /** + * CONTRACT: MONOTONICITY INVARIANT — combining whole-word delete mode. + * commitText("",1) must never over-delete (removing chars from preceding words). + */ + @Test fun `corpus - monotonicity combining whole-word delete never increases length`() { + reset() + latinIME.prefs().edit { + putInt(Settings.PREF_COMBINING_GRACE_MS, 1000) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD, true) + putBoolean(Settings.PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT, true) + } + + chainInput("alpha beta gamma") + val initial = text.length + + var prev = initial + // Stop at empty: the mock IC throws on delete-from-empty (real editors no-op), + // which is not the behavior under test. + var guard = initial + 5 + while (text.isNotEmpty() && guard-- > 0) { + functionalKeyPress(KeyCode.DELETE) + val cur = text.length + assertTrue(cur <= prev, + "Text grew after backspace: $prev → $cur text='$text'") + prev = cur + } + assertEquals("", text) + } + // ------- helper functions --------- // should be called before every test, so the same state is guaranteed diff --git a/app/src/test/java/helium314/keyboard/latin/inputlogic/BackspaceUnitStackTest.kt b/app/src/test/java/helium314/keyboard/latin/inputlogic/BackspaceUnitStackTest.kt new file mode 100644 index 000000000..8e45c2edf --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/inputlogic/BackspaceUnitStackTest.kt @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.inputlogic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Direct unit tests for [BackspaceUnitStack] (#31): the fragment- / whole-word-backspace + * length math, previously only exercised indirectly through InputLogic. Pure logic, no + * Robolectric needed. + */ +class BackspaceUnitStackTest { + + private fun stackWith(vararg boundaries: Int) = BackspaceUnitStack().apply { + boundaries.forEach { recordComposingBoundary(it) } + } + + // ---- recordComposingBoundary ---- + + @Test fun `record ignores non-positive lengths`() { + val s = BackspaceUnitStack() + s.recordComposingBoundary(0) + s.recordComposingBoundary(-3) + assertFalse(s.hasComposingBoundaries()) + } + + @Test fun `record dedups the current top`() { + val s = stackWith(3, 3, 3) + // Three identical records collapse to one boundary -> one pop empties it. + assertEquals(0, s.popComposingFragment(3)) + assertEquals(-1, s.popComposingFragment(0)) + } + + @Test fun `clearComposing drops boundaries`() { + val s = stackWith(2, 5) + assertTrue(s.hasComposingBoundaries()) + s.clearComposing() + assertFalse(s.hasComposingBoundaries()) + assertEquals(-1, s.popComposingFragment(5)) + } + + // ---- popComposingFragment ---- + + @Test fun `pop on empty returns -1`() { + assertEquals(-1, BackspaceUnitStack().popComposingFragment(4)) + } + + @Test fun `pop single fragment shrinks to zero`() { + val s = stackWith(5) + assertEquals(0, s.popComposingFragment(5)) + } + + @Test fun `pop multi-fragment shrinks to previous boundary`() { + // Two gesture fragments: "tech"(4) + "nology"(->10). Pop the second -> back to "tech". + val s = stackWith(4, 10) + assertEquals(4, s.popComposingFragment(10)) + // Pop again -> empties. + assertEquals(0, s.popComposingFragment(4)) + assertEquals(-1, s.popComposingFragment(0)) + } + + @Test fun `pop trims stale boundaries past current length`() { + // Boundary 10 is stale (word already shrank to 7 by other means): trimmed, then the + // remaining top (4) != currentLen(7) so the defensive fallback returns 4. + val s = stackWith(4, 10) + assertEquals(4, s.popComposingFragment(7)) + } + + @Test fun `pop falls back to top boundary when current fragment end unrecorded`() { + // Top boundary 4 but the word is length 6 (last 2 chars' boundary never recorded): + // fallback returns the top boundary without popping it. + val s = stackWith(4) + assertEquals(4, s.popComposingFragment(6)) + // Boundary not consumed: a pop at the recorded length still empties it. + assertEquals(0, s.popComposingFragment(4)) + } + + // ---- fragmentLengthsForCommit ---- + + @Test fun `commit lengths are deltas between boundaries`() { + val s = stackWith(4, 10) + assertEquals(listOf(4, 6), s.fragmentLengthsForCommit(10)) + } + + @Test fun `commit lengths add a trailing tail past the last boundary`() { + val s = stackWith(4) + // "tech"(4) + 2 trailing chars with no recorded boundary -> [4, 2]. + assertEquals(listOf(4, 2), s.fragmentLengthsForCommit(6)) + } + + @Test fun `commit lengths for an untracked word are one whole fragment`() { + assertEquals(listOf(5), BackspaceUnitStack().fragmentLengthsForCommit(5)) + } + + @Test fun `commit lengths for zero length are empty`() { + assertEquals(emptyList(), stackWith(4).fragmentLengthsForCommit(0)) + } + + @Test fun `commit lengths ignore boundaries past current length`() { + val s = stackWith(4, 10) + // Committing at length 7: boundary 10 is out of range, tail = 7-4 = 3. + assertEquals(listOf(4, 3), s.fragmentLengthsForCommit(7)) + } + + // ---- committed side ---- + + @Test fun `setCommitted stores length and a defensive copy of fragments`() { + val s = BackspaceUnitStack() + val src = arrayListOf(4, 7) + s.setCommitted(11, src) + assertEquals(11, s.committedLength()) + assertEquals(listOf(4, 7), s.copyCommittedFragmentLengths()) + // Mutating the source after the call must not leak into the stack. + src.add(99) + assertEquals(listOf(4, 7), s.copyCommittedFragmentLengths()) + // The returned copy is defensive too. + s.copyCommittedFragmentLengths().add(99) + assertEquals(listOf(4, 7), s.copyCommittedFragmentLengths()) + } + + @Test fun `setCommitted with no fragments still arms whole-word delete`() { + // A gesture word committed with no recorded fragments: committedLength must STILL be + // set (it arms the first-backspace whole-word delete) while the fragment list stays + // empty. The empty-list commit is the easiest place to silently drop the length. + val s = BackspaceUnitStack() + s.setCommitted(7, emptyList()) + assertEquals(7, s.committedLength()) + assertEquals(emptyList(), s.copyCommittedFragmentLengths()) + } + + @Test fun `setCommittedFragmentLengths replaces fragments but keeps length`() { + val s = BackspaceUnitStack() + s.setCommitted(11, listOf(4, 7)) + s.setCommittedFragmentLengths(listOf(4)) // popped the trailing fragment off the editor + assertEquals(listOf(4), s.copyCommittedFragmentLengths()) + assertEquals(11, s.committedLength()) + } + + @Test fun `clearCommitted resets length and fragments`() { + val s = BackspaceUnitStack() + s.setCommitted(11, listOf(4, 7)) + s.clearCommitted() + assertEquals(0, s.committedLength()) + assertEquals(emptyList(), s.copyCommittedFragmentLengths()) + } + + @Test fun `composing and committed sides are independent`() { + val s = stackWith(4, 10) + s.setCommitted(11, listOf(4, 7)) + s.clearComposing() + // Clearing composing left the committed side intact. + assertEquals(11, s.committedLength()) + assertEquals(listOf(4, 7), s.copyCommittedFragmentLengths()) + } +} diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt index afc560f28..a51cfb673 100644 --- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt +++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt @@ -80,4 +80,22 @@ class SettingsContainerTest { val context = ApplicationProvider.getApplicationContext() assertEquals("Delete last fragment", context.getString(R.string.two_thumb_backspace_fragment)) } + + @Test + fun touchpadEdgeScrollSettingIsRegistered() { + assertEquals(Settings.PREF_TOUCHPAD_EDGE_SCROLL, + container[Settings.PREF_TOUCHPAD_EDGE_SCROLL]?.key) + } + + @Test + fun toolbarSwipeDownToHideSettingIsRegistered() { + assertEquals(Settings.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE, + container[Settings.PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE]?.key) + } + + @Test + fun onlyToolbarWithHardwareKeyboardSettingIsRegistered() { + assertEquals(Settings.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD, + container[Settings.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD]?.key) + } } diff --git a/docs/badges/download.svg b/docs/badges/download.svg deleted file mode 100644 index ee156f654..000000000 --- a/docs/badges/download.svg +++ /dev/null @@ -1 +0,0 @@ -VersionVersionv3.8.5v3.8.5 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg deleted file mode 100644 index 59a40ea91..000000000 --- a/docs/badges/downloads.svg +++ /dev/null @@ -1 +0,0 @@ -DownloadsDownloads2817928179 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg deleted file mode 100644 index a3ec88a5a..000000000 --- a/docs/badges/stars.svg +++ /dev/null @@ -1 +0,0 @@ -StarsStars471471 diff --git a/docs/images/leantype_banner_dark.svg b/docs/images/leantype_banner_dark.svg index ae5e9c81b..5bea94fe0 100644 --- a/docs/images/leantype_banner_dark.svg +++ b/docs/images/leantype_banner_dark.svg @@ -5,6 +5,6 @@ .purple { fill: #7C4DFF; } - LeanType + LeanTypeDual diff --git a/docs/images/leantype_banner_light.svg b/docs/images/leantype_banner_light.svg index cf9b0a02a..bd5aefd38 100644 --- a/docs/images/leantype_banner_light.svg +++ b/docs/images/leantype_banner_light.svg @@ -5,6 +5,6 @@ .purple { fill: #7C4DFF; } - LeanType + LeanTypeDual diff --git a/fastlane/metadata/android/en-US/changelogs/3900.txt b/fastlane/metadata/android/en-US/changelogs/3900.txt new file mode 100644 index 000000000..711af4ca2 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3900.txt @@ -0,0 +1,8 @@ +- Two-thumb: smarter learned-word trust — a just-learned word stays below dictionary suggestions until you've used it a few times, so it won't hijack autocorrect early +- New "undo word" toolbar key: revert the last committed word back to its suggestion alternatives +- Add the HCESAR keyboard layout +- Touchpad: hold near the edge to auto-scroll the cursor with acceleration +- Toolbar: optionally swipe down on the toolbar to hide the keyboard +- Toolbar: option to show only the toolbar when a hardware keyboard is connected +- Two-thumb down-swipe shortcut popup now spaces its icons evenly across the row +- Under the hood: native engine + backspace now covered by automated tests for fewer regressions
- + Get it on GitHub - - Get it on F-Droid - - - + Get it on Obtainium