feat: Initial commit — libswipetype v0.1.0-dev
CI / Core C++ Tests (push) Has been cancelled
CI / Android Build (push) Has been cancelled
CI / NDK ABI Build (arm64-v8a) (push) Has been cancelled
CI / NDK ABI Build (armeabi-v7a) (push) Has been cancelled
CI / NDK ABI Build (x86_64) (push) Has been cancelled
CI / Core C++ Tests (push) Has been cancelled
CI / Android Build (push) Has been cancelled
CI / NDK ABI Build (arm64-v8a) (push) Has been cancelled
CI / NDK ABI Build (armeabi-v7a) (push) Has been cancelled
CI / NDK ABI Build (x86_64) (push) Has been cancelled
Complete swipe typing engine with: - swipetype-core: C++17 gesture recognition (DTW, adaptive scoring) - swipetype-android: JNI bridge + Android AAR packaging - sample-app: Functional IME demo with space bar zone detection - 48 unit tests (GTest), CI/CD workflows (GitHub Actions) - Full documentation suite (architecture, API, contributing, security) Co-authored-by: inventory69 <inventory69@users.noreply.github.com> Co-authored-by: Hyphonical <Hyphonical@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
## Description
|
||||
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] `feat` — New feature
|
||||
- [ ] `fix` — Bug fix
|
||||
- [ ] `docs` — Documentation
|
||||
- [ ] `test` — Tests
|
||||
- [ ] `build` — Build system
|
||||
- [ ] `refactor` — Code restructuring
|
||||
- [ ] `perf` — Performance improvement
|
||||
|
||||
## Module(s) Affected
|
||||
|
||||
- [ ] `swipetype-core`
|
||||
- [ ] `swipetype-android`
|
||||
- [ ] `adapters/heliboard`
|
||||
- [ ] `sample-app`
|
||||
- [ ] `scripts`
|
||||
- [ ] `docs`
|
||||
- [ ] `ci`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code compiles without new warnings (`-Wall -Wextra`)
|
||||
- [ ] All existing tests pass
|
||||
- [ ] New tests added for new functionality
|
||||
- [ ] Documentation updated (if applicable)
|
||||
- [ ] No API changes to stable interfaces (or joint review requested)
|
||||
- [ ] Commit messages follow Conventional Commits format
|
||||
|
||||
## API Changes
|
||||
|
||||
<!-- If this PR changes any stable API (headers in swipetype-core/include/swipetype/ or
|
||||
SwipeTypeAdapter.java/SwipeTypeEngine.java), describe the change and rationale here.
|
||||
Otherwise, write "None". -->
|
||||
@@ -0,0 +1,139 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# ========================================================================
|
||||
# Job 1: Build and test swipetype-core (C++ with Google Test)
|
||||
# ========================================================================
|
||||
core-tests:
|
||||
name: "Core C++ Tests"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build
|
||||
|
||||
- name: Configure CMake
|
||||
working-directory: swipetype-core
|
||||
run: |
|
||||
cmake -B build \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DGLIDE_BUILD_TESTS=ON
|
||||
|
||||
- name: Build
|
||||
working-directory: swipetype-core
|
||||
run: cmake --build build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: swipetype-core
|
||||
run: |
|
||||
cd build
|
||||
ctest --output-on-failure --verbose
|
||||
|
||||
# ========================================================================
|
||||
# Job 2: Build Android modules (JNI + Java)
|
||||
# ========================================================================
|
||||
android-build:
|
||||
name: "Android Build"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
sdkmanager --install "ndk;25.2.9519653"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Build all modules
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
./gradlew assembleDebug --stacktrace
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
./gradlew test --stacktrace
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
**/build/reports/tests/
|
||||
**/build/test-results/
|
||||
|
||||
# ========================================================================
|
||||
# Job 3: Build for all Android ABIs
|
||||
# ========================================================================
|
||||
ndk-abi-build:
|
||||
name: "NDK ABI Build (${{ matrix.abi }})"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
abi: [arm64-v8a, armeabi-v7a, x86_64]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
sdkmanager --install "ndk;25.2.9519653"
|
||||
|
||||
- name: Build native for ${{ matrix.abi }}
|
||||
run: |
|
||||
NDK_HOME=$ANDROID_NDK_HOME
|
||||
cmake -B build-${{ matrix.abi }} \
|
||||
-DCMAKE_TOOLCHAIN_FILE=$NDK_HOME/build/cmake/android.toolchain.cmake \
|
||||
-DANDROID_ABI=${{ matrix.abi }} \
|
||||
-DANDROID_PLATFORM=android-21 \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DSWIPETYPE_BUILD_TESTS=OFF \
|
||||
swipetype-core
|
||||
cmake --build build-${{ matrix.abi }}
|
||||
|
||||
- name: Upload .so artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: libswipetype-core-${{ matrix.abi }}
|
||||
path: build-${{ matrix.abi }}/*.a
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release-build:
|
||||
name: "Release Build"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
sdkmanager --install "ndk;25.2.9519653"
|
||||
|
||||
- name: Build release AAR
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
./gradlew :swipetype-android:assembleRelease
|
||||
./gradlew :adapters:heliboard:assembleRelease
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
swipetype-android/build/outputs/aar/swipetype-android-release.aar
|
||||
adapters/heliboard/build/outputs/aar/heliboard-release.aar
|
||||
generate_release_notes: true
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# ============================================================
|
||||
# libswipetype — .gitignore
|
||||
# ============================================================
|
||||
|
||||
# ---- Build outputs ----
|
||||
build/
|
||||
.build/
|
||||
out/
|
||||
cmake-build-*/
|
||||
|
||||
# ---- CMake ----
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
*.cmake
|
||||
!CMakeLists.txt
|
||||
|
||||
# ---- Android / Gradle ----
|
||||
.gradle/
|
||||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
*.ap_
|
||||
*.dex
|
||||
*.class
|
||||
|
||||
# ---- NDK / JNI ----
|
||||
*.so
|
||||
*.o
|
||||
*.a
|
||||
*.d
|
||||
.cxx/
|
||||
.externalNativeBuild/
|
||||
|
||||
# ---- IDE ----
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# ---- OS ----
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ---- Test artifacts ----
|
||||
*.gcov
|
||||
*.gcda
|
||||
*.gcno
|
||||
|
||||
# ---- Python ----
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
@@ -0,0 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to libswipetype are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0-dev] — 2026-02-26
|
||||
|
||||
Initial release — Phases 1–11 complete.
|
||||
|
||||
### Added
|
||||
- **swipetype-core** — C++17 gesture recognition library
|
||||
- `PathProcessor` — deduplicate, resample (64 points), bounding-box normalize
|
||||
- `IdealPathGenerator` — reference path generation with caching
|
||||
- `Scorer` — DTW distance with Sakoe-Chiba band (W=6)
|
||||
- `DictionaryLoader` — binary `.glide` format reader
|
||||
- `GestureEngine` — recognition pipeline orchestrator
|
||||
- `AdjacencyMap` — key adjacency computation (unused in scoring, for future use)
|
||||
- 48 Google Test unit tests (all passing)
|
||||
|
||||
- **swipetype-android** — Android AAR module
|
||||
- `SwipeTypeEngine` — Java lifecycle manager
|
||||
- `SwipeTypeAdapter` — keyboard integration interface
|
||||
- `GesturePoint`, `KeyboardLayoutDescriptor`, `SwipeTypeCandidate`, `SwipeTypeError` — data types
|
||||
- `GestureLibJNI.cpp` — JNI bridge
|
||||
|
||||
- **adapters/heliboard** — reference adapter for HeliBoard keyboard
|
||||
- `HeliboardSwipeTypeAdapter` — translates HeliBoard's `ProximityInfo`/`InputPointers` to swipetype API
|
||||
|
||||
- **sample-app** — minimal Input Method Service demo
|
||||
- `SampleKeyboardView` — custom QWERTY keyboard renderer with gesture trail
|
||||
- `SampleInputMethodService` — IME integration using `SwipeTypeAdapter`
|
||||
- `MainActivity` — setup guide with IME enable/switch buttons
|
||||
|
||||
- **scripts/gen_dict.py** — TSV-to-`.glide` dictionary generator
|
||||
- **test-data/** — 302-word English dictionary, QWERTY layout JSON, gesture scenarios
|
||||
|
||||
- **Documentation (Phase 9)**
|
||||
- `docs/API.md` — full C++ and Java API reference
|
||||
- `docs/ARCHITECTURE.md` — system architecture, pipeline diagram, design decisions
|
||||
- `docs/ONBOARDING.md` — developer onboarding guide (build, test, run)
|
||||
- `docs/HOW_TO_WRITE_AN_ADAPTER.md` — step-by-step adapter integration guide
|
||||
- `CHANGELOG.md` (this file)
|
||||
|
||||
- **Sample App Theme Fix (Phase 10)**
|
||||
- `styles.xml` with `AppTheme` and `SwipeTypeImeTheme`
|
||||
- Transparent window background prevents system theme bleed-through
|
||||
|
||||
- **Structural Accuracy Fixes (Phase 11)** — 3 algorithmic improvements
|
||||
- Key-transition word length estimation (replaced arc-length heuristic)
|
||||
- Absolute DTW normalization floor for single-candidate sets
|
||||
- Adaptive frequency weight: `effectiveAlpha *= max(0.1, rawRange/0.5)`
|
||||
- 3 new regression tests
|
||||
|
||||
- **CI/CD** — GitHub Actions workflows
|
||||
- `ci.yml` — Core C++ tests, Android build, NDK ABI matrix (arm64, armv7, x86_64)
|
||||
- `release.yml` — tag-triggered AAR release
|
||||
|
||||
### Fixed (Phase 8 — BUG-1 through BUG-6)
|
||||
- BUG-1: Dictionary loading moved from `onInit()` callback to `onCreate()` (eliminated infinite loop)
|
||||
- BUG-2: Keyboard layout now uses even key distribution
|
||||
- BUG-3: Dark theme with explicit colors (hardcoded in `SampleKeyboardView`)
|
||||
- BUG-4: `maxDTWFloor = 3.0` for single-candidate normalization
|
||||
- BUG-5: Frequency weight set to `α = 0.30` to balance shape vs. frequency
|
||||
- BUG-6: Dictionary expanded from 10 to 302 words
|
||||
@@ -0,0 +1,99 @@
|
||||
# Contributing to libswipetype
|
||||
|
||||
Thank you for your interest in contributing to the open-source libswipetype!
|
||||
|
||||
## Branch Naming Convention
|
||||
|
||||
All branches must follow this naming pattern:
|
||||
|
||||
| Prefix | Usage | Example |
|
||||
|--------|-------|---------|
|
||||
| `feature/` | New functionality | `feature/dtw-scoring` |
|
||||
| `fix/` | Bug fixes | `fix/path-normalization-crash` |
|
||||
| `adapter/` | Adapter work | `adapter/heliboard-jni-bridge` |
|
||||
| `docs/` | Documentation only | `docs/onboarding-guide` |
|
||||
| `test/` | Test additions/fixes | `test/scorer-edge-cases` |
|
||||
| `refactor/` | Code restructuring | `refactor/engine-pimpl` |
|
||||
|
||||
Branch names use lowercase with hyphens. No underscores, no camelCase.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types:**
|
||||
- `feat` — New feature
|
||||
- `fix` — Bug fix
|
||||
- `docs` — Documentation changes
|
||||
- `test` — Adding or fixing tests
|
||||
- `build` — Build system changes (CMake, Gradle, CI)
|
||||
- `refactor` — Code restructuring without behavior change
|
||||
- `perf` — Performance improvement
|
||||
- `chore` — Maintenance tasks
|
||||
|
||||
**Scopes:** `core`, `android`, `heliboard`, `sample`, `ci`, `docs`
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(core): implement DTW scoring with Sakoe-Chiba band
|
||||
fix(android): prevent JNI crash on null gesture path
|
||||
docs(heliboard): add integration guide for HeliBoard v2.x
|
||||
test(core): add edge case tests for single-point paths
|
||||
build(ci): add arm64-v8a to CI build matrix
|
||||
```
|
||||
|
||||
## Module Ownership
|
||||
|
||||
| Module | Primary Owner | Review Required For |
|
||||
|--------|---------------|---------------------|
|
||||
| `swipetype-core/include/swipetype/` | Developer A | **All changes** (joint review) |
|
||||
| `swipetype-core/src/` | Developer A | Internal — single review OK |
|
||||
| `swipetype-core/tests/` | Developer A | Internal — single review OK |
|
||||
| `swipetype-android/` (API interfaces) | Developer B | **Interface changes** (joint review) |
|
||||
| `swipetype-android/` (internals) | Developer B | Internal — single review OK |
|
||||
| `adapters/heliboard/` | Developer B | Single review OK |
|
||||
| `sample-app/` | Developer B | Single review OK |
|
||||
| `.github/workflows/` | Both | **All changes** (joint review) |
|
||||
|
||||
### Stable API Rule
|
||||
|
||||
Files in `swipetype-core/include/swipetype/` and the `SwipeTypeAdapter.java` / `SwipeTypeEngine.java` interfaces are **stable API surfaces**. Any changes to these files require:
|
||||
|
||||
1. A PR with the `api-change` label
|
||||
2. Approval from both developers
|
||||
3. A rationale comment explaining why the change is necessary
|
||||
4. Updated documentation in `docs/API.md`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a branch following the naming convention
|
||||
2. Make your changes with conventional commit messages
|
||||
3. Ensure all tests pass locally (`scripts/run_tests.sh`)
|
||||
4. Push and open a PR using the template
|
||||
5. Request review from the appropriate owner(s)
|
||||
6. Address review feedback
|
||||
7. Squash-merge when approved
|
||||
|
||||
## Development Setup
|
||||
|
||||
See [docs/ONBOARDING.md](docs/ONBOARDING.md) for complete setup instructions.
|
||||
|
||||
## Clean-Room Notice
|
||||
|
||||
This project observes HeliBoard's JNI call signatures to ensure compatibility but does NOT copy any HeliBoard implementation code. All algorithms and implementations are original work. When documenting HeliBoard interface compatibility:
|
||||
|
||||
- Reference only public method signatures (names, parameter types, return types)
|
||||
- Never copy implementation logic from HeliBoard or AOSP LatinIME
|
||||
- Document the source of any interface information as "observed from public API"
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the Apache License 2.0.
|
||||
@@ -0,0 +1,177 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship made available under
|
||||
the License, as indicated by a copyright notice that is included in
|
||||
or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which
|
||||
the editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and its Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean, as submitted to the Licensor for inclusion
|
||||
in the Work by the copyright owner or by an individual or Legal Entity
|
||||
authorized to submit on behalf of the copyright owner. For the purposes
|
||||
of this definition, "submitted" means any form of electronic, verbal,
|
||||
or written communication sent to the Licensor or its representatives,
|
||||
including but not limited to communication on electronic mailing lists,
|
||||
source code control systems, and issue tracking systems that are managed
|
||||
by, or on behalf of, the Licensor for the purpose of recording and
|
||||
discussing the Work, but excluding communication that is conspicuously
|
||||
marked or designated in writing by the copyright owner as "Not a
|
||||
Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any Legal Entity on behalf of
|
||||
whom a Contribution has been received by the Licensor and included
|
||||
within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by the combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a cross-claim
|
||||
or counterclaim in a lawsuit) alleging that the Work or any
|
||||
Contribution embodied within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under
|
||||
this License for that Work shall terminate as of the date such
|
||||
litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, You must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, in
|
||||
at least one of the following places: within a NOTICE text
|
||||
file distributed as part of the Derivative Works; within
|
||||
the Source form or documentation, if provided along with the
|
||||
Derivative Works; or, within a display generated by the
|
||||
Derivative Works, if and wherever such third-party notices
|
||||
normally appear. The contents of the NOTICE file are for
|
||||
informational purposes only and do not modify the License.
|
||||
You may add Your own attribution notices within Derivative
|
||||
Works that You distribute, alongside or in addition to the
|
||||
NOTICE text from the Work, provided that such additional
|
||||
attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own license statement for Your modifications and
|
||||
may provide additional grant of rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the
|
||||
Contribution, either alone or together with such modifications.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or reproducing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or exemplary damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (even if such Contributor has been advised of the possibility
|
||||
of such damages).
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may offer only
|
||||
conditions consistent with this License.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 libswipetype Contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,180 @@
|
||||
# libswipetype
|
||||
|
||||
An open-source, keyboard-agnostic glide (swipe) typing engine.
|
||||
|
||||
**libswipetype** provides accurate gesture-based word recognition for soft keyboards on Android. The core algorithm is written in portable C++17 with no external dependencies. An Android JNI wrapper and a reference HeliBoard adapter are included.
|
||||
|
||||
> **Status:** Pre-release — actively under development.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Keyboard App (HeliBoard, custom, …) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Adapter Layer (Java) │
|
||||
│ adapters/heliboard/ ←── SwipeTypeAdapter iface │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ swipetype-android (JNI bridge) │
|
||||
│ SwipeTypeEngine.java ↔ GestureLibJNI.cpp │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ swipetype-core (pure C++17) │
|
||||
│ PathProcessor → IdealPathGen → Scorer │
|
||||
│ DictionaryLoader GestureEngine │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layers
|
||||
|
||||
| Layer | Language | Purpose |
|
||||
|-------|----------|---------|
|
||||
| **swipetype-core** | C++17 | Gesture recognition algorithms, dictionary loading |
|
||||
| **swipetype-android** | Java + JNI | Android library wrapping swipetype-core |
|
||||
| **adapters/heliboard** | Java + JNI | HeliBoard-specific integration adapter |
|
||||
| **sample-app** | Java | Minimal test keyboard app |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- CMake 3.18+
|
||||
- C++17 compiler (GCC 9+ or Clang 10+)
|
||||
- Android Studio Hedgehog+ (for Android modules)
|
||||
- Android NDK r25+ (installed via SDK Manager)
|
||||
|
||||
### Build & Test — Core Library (Desktop)
|
||||
|
||||
```bash
|
||||
cd swipetype-core
|
||||
mkdir build && cd build
|
||||
cmake .. -DSWIPETYPE_BUILD_TESTS=ON
|
||||
cmake --build . -j$(nproc)
|
||||
ctest --output-on-failure
|
||||
```
|
||||
|
||||
### Build — Android Library
|
||||
|
||||
```bash
|
||||
./gradlew :swipetype-android:assembleRelease
|
||||
```
|
||||
|
||||
The AAR is produced at `swipetype-android/build/outputs/aar/`.
|
||||
|
||||
### Build — HeliBoard Adapter
|
||||
|
||||
```bash
|
||||
./gradlew :adapters:heliboard:assembleRelease
|
||||
```
|
||||
|
||||
### Generate a Dictionary
|
||||
|
||||
```bash
|
||||
python3 scripts/gen_dict.py test-data/en-us-sample.tsv test-data/en-us-sample.glide --lang en-US
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
### Using the Android Library
|
||||
|
||||
```java
|
||||
import dev.dettmer.swipetype.android.*;
|
||||
|
||||
// 1. Create engine & adapter
|
||||
SwipeTypeEngine engine = new SwipeTypeEngine();
|
||||
MyAdapter adapter = new MyAdapter(); // implements SwipeTypeAdapter
|
||||
|
||||
// 2. Initialize and load dictionary
|
||||
engine.init(context, adapter);
|
||||
InputStream dictStream = context.getResources().openRawResource(R.raw.en_us_sample);
|
||||
engine.loadDictionary("en-US", dictStream);
|
||||
|
||||
// 3. Recognize gestures (results delivered via adapter callback)
|
||||
List<GesturePoint> points = collectTouchPoints();
|
||||
engine.processGesture(points);
|
||||
|
||||
// In your adapter's onCandidatesReady():
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
String word = candidates.get(0).word; // public field
|
||||
float confidence = candidates.get(0).confidence; // public field
|
||||
}
|
||||
```
|
||||
|
||||
### Writing a Custom Adapter
|
||||
|
||||
Implement `SwipeTypeAdapter`:
|
||||
|
||||
```java
|
||||
public class MyKeyboardAdapter implements SwipeTypeAdapter {
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
// Engine is ready — load dictionary, etc.
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
// Return your keyboard's key positions (in dp)
|
||||
return new KeyboardLayoutDescriptor("en-US", keys, width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
// Display candidates to the user
|
||||
String topWord = candidates.get(0).word;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
Log.e("Adapter", "Error: " + error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [docs/HOW_TO_WRITE_AN_ADAPTER.md](docs/HOW_TO_WRITE_AN_ADAPTER.md) for a full guide.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
libswipetype/
|
||||
├── swipetype-core/ # Pure C++17 engine
|
||||
│ ├── include/swipetype/ # Public headers
|
||||
│ ├── src/ # Implementation
|
||||
│ └── tests/ # Google Test suite
|
||||
├── swipetype-android/ # Android JNI wrapper
|
||||
│ ├── src/main/java/ # Java API
|
||||
│ └── src/main/cpp/ # JNI bridge
|
||||
├── adapters/heliboard/ # HeliBoard reference adapter
|
||||
├── sample-app/ # Minimal test app
|
||||
├── scripts/ # gen_dict.py, run_tests.sh
|
||||
├── test-data/ # Sample dictionaries & gesture scenarios
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Algorithm Overview
|
||||
|
||||
1. **Preprocess** — Deduplicate → resample to 64 evenly-spaced points → normalize to [0, 1]
|
||||
2. **Filter** — Select dictionary words matching the gesture's start key, end key, and estimated length
|
||||
3. **Generate ideal paths** — For each candidate word, generate the "ideal" gesture path through key centers
|
||||
4. **Score** — Compare gesture path against ideal paths using Dynamic Time Warping (DTW) with Sakoe-Chiba band constraint
|
||||
5. **Rank** — Combine DTW distance with word frequency: `finalScore = (1 − α) · shapeScore + α · freqScore`
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Budget |
|
||||
|--------|--------|
|
||||
| Recognition latency | < 50ms on Snapdragon 665 |
|
||||
| Memory (50k words) | < 30 MB |
|
||||
| Init time | < 200ms |
|
||||
| Minimum Android API | 21 (Lollipop) |
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, branch naming, and code review process.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Made with ❤️ by <a href="https://github.com/inventory69">inventory69</a> & <a href="https://github.com/Hyphonical">Hyphonical</a></p>
|
||||
@@ -0,0 +1,17 @@
|
||||
cmake_minimum_required(VERSION 3.18)
|
||||
project(heliboard-adapter-jni VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
add_library(heliboard_glide_jni SHARED
|
||||
src/main/cpp/HeliboardJNIBridge.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(heliboard_glide_jni
|
||||
PRIVATE
|
||||
log
|
||||
)
|
||||
|
||||
# Note: For the drop-in .so approach, this would also link against swipetype-core.
|
||||
# For the MVP Java-level approach, the native bridge is optional.
|
||||
@@ -0,0 +1,64 @@
|
||||
# HeliBoard Adapter
|
||||
|
||||
Reference adapter for integrating the libswipetype with [HeliBoard](https://github.com/Helium314/HeliBoard).
|
||||
|
||||
## Integration Approaches
|
||||
|
||||
### Approach 1: Java-Level Integration (Recommended for MVP)
|
||||
|
||||
Add this adapter as a Gradle dependency in your HeliBoard fork:
|
||||
|
||||
1. Add to HeliBoard's `settings.gradle`:
|
||||
```gradle
|
||||
include ':swipetype-android', ':adapters:heliboard'
|
||||
```
|
||||
|
||||
2. Add dependency in HeliBoard's `app/build.gradle`:
|
||||
```gradle
|
||||
implementation project(':adapters:heliboard')
|
||||
```
|
||||
|
||||
3. In HeliBoard's `LatinIME.java`, initialize the adapter:
|
||||
```java
|
||||
HeliboardSwipeTypeAdapter glideAdapter = new HeliboardSwipeTypeAdapter(
|
||||
new HeliboardSwipeTypeAdapter.CandidateCallback() {
|
||||
@Override
|
||||
public void onCandidates(String[] words, int[] scores, int count) {
|
||||
// Convert to SuggestedWordInfo and show in suggestion bar
|
||||
}
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
Log.e("LatinIME", "Glide error: " + message);
|
||||
}
|
||||
}
|
||||
);
|
||||
glideAdapter.initialize(this, openDictionaryStream());
|
||||
```
|
||||
|
||||
4. In HeliBoard's gesture input handler, route to the adapter:
|
||||
```java
|
||||
if (isGestureInput) {
|
||||
glideAdapter.onGestureInput(
|
||||
inputPointers.getXCoordinates(),
|
||||
inputPointers.getYCoordinates(),
|
||||
inputPointers.getTimes(),
|
||||
inputPointers.getPointerSize()
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Approach 2: Drop-in .so Replacement (Future)
|
||||
|
||||
Build the library as `libjni_latinime.so` and place it in HeliBoard's files directory.
|
||||
This requires the `HeliboardJNIBridge.cpp` to be completed with full JNI registration.
|
||||
|
||||
## HeliBoard Version Compatibility
|
||||
|
||||
This adapter is designed against HeliBoard's main branch as of 2026-02-18. Key interfaces observed:
|
||||
|
||||
- `BinaryDictionary.getSuggestionsNative()` — gesture/typing suggestion entry point
|
||||
- `ProximityInfo.setProximityInfoNative()` — keyboard layout setup
|
||||
- `NativeSuggestOptions` — gesture mode flag (index 0)
|
||||
- `InputPointers` — touch coordinate container
|
||||
|
||||
If HeliBoard changes these interfaces, the adapter may need updating. See the version compatibility section in the main README.
|
||||
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'dev.dettmer.swipetype.adapters.heliboard'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk 21
|
||||
targetSdk 34
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags '-std=c++17 -O2'
|
||||
}
|
||||
}
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path 'CMakeLists.txt'
|
||||
version '3.18.1+'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':swipetype-android')
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="dev.dettmer.swipetype.adapters.heliboard">
|
||||
<!-- Adapter module — no application or activities -->
|
||||
</manifest>
|
||||
@@ -0,0 +1,238 @@
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <android/log.h>
|
||||
|
||||
/**
|
||||
* @file HeliboardJNIBridge.cpp
|
||||
* @brief JNI bridge that exposes the exact method signatures HeliBoard expects.
|
||||
*
|
||||
* This file is used for the "drop-in .so replacement" integration strategy.
|
||||
* It registers native methods with the same class paths and signatures that
|
||||
* HeliBoard's BinaryDictionary.java, ProximityInfo.java, and
|
||||
* DicTraverseSession.java expect.
|
||||
*
|
||||
* IMPORTANT: This bridge is for FUTURE use when we want to create a standalone
|
||||
* .so that HeliBoard can load without source code changes. For the MVP,
|
||||
* integration is done via HeliboardSwipeTypeAdapter.java at the Java level.
|
||||
*
|
||||
* HeliBoard's JNI methods are registered against these Java classes:
|
||||
* - com.android.inputmethod.latin.BinaryDictionary
|
||||
* - com.android.inputmethod.keyboard.ProximityInfo
|
||||
* - com.android.inputmethod.latin.DicTraverseSession
|
||||
*
|
||||
* The critical gesture-related method is getSuggestionsNative, which
|
||||
* HeliBoard calls with isGesture=true when the user is swiping.
|
||||
*/
|
||||
|
||||
#define LOG_TAG "HeliboardBridge"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
// ============================================================================
|
||||
// HeliBoard's Expected JNI Signatures
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
* The following documents HeliBoard's native method signatures.
|
||||
* Source: com.android.inputmethod.latin.BinaryDictionary
|
||||
*
|
||||
* getSuggestionsNative signature:
|
||||
* (JJJ[I[I[I[I[II[I[[I[ZI[I[I[I[I[I[I[F)V
|
||||
*
|
||||
* Parameters:
|
||||
* jlong dict — native dictionary handle
|
||||
* jlong proximityInfo — native ProximityInfo handle
|
||||
* jlong traverseSession — native DicTraverseSession handle
|
||||
* jintArray xCoordinates — touch X coords (pixels), or key indices for typing
|
||||
* jintArray yCoordinates — touch Y coords (pixels)
|
||||
* jintArray times — timestamps (ms)
|
||||
* jintArray pointerIds — multi-touch pointer IDs
|
||||
* jintArray inputCodePoints — char codes for typing mode
|
||||
* jint inputSize — number of input points/chars
|
||||
* jintArray suggestOptions — NativeSuggestOptions array (index 0 = isGesture)
|
||||
* jobjectArray prevWordCodePointArrays — previous words for n-gram
|
||||
* jbooleanArray isBeginningOfSentenceArray — sentence boundary flags
|
||||
* jint prevWordCount — number of previous words
|
||||
* jintArray outSuggestionCount — output: number of suggestions [1]
|
||||
* jintArray outCodePoints — output: suggestion chars (flattened)
|
||||
* jintArray outScores — output: suggestion scores
|
||||
* jintArray outSpaceIndices — output: space indices in multi-word
|
||||
* jintArray outTypes — output: suggestion types
|
||||
* jintArray outAutoCommitFirstWordConfidence — output: auto-commit confidence [1]
|
||||
* jfloatArray inOutWeightOfLangModelVsSpatialModel — weight param [1]
|
||||
*/
|
||||
|
||||
extern "C" {
|
||||
|
||||
/**
|
||||
* HeliBoard's getSuggestionsNative — the critical gesture recognition entry point.
|
||||
*
|
||||
* When suggestOptions[0] (isGesture) is 1:
|
||||
* - xCoordinates/yCoordinates contain touch trail coordinates
|
||||
* - inputSize is the number of touch points
|
||||
* - We intercept and route to our SwipeTypeEngine
|
||||
*
|
||||
* When suggestOptions[0] is 0:
|
||||
* - This is a typing suggestion request
|
||||
* - We would need to forward to HeliBoard's original implementation
|
||||
* - For the drop-in replacement, we need the original dictionary logic too
|
||||
*
|
||||
* OUTPUT CONTRACT (what HeliBoard expects):
|
||||
* - outSuggestionCount[0] = number of suggestions
|
||||
* - outCodePoints: each suggestion is MAX_WORD_LENGTH (64) ints, packed sequentially
|
||||
* e.g., suggestion 0 at [0..63], suggestion 1 at [64..127], etc.
|
||||
* Each int is a Unicode code point. Terminated by 0.
|
||||
* - outScores: score per suggestion (higher = better, range 0-2000000000)
|
||||
* - outTypes: suggestion type flags (0 = regular word)
|
||||
*/
|
||||
static void heliboard_getSuggestionsNative(
|
||||
JNIEnv* env, jclass /*clazz*/,
|
||||
jlong /*dict*/, jlong /*proximityInfo*/, jlong /*traverseSession*/,
|
||||
jintArray xCoordinates, jintArray yCoordinates,
|
||||
jintArray times, jintArray /*pointerIds*/,
|
||||
jintArray /*inputCodePoints*/, jint inputSize,
|
||||
jintArray suggestOptions,
|
||||
jobjectArray /*prevWordCodePointArrays*/,
|
||||
jbooleanArray /*isBeginningOfSentenceArray*/, jint /*prevWordCount*/,
|
||||
jintArray outSuggestionCount, jintArray /*outCodePoints*/,
|
||||
jintArray /*outScores*/, jintArray /*outSpaceIndices*/,
|
||||
jintArray /*outTypes*/, jintArray /*outAutoCommitFirstWordConfidence*/,
|
||||
jfloatArray /*inOutWeightOfLangModelVsSpatialModel*/) {
|
||||
|
||||
// Check gesture mode flag
|
||||
jint* opts = env->GetIntArrayElements(suggestOptions, nullptr);
|
||||
bool isGesture = (opts[0] == 1);
|
||||
env->ReleaseIntArrayElements(suggestOptions, opts, JNI_ABORT);
|
||||
|
||||
if (!isGesture) {
|
||||
// Typing mode — not handled by this bridge. Zero results.
|
||||
jint* count = env->GetIntArrayElements(outSuggestionCount, nullptr);
|
||||
count[0] = 0;
|
||||
env->ReleaseIntArrayElements(outSuggestionCount, count, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
LOGI("getSuggestionsNative: gesture input, %d points", inputSize);
|
||||
|
||||
// TODO: Route to SwipeTypeEngine for gesture recognition.
|
||||
// For now, return 0 results as a safe placeholder.
|
||||
// Full implementation requires:
|
||||
// 1. A global/static SwipeTypeEngine instance initialized at JNI_OnLoad
|
||||
// 2. Reading x/y/t arrays and building GesturePoint vector
|
||||
// 3. Calling engine.recognize() and writing results to output arrays
|
||||
(void)xCoordinates;
|
||||
(void)yCoordinates;
|
||||
(void)times;
|
||||
|
||||
jint* count = env->GetIntArrayElements(outSuggestionCount, nullptr);
|
||||
count[0] = 0;
|
||||
env->ReleaseIntArrayElements(outSuggestionCount, count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* HeliBoard's setProximityInfoNative.
|
||||
*
|
||||
* This is called when the keyboard layout changes. We capture the key positions
|
||||
* to update our engine's KeyboardLayout.
|
||||
*
|
||||
* @return Native handle for ProximityInfo (we can store our own data here).
|
||||
*/
|
||||
static jlong heliboard_setProximityInfoNative(
|
||||
JNIEnv* env, jclass /*clazz*/,
|
||||
jint displayWidth, jint displayHeight,
|
||||
jint /*gridWidth*/, jint /*gridHeight*/,
|
||||
jint /*mostCommonKeyWidth*/, jint /*mostCommonKeyHeight*/,
|
||||
jintArray /*proximityCharsArray*/,
|
||||
jint keyCount,
|
||||
jintArray keyXCoordinates, jintArray keyYCoordinates,
|
||||
jintArray keyWidths, jintArray keyHeights,
|
||||
jintArray keyCharCodes,
|
||||
jfloatArray /*sweetSpotCenterXs*/, jfloatArray /*sweetSpotCenterYs*/,
|
||||
jfloatArray /*sweetSpotRadii*/) {
|
||||
|
||||
LOGI("setProximityInfoNative: %d keys, display %dx%d", keyCount, displayWidth, displayHeight);
|
||||
|
||||
// Read key arrays
|
||||
jint* xArr = env->GetIntArrayElements(keyXCoordinates, nullptr);
|
||||
jint* yArr = env->GetIntArrayElements(keyYCoordinates, nullptr);
|
||||
jint* wArr = env->GetIntArrayElements(keyWidths, nullptr);
|
||||
jint* hArr = env->GetIntArrayElements(keyHeights, nullptr);
|
||||
jint* cArr = env->GetIntArrayElements(keyCharCodes, nullptr);
|
||||
|
||||
// TODO: Store keyboard layout data and update SwipeTypeEngine.
|
||||
// For now, we log and release.
|
||||
(void)displayWidth; (void)displayHeight; (void)keyCount;
|
||||
(void)xArr; (void)yArr; (void)wArr; (void)hArr; (void)cArr;
|
||||
|
||||
env->ReleaseIntArrayElements(keyXCoordinates, xArr, JNI_ABORT);
|
||||
env->ReleaseIntArrayElements(keyYCoordinates, yArr, JNI_ABORT);
|
||||
env->ReleaseIntArrayElements(keyWidths, wArr, JNI_ABORT);
|
||||
env->ReleaseIntArrayElements(keyHeights, hArr, JNI_ABORT);
|
||||
env->ReleaseIntArrayElements(keyCharCodes, cArr, JNI_ABORT);
|
||||
|
||||
return 0; // placeholder handle
|
||||
}
|
||||
|
||||
/**
|
||||
* HeliBoard's releaseProximityInfoNative.
|
||||
*/
|
||||
static void heliboard_releaseProximityInfoNative(JNIEnv* /*env*/, jclass /*clazz*/, jlong /*info*/) {
|
||||
LOGI("releaseProximityInfoNative");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JNI Registration (for drop-in .so replacement)
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
* Method registration tables.
|
||||
* These map Java method names to our C++ implementations.
|
||||
*
|
||||
* For the factory-based approach (recommended MVP), this registration
|
||||
* is NOT needed — HeliBoard's built-in library handles all JNI registration,
|
||||
* and we only inject a GestureSuggestPolicy.
|
||||
*
|
||||
* Uncomment when implementing Strategy A (drop-in .so replacement):
|
||||
*/
|
||||
|
||||
// static const JNINativeMethod sBinaryDictionaryMethods[] = {
|
||||
// {"getSuggestionsNative", "(JJJ[I[I[I[I[II[I[[I[ZI[I[I[I[I[I[I[F)V",
|
||||
// (void*)heliboard_getSuggestionsNative},
|
||||
// };
|
||||
//
|
||||
// static const JNINativeMethod sProximityInfoMethods[] = {
|
||||
// {"setProximityInfoNative", "(IIIIII[II[I[I[I[I[I[F[F[F)J",
|
||||
// (void*)heliboard_setProximityInfoNative},
|
||||
// {"releaseProximityInfoNative", "(J)V",
|
||||
// (void*)heliboard_releaseProximityInfoNative},
|
||||
// };
|
||||
//
|
||||
// JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
// JNIEnv* env;
|
||||
// if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) return -1;
|
||||
//
|
||||
// jclass binaryDictClass = env->FindClass("com/android/inputmethod/latin/BinaryDictionary");
|
||||
// env->RegisterNatives(binaryDictClass, sBinaryDictionaryMethods,
|
||||
// sizeof(sBinaryDictionaryMethods)/sizeof(sBinaryDictionaryMethods[0]));
|
||||
//
|
||||
// jclass proximityInfoClass = env->FindClass(
|
||||
// "com/android/inputmethod/keyboard/ProximityInfo");
|
||||
// env->RegisterNatives(proximityInfoClass, sProximityInfoMethods,
|
||||
// sizeof(sProximityInfoMethods)/sizeof(sProximityInfoMethods[0]));
|
||||
//
|
||||
// LOGI("HeliBoard glide bridge loaded");
|
||||
// return JNI_VERSION_1_6;
|
||||
// }
|
||||
|
||||
// Stub JNI_OnLoad for library validity (remove when using method registration above)
|
||||
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* /*reserved*/) {
|
||||
JNIEnv* env;
|
||||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
return JNI_ERR;
|
||||
}
|
||||
LOGI("HeliboardJNIBridge loaded (MVP stub)");
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package dev.dettmer.swipetype.adapters.heliboard;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import dev.dettmer.swipetype.android.SwipeTypeAdapter;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeCandidate;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeEngine;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeError;
|
||||
import dev.dettmer.swipetype.android.GesturePoint;
|
||||
import dev.dettmer.swipetype.android.KeyboardLayoutDescriptor;
|
||||
import dev.dettmer.swipetype.android.KeyboardLayoutDescriptor.KeyInfo;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HeliBoard-specific adapter for the libswipetype.
|
||||
*
|
||||
* <p>This adapter translates between HeliBoard's internal keyboard representation
|
||||
* and the generic {@link SwipeTypeAdapter} interface. It serves as the
|
||||
* <strong>reference implementation</strong> for how any keyboard project
|
||||
* integrates the libswipetype.</p>
|
||||
*
|
||||
* <h3>Integration into HeliBoard:</h3>
|
||||
* <ol>
|
||||
* <li>Add swipetype-android and this adapter as Gradle dependencies</li>
|
||||
* <li>Create an instance of this adapter in LatinIME</li>
|
||||
* <li>Call {@link #initialize(Context, InputStream)} with the dictionary</li>
|
||||
* <li>When gesture input is detected, call {@link #onGestureInput(int[], int[], int[], int)}</li>
|
||||
* <li>Receive candidates via the registered callback</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>HeliBoard-Specific Knowledge:</h3>
|
||||
* <ul>
|
||||
* <li>HeliBoard uses ProximityInfo to describe keyboard layout (key coords, dimensions)</li>
|
||||
* <li>Touch coordinates arrive as int arrays (pixels), which we convert to float (dp)</li>
|
||||
* <li>HeliBoard's NativeSuggestOptions.isGesture() flag determines gesture vs. typing mode</li>
|
||||
* <li>Suggestion results are expected as SuggestedWordInfo objects in HeliBoard's format</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class HeliboardSwipeTypeAdapter implements SwipeTypeAdapter {
|
||||
|
||||
private static final String TAG = "HeliboardSwipeTypeAdapter";
|
||||
|
||||
private SwipeTypeEngine engine;
|
||||
private Context context;
|
||||
|
||||
// HeliBoard's keyboard state — updated via setKeyboardLayout()
|
||||
private int displayWidth;
|
||||
private int displayHeight;
|
||||
private float displayDensity = 1.0f;
|
||||
private List<HeliboardKeyInfo> currentKeys = new ArrayList<>();
|
||||
private String currentLanguageTag = "en-US";
|
||||
|
||||
// Callback for delivering results to HeliBoard
|
||||
private CandidateCallback candidateCallback;
|
||||
|
||||
/**
|
||||
* Callback interface for delivering candidates back to HeliBoard.
|
||||
*
|
||||
* <p>HeliBoard should implement this to receive gesture recognition results
|
||||
* and convert them to its internal SuggestedWordInfo format.</p>
|
||||
*/
|
||||
public interface CandidateCallback {
|
||||
/**
|
||||
* Called when gesture recognition produces candidates.
|
||||
*
|
||||
* @param words Array of recognized words, best first
|
||||
* @param scores Array of confidence scores (0-1000000 in HeliBoard's scale)
|
||||
* @param count Number of valid entries in the arrays
|
||||
*/
|
||||
void onCandidates(String[] words, int[] scores, int count);
|
||||
|
||||
/**
|
||||
* Called when an error occurs.
|
||||
*
|
||||
* @param errorMessage Human-readable error description
|
||||
*/
|
||||
void onError(String errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new HeliBoard adapter.
|
||||
*
|
||||
* @param callback Callback for delivering results to HeliBoard
|
||||
*/
|
||||
public HeliboardSwipeTypeAdapter(CandidateCallback callback) {
|
||||
this.candidateCallback = callback;
|
||||
this.engine = new SwipeTypeEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the adapter with a dictionary.
|
||||
*
|
||||
* <p>Call this from HeliBoard's initialization code (e.g., LatinIME.onCreate
|
||||
* or when the dictionary changes).</p>
|
||||
*
|
||||
* @param context Android context
|
||||
* @param dictStream InputStream for the .glide dictionary file
|
||||
* @return true on success
|
||||
*/
|
||||
public boolean initialize(Context context, InputStream dictStream) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.displayDensity = context.getResources().getDisplayMetrics().density;
|
||||
|
||||
engine.init(context, this);
|
||||
return engine.loadDictionary(currentLanguageTag, dictStream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the keyboard layout from HeliBoard's ProximityInfo data.
|
||||
*
|
||||
* <p>Call this whenever the keyboard layout changes (language switch,
|
||||
* rotation, etc.).</p>
|
||||
*
|
||||
* <p><strong>HeliBoard call site:</strong> This maps to the data passed to
|
||||
* {@code ProximityInfo.setProximityInfoNative()}, specifically:<br>
|
||||
* - keyXCoordinates, keyYCoordinates: top-left corner of each key (pixels)<br>
|
||||
* - keyWidths, keyHeights: key dimensions (pixels)<br>
|
||||
* - keyCharCodes: Unicode code points for each key<br>
|
||||
* - displayWidth, displayHeight: keyboard dimensions (pixels)</p>
|
||||
*
|
||||
* @param displayWidth Keyboard width in pixels
|
||||
* @param displayHeight Keyboard height in pixels
|
||||
* @param keyCount Number of keys
|
||||
* @param keyXCoordinates Key top-left X coordinates (pixels)
|
||||
* @param keyYCoordinates Key top-left Y coordinates (pixels)
|
||||
* @param keyWidths Key widths (pixels)
|
||||
* @param keyHeights Key heights (pixels)
|
||||
* @param keyCharCodes Unicode code points per key
|
||||
* @param languageTag BCP 47 language tag
|
||||
*/
|
||||
public void setKeyboardLayout(
|
||||
int displayWidth, int displayHeight,
|
||||
int keyCount,
|
||||
int[] keyXCoordinates, int[] keyYCoordinates,
|
||||
int[] keyWidths, int[] keyHeights,
|
||||
int[] keyCharCodes,
|
||||
String languageTag) {
|
||||
|
||||
this.displayWidth = displayWidth;
|
||||
this.displayHeight = displayHeight;
|
||||
this.currentLanguageTag = languageTag;
|
||||
|
||||
currentKeys.clear();
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
float centerXDp = (keyXCoordinates[i] + keyWidths[i] / 2.0f) / displayDensity;
|
||||
float centerYDp = (keyYCoordinates[i] + keyHeights[i] / 2.0f) / displayDensity;
|
||||
float widthDp = keyWidths[i] / displayDensity;
|
||||
float heightDp = keyHeights[i] / displayDensity;
|
||||
currentKeys.add(new HeliboardKeyInfo(
|
||||
keyCharCodes[i], centerXDp, centerYDp, widthDp, heightDp));
|
||||
}
|
||||
|
||||
if (engine != null && engine.isInitialized()) {
|
||||
engine.notifyLayoutChanged();
|
||||
}
|
||||
|
||||
Log.i(TAG, "Layout updated: " + keyCount + " keys, " + languageTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process gesture input from HeliBoard.
|
||||
*
|
||||
* <p><strong>HeliBoard call site:</strong> This maps to the data from
|
||||
* {@code InputPointers} which HeliBoard passes to {@code getSuggestionsNative()}
|
||||
* when {@code NativeSuggestOptions.isGesture() == true}.</p>
|
||||
*
|
||||
* <p>HeliBoard provides touch coordinates as int arrays in pixel coordinates.
|
||||
* This method converts them to dp and forwards to the engine.</p>
|
||||
*
|
||||
* @param xCoordinates X touch coordinates (pixels)
|
||||
* @param yCoordinates Y touch coordinates (pixels)
|
||||
* @param times Timestamps (ms since first event)
|
||||
* @param pointCount Number of valid points in the arrays
|
||||
*/
|
||||
public void onGestureInput(int[] xCoordinates, int[] yCoordinates,
|
||||
int[] times, int pointCount) {
|
||||
List<GesturePoint> points = new ArrayList<>(pointCount);
|
||||
for (int i = 0; i < pointCount; i++) {
|
||||
float xDp = xCoordinates[i] / displayDensity;
|
||||
float yDp = yCoordinates[i] / displayDensity;
|
||||
long timestamp = times[i]; // already in ms
|
||||
points.add(new GesturePoint(xDp, yDp, timestamp));
|
||||
}
|
||||
engine.processGesture(points);
|
||||
// Results come back via onCandidatesReady() callback
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SwipeTypeAdapter interface implementation
|
||||
// ========================================================================
|
||||
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
Log.i(TAG, "Glide engine initialized for HeliBoard");
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
List<KeyInfo> keys = new ArrayList<>(currentKeys.size());
|
||||
for (HeliboardKeyInfo key : currentKeys) {
|
||||
String label = new String(Character.toChars(key.charCode));
|
||||
keys.add(new KeyInfo(
|
||||
label, key.charCode,
|
||||
key.centerX, key.centerY,
|
||||
key.width, key.height));
|
||||
}
|
||||
float widthDp = displayWidth / displayDensity;
|
||||
float heightDp = displayHeight / displayDensity;
|
||||
return new KeyboardLayoutDescriptor(currentLanguageTag, keys, widthDp, heightDp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
if (candidateCallback == null) return;
|
||||
|
||||
int count = candidates.size();
|
||||
String[] words = new String[count];
|
||||
int[] scores = new int[count];
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
words[i] = candidates.get(i).word;
|
||||
scores[i] = (int) (candidates.get(i).confidence * 1_000_000);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Delivering " + count + " candidates to HeliBoard");
|
||||
candidateCallback.onCandidates(words, scores, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
Log.e(TAG, "Glide error: " + error.message);
|
||||
if (candidateCallback != null) {
|
||||
candidateCallback.onError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the adapter and engine.
|
||||
*/
|
||||
public void shutdown() {
|
||||
if (engine != null) {
|
||||
engine.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Internal data class
|
||||
// ========================================================================
|
||||
|
||||
/** Internal representation of a HeliBoard key (in dp coordinates). */
|
||||
private static class HeliboardKeyInfo {
|
||||
final int charCode;
|
||||
final float centerX;
|
||||
final float centerY;
|
||||
final float width;
|
||||
final float height;
|
||||
|
||||
HeliboardKeyInfo(int charCode, float centerX, float centerY,
|
||||
float width, float height) {
|
||||
this.charCode = charCode;
|
||||
this.centerX = centerX;
|
||||
this.centerY = centerY;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package dev.swipetype.adapters.heliboard;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import dev.swipetype.android.SwipeTypeAdapter;
|
||||
import dev.swipetype.android.SwipeTypeCandidate;
|
||||
import dev.swipetype.android.SwipeTypeEngine;
|
||||
import dev.swipetype.android.SwipeTypeError;
|
||||
import dev.swipetype.android.GesturePoint;
|
||||
import dev.swipetype.android.KeyboardLayoutDescriptor;
|
||||
import dev.swipetype.android.KeyboardLayoutDescriptor.KeyInfo;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HeliBoard-specific adapter for the libswipetype.
|
||||
*
|
||||
* <p>This adapter translates between HeliBoard's internal keyboard representation
|
||||
* and the generic {@link SwipeTypeAdapter} interface. It serves as the
|
||||
* <strong>reference implementation</strong> for how any keyboard project
|
||||
* integrates the libswipetype.</p>
|
||||
*
|
||||
* <h3>Integration into HeliBoard:</h3>
|
||||
* <ol>
|
||||
* <li>Add swipetype-android and this adapter as Gradle dependencies</li>
|
||||
* <li>Create an instance of this adapter in LatinIME</li>
|
||||
* <li>Call {@link #initialize(Context, InputStream)} with the dictionary</li>
|
||||
* <li>When gesture input is detected, call {@link #onGestureInput(int[], int[], int[], int)}</li>
|
||||
* <li>Receive candidates via the registered callback</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>HeliBoard-Specific Knowledge:</h3>
|
||||
* <ul>
|
||||
* <li>HeliBoard uses ProximityInfo to describe keyboard layout (key coords, dimensions)</li>
|
||||
* <li>Touch coordinates arrive as int arrays (pixels), which we convert to float (dp)</li>
|
||||
* <li>HeliBoard's NativeSuggestOptions.isGesture() flag determines gesture vs. typing mode</li>
|
||||
* <li>Suggestion results are expected as SuggestedWordInfo objects in HeliBoard's format</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class HeliboardSwipeTypeAdapter implements SwipeTypeAdapter {
|
||||
|
||||
private static final String TAG = "HeliboardSwipeTypeAdapter";
|
||||
|
||||
private SwipeTypeEngine engine;
|
||||
private Context context;
|
||||
|
||||
// HeliBoard's keyboard state — updated via setKeyboardLayout()
|
||||
private int displayWidth;
|
||||
private int displayHeight;
|
||||
private float displayDensity = 1.0f;
|
||||
private List<HeliboardKeyInfo> currentKeys = new ArrayList<>();
|
||||
private String currentLanguageTag = "en-US";
|
||||
|
||||
// Callback for delivering results to HeliBoard
|
||||
private CandidateCallback candidateCallback;
|
||||
|
||||
/**
|
||||
* Callback interface for delivering candidates back to HeliBoard.
|
||||
*
|
||||
* <p>HeliBoard should implement this to receive gesture recognition results
|
||||
* and convert them to its internal SuggestedWordInfo format.</p>
|
||||
*/
|
||||
public interface CandidateCallback {
|
||||
/**
|
||||
* Called when gesture recognition produces candidates.
|
||||
*
|
||||
* @param words Array of recognized words, best first
|
||||
* @param scores Array of confidence scores (0-1000000 in HeliBoard's scale)
|
||||
* @param count Number of valid entries in the arrays
|
||||
*/
|
||||
void onCandidates(String[] words, int[] scores, int count);
|
||||
|
||||
/**
|
||||
* Called when an error occurs.
|
||||
*
|
||||
* @param errorMessage Human-readable error description
|
||||
*/
|
||||
void onError(String errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new HeliBoard adapter.
|
||||
*
|
||||
* @param callback Callback for delivering results to HeliBoard
|
||||
*/
|
||||
public HeliboardSwipeTypeAdapter(CandidateCallback callback) {
|
||||
this.candidateCallback = callback;
|
||||
this.engine = new SwipeTypeEngine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the adapter with a dictionary.
|
||||
*
|
||||
* <p>Call this from HeliBoard's initialization code (e.g., LatinIME.onCreate
|
||||
* or when the dictionary changes).</p>
|
||||
*
|
||||
* @param context Android context
|
||||
* @param dictStream InputStream for the .glide dictionary file
|
||||
* @return true on success
|
||||
*/
|
||||
public boolean initialize(Context context, InputStream dictStream) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.displayDensity = context.getResources().getDisplayMetrics().density;
|
||||
|
||||
engine.init(context, this);
|
||||
return engine.loadDictionary(currentLanguageTag, dictStream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the keyboard layout from HeliBoard's ProximityInfo data.
|
||||
*
|
||||
* <p>Call this whenever the keyboard layout changes (language switch,
|
||||
* rotation, etc.).</p>
|
||||
*
|
||||
* <p><strong>HeliBoard call site:</strong> This maps to the data passed to
|
||||
* {@code ProximityInfo.setProximityInfoNative()}, specifically:<br>
|
||||
* - keyXCoordinates, keyYCoordinates: top-left corner of each key (pixels)<br>
|
||||
* - keyWidths, keyHeights: key dimensions (pixels)<br>
|
||||
* - keyCharCodes: Unicode code points for each key<br>
|
||||
* - displayWidth, displayHeight: keyboard dimensions (pixels)</p>
|
||||
*
|
||||
* @param displayWidth Keyboard width in pixels
|
||||
* @param displayHeight Keyboard height in pixels
|
||||
* @param keyCount Number of keys
|
||||
* @param keyXCoordinates Key top-left X coordinates (pixels)
|
||||
* @param keyYCoordinates Key top-left Y coordinates (pixels)
|
||||
* @param keyWidths Key widths (pixels)
|
||||
* @param keyHeights Key heights (pixels)
|
||||
* @param keyCharCodes Unicode code points per key
|
||||
* @param languageTag BCP 47 language tag
|
||||
*/
|
||||
public void setKeyboardLayout(
|
||||
int displayWidth, int displayHeight,
|
||||
int keyCount,
|
||||
int[] keyXCoordinates, int[] keyYCoordinates,
|
||||
int[] keyWidths, int[] keyHeights,
|
||||
int[] keyCharCodes,
|
||||
String languageTag) {
|
||||
|
||||
this.displayWidth = displayWidth;
|
||||
this.displayHeight = displayHeight;
|
||||
this.currentLanguageTag = languageTag;
|
||||
|
||||
currentKeys.clear();
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
float centerXDp = (keyXCoordinates[i] + keyWidths[i] / 2.0f) / displayDensity;
|
||||
float centerYDp = (keyYCoordinates[i] + keyHeights[i] / 2.0f) / displayDensity;
|
||||
float widthDp = keyWidths[i] / displayDensity;
|
||||
float heightDp = keyHeights[i] / displayDensity;
|
||||
currentKeys.add(new HeliboardKeyInfo(
|
||||
keyCharCodes[i], centerXDp, centerYDp, widthDp, heightDp));
|
||||
}
|
||||
|
||||
if (engine != null && engine.isInitialized()) {
|
||||
engine.notifyLayoutChanged();
|
||||
}
|
||||
|
||||
Log.i(TAG, "Layout updated: " + keyCount + " keys, " + languageTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process gesture input from HeliBoard.
|
||||
*
|
||||
* <p><strong>HeliBoard call site:</strong> This maps to the data from
|
||||
* {@code InputPointers} which HeliBoard passes to {@code getSuggestionsNative()}
|
||||
* when {@code NativeSuggestOptions.isGesture() == true}.</p>
|
||||
*
|
||||
* <p>HeliBoard provides touch coordinates as int arrays in pixel coordinates.
|
||||
* This method converts them to dp and forwards to the engine.</p>
|
||||
*
|
||||
* @param xCoordinates X touch coordinates (pixels)
|
||||
* @param yCoordinates Y touch coordinates (pixels)
|
||||
* @param times Timestamps (ms since first event)
|
||||
* @param pointCount Number of valid points in the arrays
|
||||
*/
|
||||
public void onGestureInput(int[] xCoordinates, int[] yCoordinates,
|
||||
int[] times, int pointCount) {
|
||||
List<GesturePoint> points = new ArrayList<>(pointCount);
|
||||
for (int i = 0; i < pointCount; i++) {
|
||||
float xDp = xCoordinates[i] / displayDensity;
|
||||
float yDp = yCoordinates[i] / displayDensity;
|
||||
long timestamp = times[i]; // already in ms
|
||||
points.add(new GesturePoint(xDp, yDp, timestamp));
|
||||
}
|
||||
engine.processGesture(points);
|
||||
// Results come back via onCandidatesReady() callback
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SwipeTypeAdapter interface implementation
|
||||
// ========================================================================
|
||||
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
Log.i(TAG, "Glide engine initialized for HeliBoard");
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
List<KeyInfo> keys = new ArrayList<>(currentKeys.size());
|
||||
for (HeliboardKeyInfo key : currentKeys) {
|
||||
String label = new String(Character.toChars(key.charCode));
|
||||
keys.add(new KeyInfo(
|
||||
label, key.charCode,
|
||||
key.centerX, key.centerY,
|
||||
key.width, key.height));
|
||||
}
|
||||
float widthDp = displayWidth / displayDensity;
|
||||
float heightDp = displayHeight / displayDensity;
|
||||
return new KeyboardLayoutDescriptor(currentLanguageTag, keys, widthDp, heightDp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
if (candidateCallback == null) return;
|
||||
|
||||
int count = candidates.size();
|
||||
String[] words = new String[count];
|
||||
int[] scores = new int[count];
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
words[i] = candidates.get(i).word;
|
||||
scores[i] = (int) (candidates.get(i).confidence * 1_000_000);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Delivering " + count + " candidates to HeliBoard");
|
||||
candidateCallback.onCandidates(words, scores, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
Log.e(TAG, "Glide error: " + error.message);
|
||||
if (candidateCallback != null) {
|
||||
candidateCallback.onError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the adapter and engine.
|
||||
*/
|
||||
public void shutdown() {
|
||||
if (engine != null) {
|
||||
engine.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Internal data class
|
||||
// ========================================================================
|
||||
|
||||
/** Internal representation of a HeliBoard key (in dp coordinates). */
|
||||
private static class HeliboardKeyInfo {
|
||||
final int charCode;
|
||||
final float centerX;
|
||||
final float centerY;
|
||||
final float width;
|
||||
final float height;
|
||||
|
||||
HeliboardKeyInfo(int charCode, float centerX, float centerY,
|
||||
float width, float height) {
|
||||
this.charCode = charCode;
|
||||
this.centerX = centerX;
|
||||
this.centerY = centerY;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.2' apply false
|
||||
id 'com.android.library' version '8.2.2' apply false
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
# API Reference — libswipetype
|
||||
|
||||
> **Version:** 0.1.0 | **Language:** C++17 (core) & Java (Android wrapper) | **Updated:** 2026-02
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [C++ Core API](#c-core-api)
|
||||
- [GestureEngine](#gestureengine)
|
||||
- [RawGesturePath / GesturePath](#rawgesturepath--gesturepath)
|
||||
- [GestureCandidate](#gesturecandidate)
|
||||
- [KeyboardLayout / KeyDescriptor](#keyboardlayout--keydescriptor)
|
||||
- [ScoringConfig](#scoringconfig)
|
||||
- [DictionaryLoader](#dictionaryloader)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Constants](#constants)
|
||||
2. [Android (Java) API](#android-java-api)
|
||||
- [SwipeTypeEngine](#swipetypeengine)
|
||||
- [SwipeTypeAdapter](#swipetypeadapter)
|
||||
- [GesturePoint (Java)](#gesturepoint-java)
|
||||
- [KeyboardLayoutDescriptor](#keyboardlayoutdescriptor)
|
||||
- [SwipeTypeCandidate (Java)](#swipetypecandidate-java)
|
||||
- [SwipeTypeError](#swipetypeerror)
|
||||
|
||||
---
|
||||
|
||||
## C++ Core API
|
||||
|
||||
All C++ types live in the `swipetype` namespace.
|
||||
Header root: `swipetype-core/include/swipetype/`
|
||||
|
||||
### GestureEngine
|
||||
|
||||
**Header:** `GestureEngine.h`
|
||||
|
||||
The main entry point for gesture recognition. Orchestrates the full pipeline:
|
||||
normalize → filter → score → rank → return.
|
||||
|
||||
```cpp
|
||||
class GestureEngine {
|
||||
public:
|
||||
GestureEngine();
|
||||
~GestureEngine();
|
||||
|
||||
// Move-only (non-copyable)
|
||||
GestureEngine(GestureEngine&&) noexcept;
|
||||
GestureEngine& operator=(GestureEngine&&) noexcept;
|
||||
|
||||
bool init(const KeyboardLayout& layout, const std::string& dictPath);
|
||||
bool initWithData(const KeyboardLayout& layout,
|
||||
const uint8_t* dictData, size_t dictSize);
|
||||
std::vector<GestureCandidate> recognize(const RawGesturePath& rawPath,
|
||||
int maxCandidates = 8);
|
||||
void shutdown();
|
||||
bool isInitialized() const;
|
||||
bool updateLayout(const KeyboardLayout& layout);
|
||||
void configure(const ScoringConfig& config);
|
||||
void setErrorCallback(ErrorCallback callback);
|
||||
ErrorInfo getLastError() const;
|
||||
};
|
||||
```
|
||||
|
||||
#### `init(layout, dictPath) → bool`
|
||||
|
||||
Initialize the engine with a keyboard layout and a path to a binary `.glide` dictionary file.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `layout` | `const KeyboardLayout&` | Keyboard layout with key positions in dp |
|
||||
| `dictPath` | `const std::string&` | Absolute file path to the `.glide` dictionary |
|
||||
|
||||
**Returns:** `true` on success. On failure, call `getLastError()` for details.
|
||||
|
||||
**Post-condition:** `isInitialized() == true` on success.
|
||||
|
||||
#### `initWithData(layout, dictData, dictSize) → bool`
|
||||
|
||||
Initialize the engine with a keyboard layout and an in-memory dictionary.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `layout` | `const KeyboardLayout&` | Keyboard layout |
|
||||
| `dictData` | `const uint8_t*` | Pointer to raw dictionary bytes |
|
||||
| `dictSize` | `size_t` | Size of dictionary data in bytes |
|
||||
|
||||
**Returns:** `true` on success.
|
||||
|
||||
#### `recognize(rawPath, maxCandidates) → vector<GestureCandidate>`
|
||||
|
||||
Run the recognition pipeline on a raw gesture path.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `rawPath` | `const RawGesturePath&` | — | Raw touch points (≥ 2 points) |
|
||||
| `maxCandidates` | `int` | `8` | Max results. Clamped to [1, 20] |
|
||||
|
||||
**Returns:** Candidates sorted by confidence descending (best first). Empty if engine not initialized, path too short, or no matches found.
|
||||
|
||||
**Pipeline steps:**
|
||||
1. Deduplicate → resample to 64 points → bounding-box normalize
|
||||
2. Determine start/end key characters from first/last touch point
|
||||
3. Filter dictionary by start+end character, then by estimated word length
|
||||
4. Generate ideal path for each candidate word and compute DTW distance
|
||||
5. Normalize DTW scores, apply adaptive frequency weighting
|
||||
6. Sort by confidence and return top N
|
||||
|
||||
#### `shutdown()`
|
||||
|
||||
Release all resources (dictionary, caches). `isInitialized()` returns `false` after this call.
|
||||
|
||||
#### `updateLayout(layout) → bool`
|
||||
|
||||
Hot-swap the keyboard layout (e.g., after device rotation or language switch).
|
||||
Invalidates the ideal path cache. Does not reload the dictionary.
|
||||
|
||||
#### `configure(config)`
|
||||
|
||||
Override scoring parameters. See [ScoringConfig](#scoringconfig).
|
||||
|
||||
#### `setErrorCallback(callback)`
|
||||
|
||||
Register a callback for error notifications. Called synchronously from the thread that encounters the error.
|
||||
|
||||
```cpp
|
||||
engine.setErrorCallback([](const ErrorInfo& err) {
|
||||
std::cerr << "Error " << static_cast<int>(err.code) << ": " << err.message << "\n";
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### RawGesturePath / GesturePath
|
||||
|
||||
**Header:** `GesturePath.h`
|
||||
|
||||
```cpp
|
||||
// Raw input from the keyboard view
|
||||
struct RawGesturePath {
|
||||
std::vector<GesturePoint> points; // dp coordinates, ordered by time
|
||||
bool isEmpty() const; // true if < 2 points
|
||||
size_t size() const;
|
||||
};
|
||||
|
||||
// After normalization (64 points in [0,1] bounding box)
|
||||
struct GesturePath {
|
||||
std::vector<NormalizedPoint> points; // exactly RESAMPLE_COUNT (64)
|
||||
float aspectRatio; // originalWidth / originalHeight
|
||||
float totalArcLength; // in dp, before normalization
|
||||
int32_t startKeyIndex; // index into KeyboardLayout::keys
|
||||
int32_t endKeyIndex; // index into KeyboardLayout::keys
|
||||
bool isValid() const; // true if points.size() == 64
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GestureCandidate
|
||||
|
||||
**Header:** `GestureCandidate.h`
|
||||
|
||||
```cpp
|
||||
struct GestureCandidate {
|
||||
std::string word; // UTF-8 word
|
||||
float confidence; // [0.0, 1.0] — 1.0 = best
|
||||
uint32_t sourceFlags; // bitmask: SOURCE_MAIN_DICT, SOURCE_USER_DICT, etc.
|
||||
float dtwScore; // raw DTW distance (lower = better) — for debugging
|
||||
float frequencyScore; // normalized frequency [0.0, 1.0] — for debugging
|
||||
};
|
||||
```
|
||||
|
||||
**Source flags:**
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `SOURCE_MAIN_DICT` | `0x01` | From the primary dictionary |
|
||||
| `SOURCE_USER_DICT` | `0x02` | From the user dictionary (future) |
|
||||
| `SOURCE_COMPLETION` | `0x04` | Prefix completion (future) |
|
||||
|
||||
---
|
||||
|
||||
### KeyboardLayout / KeyDescriptor
|
||||
|
||||
**Header:** `KeyboardLayout.h`
|
||||
|
||||
```cpp
|
||||
struct KeyDescriptor {
|
||||
std::string label; // e.g. "a", "shift" — display/debug only
|
||||
int32_t codePoint; // Unicode code point. -1 for non-character keys
|
||||
float centerX; // key center X in dp
|
||||
float centerY; // key center Y in dp
|
||||
float width; // key width in dp
|
||||
float height; // key height in dp
|
||||
bool isCharacterKey() const;
|
||||
};
|
||||
|
||||
struct KeyboardLayout {
|
||||
std::string languageTag; // BCP 47 ("en-US")
|
||||
std::vector<KeyDescriptor> keys; // all keys
|
||||
float layoutWidth; // total keyboard width in dp
|
||||
float layoutHeight; // total keyboard height in dp
|
||||
|
||||
int32_t findNearestKey(float x, float y) const; // nearest char key index
|
||||
int32_t findKeyByCodePoint(int32_t codePoint) const; // by code point (case-insensitive)
|
||||
bool isValid() const; // ≥ 1 character key
|
||||
};
|
||||
```
|
||||
|
||||
**Coordinate system:** Origin is the top-left corner of the keyboard. All values are in density-independent pixels (dp). The same dp coordinates must be used for both the layout and the gesture touch points.
|
||||
|
||||
---
|
||||
|
||||
### ScoringConfig
|
||||
|
||||
**Header:** `SwipeTypeTypes.h`
|
||||
|
||||
```cpp
|
||||
struct ScoringConfig {
|
||||
int resampleCount = 64; // points after resampling
|
||||
float minPointDistance = 2.0f; // dedup threshold (dp)
|
||||
float dtwBandwidthRatio = 0.10f; // Sakoe-Chiba band = ceil(0.10 * 64) = 6
|
||||
float frequencyWeight = 0.30f; // α: weight of frequency in final score
|
||||
int maxCandidatesEvaluated = 20; // hard cap on evaluated candidates
|
||||
float lengthFilterTolerance = 3.0f; // ± tolerance for word-length filter
|
||||
float maxDTWFloor = 3.0f; // absolute floor for DTW normalization
|
||||
};
|
||||
```
|
||||
|
||||
Pass a modified config to `GestureEngine::configure()` to tune scoring behavior.
|
||||
|
||||
---
|
||||
|
||||
### DictionaryLoader
|
||||
|
||||
**Header:** `DictionaryLoader.h`
|
||||
|
||||
```cpp
|
||||
struct DictionaryEntry {
|
||||
std::string word; // UTF-8 word
|
||||
uint32_t frequency; // higher = more common
|
||||
uint8_t flags; // DICT_FLAG_PROPER_NOUN, DICT_FLAG_PROFANITY
|
||||
};
|
||||
|
||||
class DictionaryLoader {
|
||||
public:
|
||||
bool load(const std::string& filePath);
|
||||
bool loadFromMemory(const uint8_t* data, size_t size);
|
||||
void unload();
|
||||
|
||||
const std::vector<DictionaryEntry>& getAllEntries() const;
|
||||
std::vector<const DictionaryEntry*> getEntriesStartingWith(char c) const;
|
||||
std::vector<const DictionaryEntry*> getEntriesWithStartEnd(char start, char end) const;
|
||||
uint32_t getMaxFrequency() const;
|
||||
ErrorInfo getLastError() const;
|
||||
};
|
||||
```
|
||||
|
||||
The loader reads the binary `.glide` format (see `scripts/gen_dict.py`).
|
||||
**Thread safety:** Read-only operations are safe after loading. Load/unload are not thread-safe.
|
||||
|
||||
---
|
||||
|
||||
### Error Handling
|
||||
|
||||
```cpp
|
||||
enum class ErrorCode : int {
|
||||
NONE = 0,
|
||||
DICT_NOT_FOUND = 1,
|
||||
DICT_CORRUPT = 2,
|
||||
DICT_VERSION_MISMATCH = 3,
|
||||
LAYOUT_INVALID = 4,
|
||||
PATH_TOO_SHORT = 5,
|
||||
ENGINE_NOT_INITIALIZED = 6,
|
||||
OUT_OF_MEMORY = 7
|
||||
};
|
||||
|
||||
struct ErrorInfo {
|
||||
ErrorCode code;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
using ErrorCallback = std::function<void(const ErrorInfo& error)>;
|
||||
```
|
||||
|
||||
Errors are reported via:
|
||||
1. Return values (`false` from `init()`, empty vector from `recognize()`)
|
||||
2. `getLastError()` — last error info
|
||||
3. `setErrorCallback()` — synchronous callback on error
|
||||
|
||||
---
|
||||
|
||||
### Constants
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `RESAMPLE_COUNT` | `64` | Points after resampling |
|
||||
| `MIN_POINT_DISTANCE_DP` | `2.0f` | Dedup threshold (dp) |
|
||||
| `MIN_GESTURE_POINTS` | `2` | Minimum points for a valid gesture |
|
||||
| `MAX_GESTURE_POINTS` | `10000` | Hard cap on raw input points |
|
||||
| `DTW_BANDWIDTH` | `6` | Sakoe-Chiba band width |
|
||||
| `FREQUENCY_WEIGHT` | `0.30f` | Default α for frequency weighting |
|
||||
| `LENGTH_FILTER_TOLERANCE` | `3.0f` | Word-length filter tolerance (±) |
|
||||
| `MAX_DTW_FLOOR` | `3.0f` | Absolute DTW normalization floor |
|
||||
| `DEFAULT_MAX_CANDIDATES` | `8` | Default max candidates |
|
||||
| `MAX_MAX_CANDIDATES` | `20` | Hard cap on max candidates |
|
||||
| `DICT_MAGIC` | `0x474C4944` | `.glide` file magic ("GLID") |
|
||||
| `DICT_VERSION` | `1` | Current dict format version |
|
||||
| `DICT_HEADER_SIZE` | `32` | Fixed header size in bytes |
|
||||
| `MAX_WORD_LENGTH` | `64` | Max word length (UTF-8 bytes) |
|
||||
|
||||
---
|
||||
|
||||
## Android (Java) API
|
||||
|
||||
Package: `dev.dettmer.swipetype.android`
|
||||
|
||||
### SwipeTypeEngine
|
||||
|
||||
The main Android entry point. Wraps the C++ core via JNI.
|
||||
|
||||
```java
|
||||
public class SwipeTypeEngine {
|
||||
// Lifecycle
|
||||
void init(Context context, SwipeTypeAdapter adapter);
|
||||
boolean loadDictionary(String languageTag, InputStream dictStream);
|
||||
void shutdown();
|
||||
boolean isInitialized();
|
||||
|
||||
// Recognition
|
||||
void processGesture(List<GesturePoint> points);
|
||||
|
||||
// Layout
|
||||
void notifyLayoutChanged();
|
||||
}
|
||||
```
|
||||
|
||||
#### `init(context, adapter)`
|
||||
|
||||
Store the app context and adapter. Does NOT load the dictionary or initialize native code. Call `loadDictionary()` after this.
|
||||
|
||||
#### `loadDictionary(languageTag, dictStream) → boolean`
|
||||
|
||||
Copy the dictionary to the cache directory, query the adapter for the current layout, and initialize the native engine. Calls `adapter.onInit(this)` on success.
|
||||
|
||||
#### `processGesture(points)`
|
||||
|
||||
Run recognition on the given touch points. Results are delivered synchronously via `adapter.onCandidatesReady()` on the calling thread.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `points` | `List<GesturePoint>` | ≥ 2 touch points with dp coordinates |
|
||||
|
||||
#### `notifyLayoutChanged()`
|
||||
|
||||
Re-query the adapter for the current layout and update the native engine. Call after device rotation, language switch, or layout resize.
|
||||
|
||||
#### `shutdown()`
|
||||
|
||||
Release all native resources. Safe to call multiple times.
|
||||
|
||||
**Thread safety:** All public methods are `synchronized`. `processGesture()` can be called from any thread.
|
||||
|
||||
---
|
||||
|
||||
### SwipeTypeAdapter
|
||||
|
||||
The contract between libswipetype and any keyboard app. Every keyboard integration must implement this interface.
|
||||
|
||||
```java
|
||||
public interface SwipeTypeAdapter {
|
||||
void onInit(SwipeTypeEngine engine);
|
||||
KeyboardLayoutDescriptor getKeyboardLayout();
|
||||
void onCandidatesReady(List<SwipeTypeCandidate> candidates);
|
||||
void onError(SwipeTypeError error);
|
||||
}
|
||||
```
|
||||
|
||||
| Method | When called | What to do |
|
||||
|--------|-------------|------------|
|
||||
| `onInit` | After `loadDictionary()` succeeds | Store engine reference if needed |
|
||||
| `getKeyboardLayout` | During init and `notifyLayoutChanged()` | Return current key positions in dp |
|
||||
| `onCandidatesReady` | After `processGesture()` | Show candidates in suggestion bar |
|
||||
| `onError` | On any error | Log and optionally show user message |
|
||||
|
||||
---
|
||||
|
||||
### GesturePoint (Java)
|
||||
|
||||
```java
|
||||
public class GesturePoint {
|
||||
public final float x; // dp
|
||||
public final float y; // dp
|
||||
public final long timestamp; // ms since gesture start
|
||||
|
||||
public GesturePoint(float x, float y, long timestamp);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### KeyboardLayoutDescriptor
|
||||
|
||||
```java
|
||||
public class KeyboardLayoutDescriptor {
|
||||
public final String languageTag;
|
||||
public final List<KeyInfo> keys;
|
||||
public final float layoutWidth; // dp
|
||||
public final float layoutHeight; // dp
|
||||
|
||||
public static class KeyInfo {
|
||||
public final String label;
|
||||
public final int codePoint; // Unicode. -1 for non-char keys
|
||||
public final float centerX; // dp
|
||||
public final float centerY; // dp
|
||||
public final float width; // dp
|
||||
public final float height; // dp
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SwipeTypeCandidate (Java)
|
||||
|
||||
```java
|
||||
public final class SwipeTypeCandidate {
|
||||
public final String word; // recognized word
|
||||
public final float confidence; // [0.0, 1.0]
|
||||
public final int sourceFlags; // bitmask
|
||||
|
||||
public static final int SOURCE_MAIN_DICT = 0x01;
|
||||
public static final int SOURCE_USER_DICT = 0x02;
|
||||
public static final int SOURCE_COMPLETION = 0x04;
|
||||
|
||||
public boolean isFromMainDict();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SwipeTypeError
|
||||
|
||||
```java
|
||||
public class SwipeTypeError {
|
||||
public final int code;
|
||||
public final String message;
|
||||
|
||||
// Predefined error constants:
|
||||
public static final SwipeTypeError DICT_NOT_FOUND;
|
||||
public static final SwipeTypeError DICT_CORRUPT;
|
||||
public static final SwipeTypeError LAYOUT_INVALID;
|
||||
public static final SwipeTypeError PATH_TOO_SHORT;
|
||||
public static final SwipeTypeError ENGINE_NOT_INITIALIZED;
|
||||
public static final SwipeTypeError JNI_ERROR;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Architecture — libswipetype
|
||||
|
||||
> **Version:** 0.1.0 | **Updated:** 2026-02
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
libswipetype is a swipe/glide typing engine that converts finger gestures into word candidates. The system has three layers:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Keyboard App (HeliBoard, FlorisBoard, sample-app, etc.) │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ SwipeTypeAdapter interface
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ swipetype-android (Java + JNI) │
|
||||
│ SwipeTypeEngine → GestureLibJNI.cpp → native calls │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ C ABI (JNI function pointers)
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ swipetype-core (C++17, zero dependencies) │
|
||||
│ GestureEngine → PathProcessor → IdealPathGenerator → │
|
||||
│ Scorer → DictionaryLoader │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Map
|
||||
|
||||
| Module | Language | Artifact | Purpose |
|
||||
|--------|----------|----------|---------|
|
||||
| `swipetype-core/` | C++17 | `libswipetype-core.a` | Pure algorithm library. Zero external dependencies. |
|
||||
| `swipetype-android/` | Java + JNI | `swipetype-android.aar` | Android AAR wrapping the core via JNI |
|
||||
| `adapters/heliboard/` | Java | Source files | Reference adapter for the HeliBoard keyboard |
|
||||
| `sample-app/` | Java | Debug APK | Minimal IME demonstrating the full integration |
|
||||
| `scripts/` | Python | CLI tools | Dictionary generation (`gen_dict.py`) |
|
||||
| `test-data/` | JSON, TSV | Test fixtures | Keyboard layouts, gesture scenarios, word lists |
|
||||
|
||||
---
|
||||
|
||||
## Core Recognition Pipeline
|
||||
|
||||
The recognition pipeline runs inside `GestureEngine::recognize()`. All steps execute synchronously on the calling thread.
|
||||
|
||||
```
|
||||
Raw touch points (dp)
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 1. PathProcessor │ deduplicate → resample(64) → normalize([0,1])
|
||||
│ normalize() │
|
||||
└───────┬───────────┘
|
||||
│ GesturePath (64 NormalizedPoints)
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 2. Start/End Key │ Find nearest key to first & last touch point
|
||||
│ Detection │
|
||||
└───────┬───────────┘
|
||||
│ startChar, endChar
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 3. Candidate │ Filter dictionary by start+end char, then by
|
||||
│ Filtering │ estimated word length (key-transition count ±3)
|
||||
└───────┬───────────┘
|
||||
│ filtered DictionaryEntry list
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 4. Ideal Path │ For each candidate word, generate the "ideal"
|
||||
│ Generation │ swipe path through key centers. Cached per word.
|
||||
│ (IPG) │
|
||||
└───────┬───────────┘
|
||||
│ GesturePath per candidate
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 5. DTW Scoring │ Compute DTW distance between gesture and each
|
||||
│ (Scorer) │ ideal path using Sakoe-Chiba band (W=6)
|
||||
└───────┬───────────┘
|
||||
│ (word, dtwDistance) pairs
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 6. Confidence │ Normalize DTW, apply frequency weight (adaptive α),
|
||||
│ Computation │ compute confidence = 1 - finalScore
|
||||
└───────┬───────────┘
|
||||
│ GestureCandidate list
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ 7. Sort & Prune │ Sort by confidence descending, truncate to maxCandidates
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
vector<GestureCandidate>
|
||||
```
|
||||
|
||||
### Step 1: Path Normalization (PathProcessor)
|
||||
|
||||
**File:** `swipetype-core/src/PathProcessor.cpp`
|
||||
|
||||
Three sub-steps:
|
||||
|
||||
1. **Deduplicate** — Remove consecutive points closer than `MIN_POINT_DISTANCE_DP` (2.0 dp). Always keeps first and last point.
|
||||
2. **Resample** — Equidistant resampling to exactly `RESAMPLE_COUNT` (64) points along the path arc. Uses the $1 Unistroke algorithm (Wobbrock et al., 2007).
|
||||
3. **Bounding-box normalize** — Scale coordinates to [0.0, 1.0] preserving aspect ratio. Normalizes time to [0.0, 1.0].
|
||||
|
||||
Output: `GesturePath` with 64 `NormalizedPoint`s plus metadata (arc length, start/end key indices, aspect ratio).
|
||||
|
||||
### Step 2: Start/End Key Detection
|
||||
|
||||
Uses `KeyboardLayout::findNearestKey()` on the first and last raw touch points (not the resampled points). Maps to lowercase ASCII characters for dictionary lookup.
|
||||
|
||||
### Step 3: Candidate Filtering
|
||||
|
||||
Three-tier filter cascade:
|
||||
|
||||
1. `getEntriesWithStartEnd(startChar, endChar)` — words matching both start and end character
|
||||
2. `getEntriesStartingWith(startChar)` — fallback if tier 1 yields nothing
|
||||
3. `getAllEntries()` — last resort brute-force
|
||||
|
||||
After tier selection, a **word-length filter** eliminates candidates whose character count differs from the estimated word length by more than `LENGTH_FILTER_TOLERANCE` (±3.0). The estimate uses **key-transition counting**: walk the raw gesture path, snap each point to its nearest key, count distinct key transitions.
|
||||
|
||||
### Step 4: Ideal Path Generation (IdealPathGenerator)
|
||||
|
||||
**File:** `swipetype-core/src/IdealPathGenerator.cpp`
|
||||
|
||||
For each dictionary word, generates the "perfect" swipe path by connecting key centers with straight lines, then resampling to 64 points. Duplicate consecutive keys (e.g., "l" in "hello") are collapsed to a single key center.
|
||||
|
||||
Results are **cached** per word (invalidated when the layout changes via `setLayout()`).
|
||||
|
||||
### Step 5: DTW Scoring (Scorer)
|
||||
|
||||
**File:** `swipetype-core/src/Scorer.cpp`
|
||||
|
||||
Computes Dynamic Time Warping (DTW) distance between the gesture path and each ideal path. Uses:
|
||||
|
||||
- **Sakoe-Chiba band** with width `W = ceil(0.10 × 64) = 6` to constrain the warping window
|
||||
- **Two-row rolling array** for O(N × W) time and O(N) space
|
||||
- **Euclidean distance** between NormalizedPoint(x, y) pairs as the local cost function
|
||||
- Final DTW divided by path length (N=64) for per-point normalization
|
||||
|
||||
### Step 6: Confidence Computation
|
||||
|
||||
```
|
||||
maxDTW = max(maxCandidateDTW, MAX_DTW_FLOOR) // floor only for single-candidate; multi uses raw maxDTW
|
||||
normalizedDTW = min(1.0, dtwDistance / maxDTW)
|
||||
normalizedFreq = frequency / maxFrequency
|
||||
|
||||
// Adaptive alpha: proportional scaling based on DTW range
|
||||
effectiveAlpha = α × max(0.1, rawRange / 0.5)
|
||||
|
||||
finalScore = (1 - effectiveAlpha) × normalizedDTW + effectiveAlpha × (1 - normalizedFreq)
|
||||
confidence = 1.0 - clamp(finalScore, 0, 1)
|
||||
```
|
||||
|
||||
### Step 7: Sort & Prune
|
||||
|
||||
Sort by confidence descending. Truncate to `maxCandidates` (default 8, max 20).
|
||||
|
||||
---
|
||||
|
||||
## Android Integration Layer
|
||||
|
||||
### JNI Bridge
|
||||
|
||||
**File:** `swipetype-android/src/main/cpp/GestureLibJNI.cpp`
|
||||
|
||||
Translates Java arrays into C++ types and vice versa. The JNI layer is thin — it only marshals data and forwards to `GestureEngine`.
|
||||
|
||||
| JNI Function | Calls |
|
||||
|-------------|-------|
|
||||
| `nativeInit()` | `GestureEngine::init()` |
|
||||
| `nativeInitWithData()` | `GestureEngine::initWithData()` |
|
||||
| `nativeRecognize()` | `GestureEngine::recognize()` |
|
||||
| `nativeUpdateLayout()` | `GestureEngine::updateLayout()` |
|
||||
| `nativeShutdown()` | `GestureEngine::shutdown()` |
|
||||
|
||||
The native library is named `glide_jni` and loaded via `System.loadLibrary("glide_jni")`.
|
||||
|
||||
### SwipeTypeEngine (Java)
|
||||
|
||||
**File:** `swipetype-android/src/main/java/dev/dettmer/swipetype/android/SwipeTypeEngine.java`
|
||||
|
||||
Manages the lifecycle:
|
||||
1. `init(context, adapter)` — stores context and adapter reference
|
||||
2. `loadDictionary(tag, stream)` — copies stream to cache, queries adapter for layout, calls `nativeInit()`
|
||||
3. `processGesture(points)` — converts `List<GesturePoint>` to arrays, calls `nativeRecognize()`, wraps results in `SwipeTypeCandidate` list, delivers via `adapter.onCandidatesReady()`
|
||||
4. `notifyLayoutChanged()` — re-queries layout and calls `nativeUpdateLayout()`
|
||||
5. `shutdown()` — calls `nativeShutdown()`
|
||||
|
||||
All public methods are `synchronized`.
|
||||
|
||||
---
|
||||
|
||||
## Dictionary Format
|
||||
|
||||
Binary `.glide` format produced by `scripts/gen_dict.py` from a TSV word list.
|
||||
|
||||
### File Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Header (32 bytes, fixed) │
|
||||
├──────────────────────────────┤
|
||||
│ Entry 0 │
|
||||
│ Entry 1 │
|
||||
│ ... │
|
||||
│ Entry N-1 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Header (32 bytes)
|
||||
|
||||
| Offset | Size | Field | Value |
|
||||
|--------|------|-------|-------|
|
||||
| 0 | 4 | magic | `0x474C4944` ("GLID", little-endian) |
|
||||
| 4 | 2 | version | `1` |
|
||||
| 6 | 2 | flags | `0` (reserved) |
|
||||
| 8 | 4 | entryCount | number of words |
|
||||
| 12 | 2 | langLen | length of language tag |
|
||||
| 14 | N | langTag | UTF-8 language tag (e.g., "en") |
|
||||
| 14+N | padding | — | zeros to byte 32 |
|
||||
|
||||
### Entry Format
|
||||
|
||||
| Size | Field | Description |
|
||||
|------|-------|-------------|
|
||||
| 1 | wordLen | Length of word in bytes |
|
||||
| wordLen | word | UTF-8 word string |
|
||||
| 4 | frequency | Little-endian uint32 |
|
||||
| 1 | flags | `DICT_FLAG_PROPER_NOUN` (0x01), `DICT_FLAG_PROFANITY` (0x02) |
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
libswipetype/
|
||||
├── swipetype-core/ # C++17 core library
|
||||
│ ├── CMakeLists.txt # Build config (standalone or via Gradle)
|
||||
│ ├── include/swipetype/ # Public headers
|
||||
│ │ ├── GestureEngine.h # Main API
|
||||
│ │ ├── GesturePath.h # Path data structures
|
||||
│ │ ├── GesturePoint.h # Point types (raw + normalized)
|
||||
│ │ ├── GestureCandidate.h # Recognition result
|
||||
│ │ ├── KeyboardLayout.h # Layout descriptor
|
||||
│ │ ├── PathProcessor.h # Path normalization
|
||||
│ │ ├── IdealPathGenerator.h # Reference path generation
|
||||
│ │ ├── Scorer.h # DTW scoring
|
||||
│ │ ├── DictionaryLoader.h # Dictionary I/O
|
||||
│ │ └── SwipeTypeTypes.h # Shared types / constants
|
||||
│ ├── src/ # Implementation files
|
||||
│ │ ├── GestureEngine.cpp
|
||||
│ │ ├── PathProcessor.cpp
|
||||
│ │ ├── IdealPathGenerator.cpp
|
||||
│ │ ├── Scorer.cpp
|
||||
│ │ ├── DictionaryLoader.cpp
|
||||
│ │ └── AdjacencyMap.cpp
|
||||
│ └── tests/ # Google Test suite
|
||||
│ ├── CMakeLists.txt
|
||||
│ ├── TestHelpers.h
|
||||
│ ├── PathProcessorTest.cpp
|
||||
│ ├── ScorerTest.cpp
|
||||
│ ├── DictionaryLoaderTest.cpp
|
||||
│ ├── GestureEngineTest.cpp
|
||||
│ └── IdealPathGeneratorTest.cpp
|
||||
├── swipetype-android/ # Android AAR module
|
||||
│ ├── build.gradle # Gradle + CMake NDK build
|
||||
│ └── src/main/
|
||||
│ ├── cpp/GestureLibJNI.cpp # JNI bridge
|
||||
│ └── java/dev/dettmer/swipetype/android/
|
||||
│ ├── SwipeTypeEngine.java
|
||||
│ ├── SwipeTypeAdapter.java
|
||||
│ ├── GesturePoint.java
|
||||
│ ├── KeyboardLayoutDescriptor.java
|
||||
│ ├── SwipeTypeCandidate.java
|
||||
│ └── SwipeTypeError.java
|
||||
├── adapters/
|
||||
│ └── heliboard/ # Reference adapter for HeliBoard
|
||||
│ └── src/main/java/dev/dettmer/swipetype/adapters/heliboard/
|
||||
│ └── HeliboardSwipeTypeAdapter.java
|
||||
├── sample-app/ # Minimal sample IME
|
||||
│ ├── build.gradle
|
||||
│ └── src/main/java/dev/dettmer/swipetype/sample/
|
||||
│ ├── MainActivity.java
|
||||
│ ├── SampleInputMethodService.java
|
||||
│ └── SampleKeyboardView.java
|
||||
├── scripts/
|
||||
│ └── gen_dict.py # TSV → .glide dictionary generator
|
||||
├── test-data/
|
||||
│ ├── en-us-full.tsv # 302-word English word list
|
||||
│ ├── gesture-scenarios.json # Test gesture definitions
|
||||
│ └── qwerty-standard.json # Standard QWERTY layout definition
|
||||
├── docs/ # Documentation (this directory)
|
||||
│ ├── API.md
|
||||
│ ├── ARCHITECTURE.md (this file)
|
||||
│ ├── ONBOARDING.md
|
||||
│ └── HOW_TO_WRITE_AN_ADAPTER.md
|
||||
├── CHANGELOG.md
|
||||
├── CONTRIBUTING.md
|
||||
├── LICENSE (Apache 2.0)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why pImpl?
|
||||
|
||||
All core classes (`GestureEngine`, `PathProcessor`, `IdealPathGenerator`, `Scorer`, `DictionaryLoader`) use the pointer-to-implementation (pImpl) idiom. This:
|
||||
|
||||
1. **Hides implementation details** from public headers — keyboard apps only include headers, never see internal types
|
||||
2. **Enables binary compatibility** — internal changes don't break ABI
|
||||
3. **Reduces compile times** — changes to `.cpp` files don't force recompilation of dependents
|
||||
|
||||
### Why DTW instead of neural networks?
|
||||
|
||||
DTW is deterministic, explainable, and requires zero training data. It runs in under 5ms per candidate on mid-range Android devices. The tradeoff is lower accuracy on edge cases (very short words, similar shapes), which is acceptable for a v0.1 library.
|
||||
|
||||
### Why bounding-box normalization?
|
||||
|
||||
Normalizing gesture and ideal paths to a [0, 1] bounding box makes the DTW comparison scale-invariant. A gesture on a tablet (high dp) produces the same normalized path as one on a phone (low dp). Aspect ratio is preserved to disambiguate horizontal vs. vertical swipes.
|
||||
|
||||
### Why key-transition counting for length estimation?
|
||||
|
||||
The original arc-length heuristic divided total gesture arc length by average key spacing. This overestimated drastically for zigzag words (e.g., "hello" estimated at 17+ characters). Key-transition counting walks the raw path, snaps each point to its nearest key, and counts distinct transitions — yielding an estimate that closely matches actual word length regardless of path geometry.
|
||||
|
||||
### Why adaptive frequency weight?
|
||||
|
||||
A fixed frequency weight (α = 0.30) allows high-frequency words to dominate when DTW scores are compressed (all candidates match similarly well). The adaptive approach scales α proportionally with the DTW range (`effectiveAlpha *= max(0.1, rawRange/0.5)`), so when scores are tightly clustered, frequency influence shrinks smoothly. This ensures shape remains the primary discriminator unless there's a clear shape winner.
|
||||
|
||||
---
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
| Operation | Time | Notes |
|
||||
|-----------|------|-------|
|
||||
| `normalize()` | < 1ms | Resample 64 points |
|
||||
| `getIdealPath()` (cached) | < 10μs | Hash map lookup |
|
||||
| `getIdealPath()` (miss) | < 0.5ms | Generate + resample |
|
||||
| `computeDTWDistance()` | < 2ms | 64×64 with band W=6 |
|
||||
| `recognize()` (full pipeline) | < 50ms | With 302-word dictionary |
|
||||
|
||||
Memory: ~10KB per loaded dictionary word (entry + cached ideal path). 302 words ≈ 3MB.
|
||||
|
||||
---
|
||||
|
||||
## Thread Safety
|
||||
|
||||
| Component | Thread Safety |
|
||||
|-----------|---------------|
|
||||
| `GestureEngine` (C++) | NOT thread-safe. External sync required |
|
||||
| `SwipeTypeEngine` (Java) | All public methods `synchronized` |
|
||||
| `DictionaryLoader` (after load) | Read-only operations thread-safe |
|
||||
| `PathProcessor` | Stateless after construction — thread-safe |
|
||||
| `Scorer` | Stateless after `configure()` — thread-safe |
|
||||
| `IdealPathGenerator` | NOT thread-safe (mutable cache) |
|
||||
@@ -0,0 +1,358 @@
|
||||
# How to Write an Adapter — libswipetype
|
||||
|
||||
> A step-by-step guide to integrating libswipetype into any Android keyboard app.
|
||||
|
||||
---
|
||||
|
||||
## What is an Adapter?
|
||||
|
||||
An adapter is the glue between a keyboard app and libswipetype. It implements the `SwipeTypeAdapter` interface, translating between the keyboard app's internal types and the generic swipetype API.
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ Your Keyboard App │◄──────►│ YourSwipeTypeAdapter │
|
||||
│ (e.g., FlorisBoard)│ │ implements │
|
||||
│ Key positions │ │ SwipeTypeAdapter │
|
||||
│ Touch events │ │ │
|
||||
│ Suggestion bar │ │ ┌──────────────────┐ │
|
||||
└────────────────────┘ │ │ SwipeTypeEngine │ │
|
||||
│ │ (libswipetype) │ │
|
||||
│ └──────────────────┘ │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Your keyboard app can provide key positions (center, width, height) in dp
|
||||
- You have access to touch events (x, y, timestamp) during a swipe gesture
|
||||
- You have a place to display word candidates (suggestion bar, popup, etc.)
|
||||
- The `swipetype-android` AAR is available as a dependency
|
||||
|
||||
### Adding the dependency
|
||||
|
||||
In your keyboard module's `build.gradle`:
|
||||
|
||||
```groovy
|
||||
dependencies {
|
||||
implementation project(':swipetype-android')
|
||||
// Or, when published:
|
||||
// implementation 'dev.dettmer.swipetype:swipetype-android:0.1.0'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
### Step 1: Implement `SwipeTypeAdapter`
|
||||
|
||||
Create a class that implements all four methods:
|
||||
|
||||
```java
|
||||
package com.example.mykeyboard;
|
||||
|
||||
import dev.dettmer.swipetype.android.*;
|
||||
import java.util.List;
|
||||
|
||||
public class MySwipeTypeAdapter implements SwipeTypeAdapter {
|
||||
|
||||
private final MyKeyboardService service;
|
||||
private SwipeTypeEngine engine;
|
||||
|
||||
public MySwipeTypeAdapter(MyKeyboardService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
// ── Called after loadDictionary() succeeds ───────────────────────
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
this.engine = engine;
|
||||
// Store the engine reference for later use (e.g., shutdown)
|
||||
// Do NOT call loadDictionary() from here — it's already loaded.
|
||||
}
|
||||
|
||||
// ── Called by the engine to get key positions ────────────────────
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
// Translate your keyboard's internal layout to KeyboardLayoutDescriptor
|
||||
List<KeyboardLayoutDescriptor.KeyInfo> keys = new ArrayList<>();
|
||||
|
||||
for (MyKey key : service.getCurrentKeys()) {
|
||||
// IMPORTANT: All coordinates must be in dp, not pixels!
|
||||
keys.add(new KeyboardLayoutDescriptor.KeyInfo(
|
||||
key.getLabel(), // "a", "b", etc.
|
||||
key.getCodePoint(), // Unicode code point (97 for 'a')
|
||||
key.getCenterXDp(), // center X in dp
|
||||
key.getCenterYDp(), // center Y in dp
|
||||
key.getWidthDp(), // key width in dp
|
||||
key.getHeightDp() // key height in dp
|
||||
));
|
||||
}
|
||||
|
||||
return new KeyboardLayoutDescriptor(
|
||||
"en-US", // BCP 47 language tag
|
||||
keys,
|
||||
service.getKeyboardWidthDp(), // total keyboard width
|
||||
service.getKeyboardHeightDp() // total keyboard height
|
||||
);
|
||||
}
|
||||
|
||||
// ── Called with recognition results ──────────────────────────────
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
// Forward to your keyboard's suggestion bar
|
||||
List<String> words = new ArrayList<>();
|
||||
for (SwipeTypeCandidate c : candidates) {
|
||||
words.add(c.word); // public field, not a getter
|
||||
}
|
||||
service.showSuggestions(words);
|
||||
}
|
||||
|
||||
// ── Called on errors ─────────────────────────────────────────────
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
Log.e("MyAdapter", "SwipeType error [" + error.code + "]: " + error.message);
|
||||
// Optionally show a user-facing message for critical errors
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Initialize the Engine
|
||||
|
||||
In your keyboard service's `onCreate()`:
|
||||
|
||||
```java
|
||||
public class MyKeyboardService extends InputMethodService {
|
||||
|
||||
private SwipeTypeEngine engine;
|
||||
private MySwipeTypeAdapter adapter;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
adapter = new MySwipeTypeAdapter(this);
|
||||
engine = new SwipeTypeEngine();
|
||||
engine.init(this, adapter);
|
||||
|
||||
// Load dictionary — onInit() callback fires on success
|
||||
loadDictionary();
|
||||
}
|
||||
|
||||
private void loadDictionary() {
|
||||
// Option A: From APK raw resources
|
||||
int resId = getResources().getIdentifier("en_us", "raw", getPackageName());
|
||||
if (resId != 0) {
|
||||
try (InputStream is = getResources().openRawResource(resId)) {
|
||||
engine.loadDictionary("en-US", is);
|
||||
} catch (Exception e) {
|
||||
Log.e("MyIME", "Dict load failed: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
// Option B: From a file on disk
|
||||
// try (InputStream is = new FileInputStream("/path/to/dict.glide")) {
|
||||
// engine.loadDictionary("en-US", is);
|
||||
// }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (engine != null) engine.shutdown();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Feed Touch Events
|
||||
|
||||
Collect `ACTION_DOWN` → `ACTION_MOVE` → `ACTION_UP` touch events during a swipe gesture and convert them to `GesturePoint` objects:
|
||||
|
||||
```java
|
||||
// In your keyboard view's onTouchEvent():
|
||||
private List<GesturePoint> activePath = new ArrayList<>();
|
||||
private long gestureStartMs;
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
// Convert pixels to dp!
|
||||
float xDp = event.getX() / getResources().getDisplayMetrics().density;
|
||||
float yDp = event.getY() / getResources().getDisplayMetrics().density;
|
||||
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
activePath.clear();
|
||||
gestureStartMs = event.getEventTime();
|
||||
activePath.add(new GesturePoint(xDp, yDp, 0));
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
long elapsed = event.getEventTime() - gestureStartMs;
|
||||
activePath.add(new GesturePoint(xDp, yDp, elapsed));
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_UP:
|
||||
if (activePath.size() >= 2) {
|
||||
// This triggers recognition → onCandidatesReady()
|
||||
engine.notifyLayoutChanged(); // ensure layout is fresh
|
||||
engine.processGesture(new ArrayList<>(activePath));
|
||||
}
|
||||
activePath.clear();
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Handle Layout Changes
|
||||
|
||||
Call `engine.notifyLayoutChanged()` when:
|
||||
|
||||
- The user rotates the device
|
||||
- The user switches languages
|
||||
- The keyboard layout changes size
|
||||
- Before the first `processGesture()` call (if layout may have changed since init)
|
||||
|
||||
```java
|
||||
@Override
|
||||
public void onStartInputView(EditorInfo info, boolean restarting) {
|
||||
super.onStartInputView(info, restarting);
|
||||
if (engine != null && engine.isInitialized()) {
|
||||
engine.notifyLayoutChanged();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coordinate Conversion Checklist
|
||||
|
||||
The **#1 cause of wrong results** is coordinate mismatch. Use this checklist:
|
||||
|
||||
- [ ] Key positions are in **dp** (not pixels)
|
||||
- [ ] Touch event coordinates are converted from pixels to **dp**
|
||||
- [ ] Key `centerX`/`centerY` are relative to the **keyboard view's top-left** (not the screen)
|
||||
- [ ] `layoutWidth`/`layoutHeight` match the keyboard view's actual **dp dimensions**
|
||||
- [ ] Both the layout and touch events use the **same coordinate space**
|
||||
|
||||
Quick dp conversion:
|
||||
```java
|
||||
float density = context.getResources().getDisplayMetrics().density;
|
||||
float dp = pixels / density;
|
||||
float pixels = dp * density;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference: HeliBoard Adapter
|
||||
|
||||
The HeliBoard adapter in `adapters/heliboard/` is a working reference implementation.
|
||||
|
||||
Key points:
|
||||
- Translates HeliBoard's `ProximityInfo` and `Key[]` objects into `KeyboardLayoutDescriptor`
|
||||
- Translates HeliBoard's `InputPointers` into `List<GesturePoint>`
|
||||
- Delivers candidates to HeliBoard's `SuggestedWords` interface
|
||||
|
||||
File: `adapters/heliboard/src/main/java/dev/dettmer/swipetype/adapters/heliboard/HeliboardSwipeTypeAdapter.java`
|
||||
|
||||
---
|
||||
|
||||
## Generating a Dictionary
|
||||
|
||||
The engine requires a binary `.glide` dictionary file. Generate one from a TSV word list:
|
||||
|
||||
```bash
|
||||
python3 scripts/gen_dict.py \
|
||||
--input words.tsv \
|
||||
--output mydict.glide \
|
||||
--language en-US
|
||||
```
|
||||
|
||||
TSV format (no header):
|
||||
```
|
||||
the 1000000
|
||||
and 800000
|
||||
hello 50000
|
||||
world 40000
|
||||
```
|
||||
|
||||
Tab-separated: `word<TAB>frequency`. Higher frequency = more likely to be suggested when DTW scores are similar.
|
||||
|
||||
Place the `.glide` file in your APK's `res/raw/` directory with underscores instead of hyphens (e.g., `en_us.glide` → accessed as `R.raw.en_us`).
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | Symptom | Fix |
|
||||
|---------|---------|-----|
|
||||
| Coordinates in pixels instead of dp | Empty results or wrong candidates | Divide by `displayMetrics.density` |
|
||||
| Missing `loadDictionary()` call | `onCandidatesReady()` never fires | Call `loadDictionary()` in `onCreate()` |
|
||||
| Calling `loadDictionary()` from `onInit()` | Infinite loop / stack overflow | `onInit` is a callback — don't reload from it |
|
||||
| Layout not updated before gesture | First gesture returns wrong results | Call `notifyLayoutChanged()` before `processGesture()` |
|
||||
| Using `getWord()` instead of `.word` | Compile error | `SwipeTypeCandidate` uses public fields, not getters |
|
||||
| Wrong native lib name | `UnsatisfiedLinkError` | The lib is `glide_jni`, ensure it's in `jniLibs/` |
|
||||
| Dictionary too small | Poor accuracy | Use ≥ 10,000 words for production quality |
|
||||
|
||||
---
|
||||
|
||||
## Minimal Working Example
|
||||
|
||||
A complete, self-contained adapter in ~60 lines:
|
||||
|
||||
```java
|
||||
public class MinimalAdapter implements SwipeTypeAdapter {
|
||||
private final InputMethodService ime;
|
||||
|
||||
public MinimalAdapter(InputMethodService ime) { this.ime = ime; }
|
||||
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
Log.i("Minimal", "Engine ready");
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
// Hardcoded QWERTY layout (360×260 dp)
|
||||
float w = 360f, h = 260f, kw = 36f, kh = 65f;
|
||||
String[] rows = {"qwertyuiop", "asdfghjkl", "zxcvbnm"};
|
||||
List<KeyboardLayoutDescriptor.KeyInfo> keys = new ArrayList<>();
|
||||
float rowY = kh / 2f;
|
||||
for (String row : rows) {
|
||||
float offsetX = (w - row.length() * kw) / 2f + kw / 2f;
|
||||
for (int c = 0; c < row.length(); c++) {
|
||||
char ch = row.charAt(c);
|
||||
keys.add(new KeyboardLayoutDescriptor.KeyInfo(
|
||||
String.valueOf(ch), (int) ch,
|
||||
offsetX + c * kw, rowY, kw, kh));
|
||||
}
|
||||
rowY += kh;
|
||||
}
|
||||
return new KeyboardLayoutDescriptor("en-US", keys, w, h);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
if (!candidates.isEmpty()) {
|
||||
InputConnection ic = ime.getCurrentInputConnection();
|
||||
if (ic != null) ic.commitText(candidates.get(0).word + " ", 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
Log.e("Minimal", error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [API.md](API.md) for the full API reference
|
||||
- Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the recognition pipeline
|
||||
- Study the sample app for a complete working integration
|
||||
- Check the [CHANGELOG.md](../CHANGELOG.md) for the latest changes
|
||||
@@ -0,0 +1,241 @@
|
||||
# Developer Onboarding — libswipetype
|
||||
|
||||
> Get from zero to a working build + passing tests in under 15 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Check |
|
||||
|------|---------|-------|
|
||||
| **Android Studio** or IntelliJ IDEA | 2023.1+ | `studio --version` |
|
||||
| **JDK** | 17+ | `java -version` |
|
||||
| **Android SDK** | API 34 (compileSdk) | SDK Manager |
|
||||
| **Android NDK** | 25.2.9519653 | SDK Manager → SDK Tools → NDK |
|
||||
| **CMake** | 3.18+ | `cmake --version` |
|
||||
| **Python** | 3.8+ | `python3 --version` (for dictionary generation) |
|
||||
| **Git** | 2.x | `git --version` |
|
||||
|
||||
Optional:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **clang-format** | Code formatting (`.clang-format` in repo root) |
|
||||
| **adb** | On-device testing |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/libswipetype/libswipetype.git
|
||||
cd libswipetype
|
||||
```
|
||||
|
||||
### 2. Build & test the C++ core (standalone)
|
||||
|
||||
The C++ core has zero external dependencies (Google Test is fetched automatically).
|
||||
|
||||
```bash
|
||||
cd swipetype-core
|
||||
mkdir build && cd build
|
||||
cmake .. -DCMAKE_BUILD_TYPE=Debug -DSWIPETYPE_BUILD_TESTS=ON
|
||||
cmake --build . -j$(nproc)
|
||||
ctest --output-on-failure
|
||||
```
|
||||
|
||||
You should see **48 tests passed, 0 failed**.
|
||||
|
||||
### 3. Build the full Android project
|
||||
|
||||
Open the project root in Android Studio, or build from the command line:
|
||||
|
||||
```bash
|
||||
cd /path/to/libswipetype
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
This builds:
|
||||
- `swipetype-core` (via CMake, triggered by Gradle)
|
||||
- `swipetype-android` AAR
|
||||
- `adapters/heliboard` module
|
||||
- `sample-app` debug APK
|
||||
|
||||
### 4. Run the sample app
|
||||
|
||||
```bash
|
||||
# Install on a connected device or emulator
|
||||
./gradlew :sample-app:installDebug
|
||||
|
||||
# Or use Android Studio: Run → sample-app
|
||||
```
|
||||
|
||||
Then on the device:
|
||||
1. Open "SwipeType Demo" app
|
||||
2. Tap "Enable IME" → enable "SwipeType Sample IME" in Settings
|
||||
3. Return to the app
|
||||
4. Tap the text field → switch to the SwipeType keyboard via the globe icon
|
||||
5. Draw a swipe gesture across the keys
|
||||
|
||||
### 5. Generate a dictionary (optional)
|
||||
|
||||
The sample app already includes `en_us_sample.glide` in `res/raw/`. To regenerate or create a new dictionary:
|
||||
|
||||
```bash
|
||||
python3 scripts/gen_dict.py \
|
||||
--input test-data/en-us-full.tsv \
|
||||
--output sample-app/src/main/res/raw/en_us_sample.glide \
|
||||
--language en-US
|
||||
```
|
||||
|
||||
TSV format: `word<TAB>frequency` per line. No header row.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure at a Glance
|
||||
|
||||
```
|
||||
libswipetype/
|
||||
├── swipetype-core/ ← C++ library (the brain)
|
||||
├── swipetype-android/ ← Android AAR (JNI bridge)
|
||||
├── adapters/heliboard/ ← Reference adapter for HeliBoard
|
||||
├── sample-app/ ← Minimal IME demo
|
||||
├── scripts/ ← Dictionary generation
|
||||
├── test-data/ ← Test fixtures (layouts, gestures, word lists)
|
||||
├── docs/ ← Documentation (you are here)
|
||||
├── build.gradle ← Root Gradle build
|
||||
└── settings.gradle ← Module declarations
|
||||
```
|
||||
|
||||
For a detailed breakdown, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Coordinate System
|
||||
|
||||
Everything uses **density-independent pixels (dp)**, relative to the top-left corner of the keyboard view. The keyboard app provides key positions in dp; the user's touch events must also be in dp. This ensures the algorithm is screen-density-independent.
|
||||
|
||||
### Adapter Pattern
|
||||
|
||||
The library doesn't depend on any specific keyboard app. Instead, each keyboard provides an **adapter** implementing `SwipeTypeAdapter`:
|
||||
|
||||
```
|
||||
┌──────────────┐ SwipeTypeAdapter ┌──────────────┐
|
||||
│ HeliBoard │──────────────────────────│ SwipeType │
|
||||
│ FlorisBoard │ getKeyboardLayout() │ Engine │
|
||||
│ Your App │ onCandidatesReady() │ │
|
||||
└──────────────┘ onError() └──────────────┘
|
||||
```
|
||||
|
||||
### Recognition Pipeline (TL;DR)
|
||||
|
||||
1. Raw touch points → normalize to 64 equidistant points in [0,1] box
|
||||
2. Find start/end keys → filter dictionary candidates
|
||||
3. For each candidate: generate ideal path through key centers → compute DTW distance
|
||||
4. Rank by combined shape + frequency score → return top N
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### C++ Tests (Google Test)
|
||||
|
||||
```bash
|
||||
cd swipetype-core/build
|
||||
ctest --output-on-failure
|
||||
```
|
||||
|
||||
Or run individual test suites:
|
||||
|
||||
```bash
|
||||
./swipetype-core-tests --gtest_filter="GestureEngineTest.*"
|
||||
./swipetype-core-tests --gtest_filter="PathProcessorTest.*"
|
||||
./swipetype-core-tests --gtest_filter="ScorerTest.*"
|
||||
```
|
||||
|
||||
### Android Tests
|
||||
|
||||
```bash
|
||||
# Unit tests (JVM)
|
||||
./gradlew :swipetype-android:test
|
||||
|
||||
# Connected tests (requires device/emulator)
|
||||
./gradlew :swipetype-android:connectedAndroidTest
|
||||
```
|
||||
|
||||
> **Note:** Some Java unit tests are currently disabled (skipped via `Assume.assumeTrue(false)`) pending JNI native library loading on the host JVM. The C++ tests provide full pipeline coverage.
|
||||
|
||||
---
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Modifying the recognition algorithm
|
||||
|
||||
1. Edit files in `swipetype-core/src/`
|
||||
2. Rebuild & test: `cd build && cmake --build . -j$(nproc) && ctest --output-on-failure`
|
||||
3. The Gradle build will automatically rebuild the native library for Android
|
||||
|
||||
### Adding a new word to the test dictionary
|
||||
|
||||
1. Add `word<TAB>frequency` to `test-data/en-us-full.tsv`
|
||||
2. Regenerate: `python3 scripts/gen_dict.py --input test-data/en-us-full.tsv --output sample-app/src/main/res/raw/en_us_sample.glide`
|
||||
3. Rebuild the sample app
|
||||
|
||||
### Tuning scoring parameters
|
||||
|
||||
Edit `ScoringConfig` defaults in `swipetype-core/include/swipetype/SwipeTypeTypes.h`, or configure at runtime:
|
||||
|
||||
```cpp
|
||||
ScoringConfig config;
|
||||
config.frequencyWeight = 0.20f; // reduce frequency influence
|
||||
config.lengthFilterTolerance = 4.0f; // widen length filter
|
||||
engine.configure(config);
|
||||
```
|
||||
|
||||
### Writing a new adapter
|
||||
|
||||
See [HOW_TO_WRITE_AN_ADAPTER.md](HOW_TO_WRITE_AN_ADAPTER.md).
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Logcat tags
|
||||
|
||||
| Tag | Source | What it shows |
|
||||
|-----|--------|---------------|
|
||||
| `SampleIME` | `SampleInputMethodService` | Layout size, gesture bounding box, top candidate |
|
||||
| `SwipeTypeEngine` | `SwipeTypeEngine.java` | Init, dict load, JNI errors |
|
||||
| `GestureLibJNI` | `GestureLibJNI.cpp` | Native-side errors, candidate counts |
|
||||
|
||||
### View gesture coordinates
|
||||
|
||||
The sample IME logs the gesture bounding box on every swipe:
|
||||
|
||||
```
|
||||
D SampleIME: Layout 360x260 dp | gesture x=[48..288] y=[26..134] 47 pts
|
||||
```
|
||||
|
||||
This helps verify coordinate alignment between the keyboard view and the engine.
|
||||
|
||||
### Examine DTW scores
|
||||
|
||||
Enable verbose logging in `GestureEngine.cpp` (define `SWIPETYPE_DEBUG`) to see per-candidate DTW scores, normalized values, and final confidence.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
1. **Native library name is `glide_jni`**, not `swipetype`. If you see `UnsatisfiedLinkError`, check that the `.so` files are in the correct `jniLibs/` directory.
|
||||
|
||||
2. **Dictionary must be loaded before `processGesture()`**. The `onInit()` callback fires only after `loadDictionary()` succeeds — do NOT call `loadDictionary()` from inside `onInit()` (infinite loop).
|
||||
|
||||
3. **Coordinates must be in dp**, not pixels. If candidates are wrong or empty, check that touch coordinates and layout key positions use the same unit.
|
||||
|
||||
4. **The C++ library is NOT thread-safe**. The Java `SwipeTypeEngine` handles synchronization, but if you call the C++ API directly, you must synchronize externally.
|
||||
|
||||
5. **Duplicate package `dev.swipetype`** exists in the codebase alongside `dev.dettmer.swipetype`. Both work identically — the duplicates will be consolidated in Phase 12.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Project-wide Gradle settings
|
||||
|
||||
# Enable AndroidX
|
||||
android.useAndroidX=true
|
||||
|
||||
# JVM settings
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
|
||||
# Android SDK/NDK
|
||||
# NDK version — minimum r25 required
|
||||
android.ndkVersion=25.2.9519653
|
||||
|
||||
# Enable parallel execution
|
||||
org.gradle.parallel=true
|
||||
|
||||
# Enable build cache
|
||||
org.gradle.caching=true
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,32 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'dev.dettmer.swipetype.sample'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'dev.dettmer.swipetype.sample'
|
||||
minSdk 21
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':swipetype-android')
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- READ_EXTERNAL_STORAGE only needed if loading dictionary from SD card (optional) -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<application
|
||||
android:label="SwipeType Sample"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<!-- Demo activity: lets the user set the keyboard and type into a text field -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="SwipeType Demo">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Input Method Service — uses its own theme to avoid inheriting the system theme -->
|
||||
<service
|
||||
android:name=".SampleInputMethodService"
|
||||
android:label="SwipeType Sample IME"
|
||||
android:permission="android.permission.BIND_INPUT_METHOD"
|
||||
android:theme="@style/SwipeTypeImeTheme"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.view.InputMethod" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.view.im"
|
||||
android:resource="@xml/method" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,90 @@
|
||||
package dev.dettmer.swipetype.sample;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Demo launcher activity for the SwipeType sample IME.
|
||||
*
|
||||
* <p>The activity guides the user through two steps:
|
||||
* <ol>
|
||||
* <li>Enable the "SwipeType Sample IME" keyboard in Android Settings →
|
||||
* System → Languages & input → Virtual keyboard.</li>
|
||||
* <li>Tap the text field and switch to the SwipeType keyboard via the
|
||||
* globe/keyboard icon in the navigation bar.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Once active, draw a swipe gesture across the on-screen keys to see
|
||||
* gesture-recognition candidates committed to the text field.
|
||||
*/
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
Button btnEnableIme = findViewById(R.id.btn_enable_ime);
|
||||
Button btnPickIme = findViewById(R.id.btn_pick_ime);
|
||||
EditText editText = findViewById(R.id.edit_text_demo);
|
||||
TextView tvStatus = findViewById(R.id.tv_status);
|
||||
|
||||
// ── Step 1: open Android IME settings so the user can enable the keyboard
|
||||
btnEnableIme.setOnClickListener(v ->
|
||||
startActivity(new Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)));
|
||||
|
||||
// ── Step 2: show the IME picker so the user can switch to SwipeType
|
||||
btnPickIme.setOnClickListener(v -> {
|
||||
InputMethodManager imm = getSystemService(InputMethodManager.class);
|
||||
if (imm != null) imm.showInputMethodPicker();
|
||||
});
|
||||
|
||||
updateStatus(tvStatus);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
TextView tvStatus = findViewById(R.id.tv_status);
|
||||
updateStatus(tvStatus);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void updateStatus(TextView tv) {
|
||||
if (tv == null) return;
|
||||
boolean enabled = isSwipeTypeEnabled();
|
||||
if (enabled) {
|
||||
tv.setText("✓ SwipeType IME is enabled. Tap the text field and switch to it.");
|
||||
} else {
|
||||
tv.setText("SwipeType IME is NOT enabled yet. Tap \"Enable IME\" above.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if our IME is listed as an enabled input method. */
|
||||
private boolean isSwipeTypeEnabled() {
|
||||
InputMethodManager imm = getSystemService(InputMethodManager.class);
|
||||
if (imm == null) return false;
|
||||
List<?> list = imm.getEnabledInputMethodList();
|
||||
String pkg = getPackageName();
|
||||
for (Object info : list) {
|
||||
// InputMethodInfo.getPackageName()
|
||||
try {
|
||||
String p = (String) info.getClass().getMethod("getPackageName").invoke(info);
|
||||
if (pkg.equals(p)) return true;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package dev.dettmer.swipetype.sample;
|
||||
|
||||
import android.inputmethodservice.InputMethodService;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
import dev.dettmer.swipetype.android.GesturePoint;
|
||||
import dev.dettmer.swipetype.android.KeyboardLayoutDescriptor;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeAdapter;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeCandidate;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeEngine;
|
||||
import dev.dettmer.swipetype.android.SwipeTypeError;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A minimal Input Method Service that demonstrates gesture recognition with SwipeType.
|
||||
*
|
||||
* <p>Architecture:
|
||||
* <pre>
|
||||
* ┌─────────────────────────────────────────────────────┐
|
||||
* │ SampleInputMethodService (InputMethodService) │
|
||||
* │ │
|
||||
* │ ┌──────────────────────┐ │
|
||||
* │ │ SampleKeyboardView │──(swipe gesture)──────┐ │
|
||||
* │ │ (input view) │──(key tap)────────────┐│ │
|
||||
* │ └──────────────────────┘ ││ │
|
||||
* │ ││ │
|
||||
* │ ┌──────────────────────┐ ┌──────────────┐ ││ │
|
||||
* │ │ SwipeTypeEngine │◄───┤ gesturePoints│◄─┘│ │
|
||||
* │ │ (native C++) │ │ │ │ │
|
||||
* │ │ → candidates │────►commitText() │ │ │
|
||||
* │ └──────────────────────┘ └──────────────┘ │ │
|
||||
* │ │ │
|
||||
* │ commitChar()◄──┘ │
|
||||
* └─────────────────────────────────────────────────────┘
|
||||
* </pre>
|
||||
*
|
||||
* <p>Dictionary loading: The IME looks for {@code raw/en_us_sample} in the APK's
|
||||
* raw resources. Include {@code en-us-sample.glide} as
|
||||
* {@code sample-app/src/main/res/raw/en_us_sample.glide} to enable gesture recognition.
|
||||
* (Generate it with {@code scripts/gen_dict.py}.)
|
||||
*/
|
||||
public class SampleInputMethodService extends InputMethodService
|
||||
implements SwipeTypeAdapter, SampleKeyboardView.KeyboardListener {
|
||||
|
||||
private static final String TAG = "SampleIME";
|
||||
|
||||
private SwipeTypeEngine engine;
|
||||
private SampleKeyboardView keyboardView;
|
||||
private boolean engineReady = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// InputMethodService lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
engine = new SwipeTypeEngine();
|
||||
engine.init(this, this);
|
||||
// engine.init() only stores context+adapter — it does NOT call onInit().
|
||||
// onInit() is fired by the engine at the END of loadDictionary() as a
|
||||
// success callback. So we must call loadDictionary() here ourselves.
|
||||
loadDict();
|
||||
}
|
||||
|
||||
private void loadDict() {
|
||||
int resId = getResources().getIdentifier("en_us_sample", "raw", getPackageName());
|
||||
if (resId == 0) {
|
||||
Log.w(TAG, "Dictionary resource raw/en_us_sample not found");
|
||||
return;
|
||||
}
|
||||
try (InputStream is = getResources().openRawResource(resId)) {
|
||||
boolean ok = engine.loadDictionary("en-US", is);
|
||||
Log.i(TAG, "loadDictionary returned: " + ok);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "loadDict failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (engine != null) {
|
||||
engine.shutdown();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateInputView() {
|
||||
keyboardView = new SampleKeyboardView(this);
|
||||
keyboardView.setKeyboardListener(this);
|
||||
|
||||
// Force a transparent window background so the keyboard view's own
|
||||
// canvas background (light gray #D3D3D3) is the only visible color.
|
||||
// Without this, the system theme's windowBackground bleeds through.
|
||||
Window w = getWindow().getWindow();
|
||||
if (w != null) {
|
||||
w.setBackgroundDrawableResource(android.R.color.transparent);
|
||||
}
|
||||
|
||||
return keyboardView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartInputView(EditorInfo info, boolean restarting) {
|
||||
super.onStartInputView(info, restarting);
|
||||
// Let the engine re-query layout via getKeyboardLayout()
|
||||
if (engineReady) {
|
||||
engine.notifyLayoutChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SwipeTypeAdapter implementation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called by SwipeTypeEngine at the END of loadDictionary() on success.
|
||||
* Pure callback -- do NOT call loadDictionary() from here.
|
||||
*/
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine swipeTypeEngine) {
|
||||
engineReady = true;
|
||||
Log.i(TAG, "onInit callback -- engine ready");
|
||||
if (keyboardView != null && keyboardView.getLayoutDescriptor() != null) {
|
||||
engine.notifyLayoutChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
if (keyboardView != null) {
|
||||
KeyboardLayoutDescriptor desc = keyboardView.getLayoutDescriptor();
|
||||
if (desc != null) return desc;
|
||||
}
|
||||
return buildFallbackLayout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
Log.w(TAG, "onCandidatesReady: no candidates returned");
|
||||
return;
|
||||
}
|
||||
|
||||
InputConnection ic = getCurrentInputConnection();
|
||||
if (ic == null) return;
|
||||
|
||||
// Log ALL candidates for accuracy analysis
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Candidates (").append(candidates.size()).append("):");
|
||||
for (int i = 0; i < candidates.size(); i++) {
|
||||
SwipeTypeCandidate c = candidates.get(i);
|
||||
sb.append(String.format(java.util.Locale.US,
|
||||
"\n #%d %-12s conf=%.4f", i + 1, c.word, c.confidence));
|
||||
}
|
||||
Log.d(TAG, sb.toString());
|
||||
|
||||
SwipeTypeCandidate top = candidates.get(0);
|
||||
Log.i(TAG, "COMMIT: \"" + top.word + "\" (confidence=" + top.confidence + ")");
|
||||
ic.commitText(top.word + " ", 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
Log.e(TAG, "SwipeType error [" + error.code + "]: " + error.message);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SampleKeyboardView.KeyboardListener implementation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void onKeyTap(int codePoint) {
|
||||
InputConnection ic = getCurrentInputConnection();
|
||||
if (ic != null) ic.commitText(String.valueOf((char) codePoint), 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSwipeGesture(List<GesturePoint> points) {
|
||||
if (!engineReady) {
|
||||
Log.w(TAG, "Engine not ready — dictionary missing or load failed");
|
||||
return;
|
||||
}
|
||||
// Diagnostic: log layout size + gesture bounding box to verify coordinate alignment
|
||||
KeyboardLayoutDescriptor desc = getKeyboardLayout();
|
||||
float minX = Float.MAX_VALUE, maxX = -Float.MAX_VALUE;
|
||||
float minY = Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
|
||||
for (GesturePoint p : points) {
|
||||
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
|
||||
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
Log.d(TAG, String.format(java.util.Locale.US,
|
||||
"Layout %.0fx%.0f dp | gesture x=[%.0f..%.0f] y=[%.0f..%.0f] %d pts",
|
||||
desc != null ? desc.layoutWidth : -1,
|
||||
desc != null ? desc.layoutHeight : -1,
|
||||
minX, maxX, minY, maxY, points.size()));
|
||||
engine.notifyLayoutChanged();
|
||||
engine.processGesture(points);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fallback layout (used before the keyboard view has been laid out)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static KeyboardLayoutDescriptor buildFallbackLayout() {
|
||||
final float W = 360.0f, H = 260.0f;
|
||||
final float rowH = (H * 0.75f) / 3.0f;
|
||||
final float kw = W / 10.0f;
|
||||
final String[] rows = {"qwertyuiop", "asdfghjkl", "zxcvbnm"};
|
||||
List<KeyboardLayoutDescriptor.KeyInfo> keys = new java.util.ArrayList<>();
|
||||
float rowY = rowH / 2.0f;
|
||||
for (String row : rows) {
|
||||
float offsetX = (W - row.length() * kw) / 2.0f + kw / 2.0f;
|
||||
for (int c = 0; c < row.length(); c++) {
|
||||
char ch = row.charAt(c);
|
||||
keys.add(new KeyboardLayoutDescriptor.KeyInfo(
|
||||
String.valueOf(ch), (int) ch,
|
||||
offsetX + c * kw, rowY, kw, rowH));
|
||||
}
|
||||
rowY += rowH;
|
||||
}
|
||||
return new KeyboardLayoutDescriptor("en-US", keys, W, H);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package dev.dettmer.swipetype.sample;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
import dev.dettmer.swipetype.android.GesturePoint;
|
||||
import dev.dettmer.swipetype.android.KeyboardLayoutDescriptor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A simple QWERTY keyboard view that:
|
||||
* <ul>
|
||||
* <li>Renders 3 rows of letter keys and a space bar</li>
|
||||
* <li>Tracks finger movement to build a gesture path</li>
|
||||
* <li>Dispatches tap and swipe gestures to a {@link KeyboardListener}</li>
|
||||
* </ul>
|
||||
*
|
||||
* Key coordinates are in density-independent pixels (dp). The view converts them
|
||||
* to screen pixels for rendering and to dp for the SwipeType engine.
|
||||
*/
|
||||
public class SampleKeyboardView extends View {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Rows of keys in order (top to bottom). */
|
||||
private static final String[] KEY_ROWS = {"qwertyuiop", "asdfghjkl", "zxcvbnm"};
|
||||
|
||||
/** Minimum swipe distance (dp) to classify a gesture as a swipe rather than a tap. */
|
||||
private static final float SWIPE_THRESHOLD_DP = 30.0f;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout state (populated in onSizeChanged, in dp units)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private KeyboardLayoutDescriptor layoutDescriptor;
|
||||
private float[] keyCX; // key center X in dp
|
||||
private float[] keyCY; // key center Y in dp
|
||||
private float[] keyW; // key width in dp
|
||||
private float[] keyH; // key height in dp
|
||||
private int[] keyCodes;
|
||||
private String[] keyLabels;
|
||||
private int keyCount;
|
||||
|
||||
/** Space bar bounds in dp */
|
||||
private float spaceCX, spaceCY, spaceW, spaceH;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Gesture tracking state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Gesture path accumulated during ACTION_MOVE (dp coordinates). */
|
||||
private final List<GesturePoint> activePath = new ArrayList<>();
|
||||
private long gestureStartMs;
|
||||
private boolean isSwiping;
|
||||
private boolean touchStartOnSpace; // true when touch began in the space bar zone
|
||||
private float touchStartX, touchStartY;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private final Paint keyBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint keyTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint trailPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint keyPressedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private int pressedKeyIndex = -1;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Callback
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public interface KeyboardListener {
|
||||
/** Called when the user taps a single key. */
|
||||
void onKeyTap(int codePoint);
|
||||
/** Called when the user completes a swipe gesture. */
|
||||
void onSwipeGesture(List<GesturePoint> points);
|
||||
}
|
||||
|
||||
private KeyboardListener listener;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Construction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public SampleKeyboardView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public SampleKeyboardView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public SampleKeyboardView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
keyBgPaint.setColor(Color.WHITE);
|
||||
keyBgPaint.setStyle(Paint.Style.FILL);
|
||||
|
||||
keyPressedPaint.setColor(Color.parseColor("#B0C4DE")); // light steel blue
|
||||
keyPressedPaint.setStyle(Paint.Style.FILL);
|
||||
|
||||
keyTextPaint.setColor(Color.BLACK);
|
||||
keyTextPaint.setTextAlign(Paint.Align.CENTER);
|
||||
|
||||
trailPaint.setColor(Color.parseColor("#4A90D9"));
|
||||
trailPaint.setStyle(Paint.Style.STROKE);
|
||||
trailPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
trailPaint.setStrokeJoin(Paint.Join.ROUND);
|
||||
}
|
||||
|
||||
public void setKeyboardListener(KeyboardListener l) {
|
||||
this.listener = l;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout computation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Keyboard is always exactly 260 dp tall — prevents it filling the whole screen. */
|
||||
private static final float KEYBOARD_HEIGHT_DP = 260.0f;
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
int w = MeasureSpec.getSize(widthMeasureSpec);
|
||||
int h = (int) dpToPx(KEYBOARD_HEIGHT_DP);
|
||||
setMeasuredDimension(w, h);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldW, int oldH) {
|
||||
super.onSizeChanged(w, h, oldW, oldH);
|
||||
buildLayout(pxToDp(w), pxToDp(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute key positions in dp and populate {@link #layoutDescriptor}.
|
||||
*
|
||||
* Layout uses even distribution: 75% of height for 3 letter rows, 25% for
|
||||
* space bar. All rows use a uniform key width (based on the 10-key top row)
|
||||
* and each row is centred horizontally to produce standard QWERTY stagger.
|
||||
*
|
||||
* Both rendering and the engine receive the same dp coordinates, so gesture
|
||||
* recognition is always consistent regardless of device screen density.
|
||||
*/
|
||||
private void buildLayout(float widthDp, float heightDp) {
|
||||
final float letterAreaH = heightDp * 0.75f;
|
||||
final float rowH = letterAreaH / 3.0f;
|
||||
final float kw = widthDp / 10.0f; // uniform key width (10 keys per top row)
|
||||
|
||||
int totalKeys = 0;
|
||||
for (String row : KEY_ROWS) totalKeys += row.length();
|
||||
|
||||
keyCX = new float[totalKeys];
|
||||
keyCY = new float[totalKeys];
|
||||
keyW = new float[totalKeys];
|
||||
keyH = new float[totalKeys];
|
||||
keyCodes = new int[totalKeys];
|
||||
keyLabels = new String[totalKeys];
|
||||
keyCount = 0;
|
||||
|
||||
for (int r = 0; r < KEY_ROWS.length; r++) {
|
||||
String row = KEY_ROWS[r];
|
||||
float rowY = rowH / 2.0f + r * rowH;
|
||||
// Centre each row horizontally
|
||||
float offsetX = (widthDp - row.length() * kw) / 2.0f + kw / 2.0f;
|
||||
for (int c = 0; c < row.length(); c++) {
|
||||
char ch = row.charAt(c);
|
||||
keyCX[keyCount] = offsetX + c * kw;
|
||||
keyCY[keyCount] = rowY;
|
||||
keyW[keyCount] = kw;
|
||||
keyH[keyCount] = rowH;
|
||||
keyCodes[keyCount] = (int) ch;
|
||||
keyLabels[keyCount] = String.valueOf(ch);
|
||||
keyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Space bar centred in the bottom 25% area
|
||||
final float spaceAreaY = letterAreaH;
|
||||
spaceW = widthDp * 0.5f;
|
||||
spaceH = (heightDp - letterAreaH) * 0.65f;
|
||||
spaceCX = widthDp / 2.0f;
|
||||
spaceCY = spaceAreaY + (heightDp - letterAreaH) / 2.0f;
|
||||
|
||||
// Build KeyboardLayoutDescriptor for the engine
|
||||
List<KeyboardLayoutDescriptor.KeyInfo> infos = new ArrayList<>();
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
infos.add(new KeyboardLayoutDescriptor.KeyInfo(
|
||||
keyLabels[i], keyCodes[i],
|
||||
keyCX[i], keyCY[i], keyW[i], keyH[i]));
|
||||
}
|
||||
layoutDescriptor = new KeyboardLayoutDescriptor(
|
||||
"en-US", infos, widthDp, heightDp);
|
||||
}
|
||||
|
||||
/** Returns the current layout descriptor (may be null before first layout pass). */
|
||||
public KeyboardLayoutDescriptor getLayoutDescriptor() {
|
||||
return layoutDescriptor;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drawing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
canvas.drawColor(Color.parseColor("#D3D3D3")); // light gray background
|
||||
|
||||
if (keyCount == 0) return;
|
||||
|
||||
float radius = dpToPx(4);
|
||||
keyTextPaint.setTextSize(dpToPx(14));
|
||||
keyTextPaint.setColor(Color.BLACK); // always black regardless of system theme
|
||||
trailPaint.setStrokeWidth(dpToPx(3));
|
||||
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
float cx = dpToPx(keyCX[i]);
|
||||
float cy = dpToPx(keyCY[i]);
|
||||
float hw = dpToPx(keyW[i]) / 2.0f - dpToPx(1);
|
||||
float hh = dpToPx(keyH[i]) / 2.0f - dpToPx(1);
|
||||
|
||||
RectF rect = new RectF(cx - hw, cy - hh, cx + hw, cy + hh);
|
||||
canvas.drawRoundRect(rect, radius, radius,
|
||||
i == pressedKeyIndex ? keyPressedPaint : keyBgPaint);
|
||||
canvas.drawText(keyLabels[i].toUpperCase(), cx, cy + dpToPx(5), keyTextPaint);
|
||||
}
|
||||
|
||||
// Space bar
|
||||
RectF spaceRect = new RectF(
|
||||
dpToPx(spaceCX - spaceW / 2.0f), dpToPx(spaceCY - spaceH / 2.0f),
|
||||
dpToPx(spaceCX + spaceW / 2.0f), dpToPx(spaceCY + spaceH / 2.0f));
|
||||
canvas.drawRoundRect(spaceRect, radius, radius, keyBgPaint);
|
||||
canvas.drawText("space", dpToPx(spaceCX), dpToPx(spaceCY) + dpToPx(5), keyTextPaint);
|
||||
|
||||
// Draw gesture trail
|
||||
if (activePath.size() >= 2) {
|
||||
for (int i = 1; i < activePath.size(); i++) {
|
||||
canvas.drawLine(
|
||||
dpToPx(activePath.get(i - 1).x),
|
||||
dpToPx(activePath.get(i - 1).y),
|
||||
dpToPx(activePath.get(i).x),
|
||||
dpToPx(activePath.get(i).y),
|
||||
trailPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Touch handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
float xDp = pxToDp(event.getX());
|
||||
float yDp = pxToDp(event.getY());
|
||||
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
activePath.clear();
|
||||
isSwiping = false;
|
||||
touchStartX = xDp;
|
||||
touchStartY = yDp;
|
||||
touchStartOnSpace = isInSpaceZone(yDp);
|
||||
gestureStartMs = event.getEventTime();
|
||||
if (!touchStartOnSpace) {
|
||||
activePath.add(new GesturePoint(xDp, yDp, 0));
|
||||
}
|
||||
pressedKeyIndex = touchStartOnSpace ? -1 : findNearestKey(xDp, yDp);
|
||||
invalidate();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (!touchStartOnSpace) {
|
||||
long elapsed = event.getEventTime() - gestureStartMs;
|
||||
activePath.add(new GesturePoint(xDp, yDp, elapsed));
|
||||
float dx = xDp - touchStartX;
|
||||
float dy = yDp - touchStartY;
|
||||
if (!isSwiping && Math.sqrt(dx * dx + dy * dy) > SWIPE_THRESHOLD_DP) {
|
||||
isSwiping = true;
|
||||
pressedKeyIndex = -1;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
pressedKeyIndex = -1;
|
||||
if (touchStartOnSpace) {
|
||||
// Any touch that started in the space zone is always a space
|
||||
if (listener != null) listener.onKeyTap(' ');
|
||||
} else if (isSwiping && activePath.size() >= 2 && listener != null) {
|
||||
listener.onSwipeGesture(new ArrayList<>(activePath));
|
||||
} else if (!isSwiping && listener != null) {
|
||||
int ki = findNearestKey(xDp, yDp);
|
||||
if (ki >= 0) listener.onKeyTap(keyCodes[ki]);
|
||||
}
|
||||
activePath.clear();
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hit testing helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private int findNearestKey(float xDp, float yDp) {
|
||||
float best = Float.MAX_VALUE;
|
||||
int bestIdx = -1;
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
float dx = xDp - keyCX[i];
|
||||
float dy = yDp - keyCY[i];
|
||||
float dist = dx * dx + dy * dy;
|
||||
if (dist < best) { best = dist; bestIdx = i; }
|
||||
}
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
private boolean isOnSpaceBar(float xDp, float yDp) {
|
||||
return Math.abs(xDp - spaceCX) <= spaceW / 2.0f
|
||||
&& Math.abs(yDp - spaceCY) <= spaceH / 2.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the y-coordinate is below the letter area (bottom 25%).
|
||||
* The entire bottom zone is treated as the space bar for touch purposes.
|
||||
*/
|
||||
private boolean isInSpaceZone(float yDp) {
|
||||
float letterAreaH = KEYBOARD_HEIGHT_DP * 0.75f;
|
||||
return yDp > letterAreaH;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Unit conversion helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private float dpToPx(float dp) {
|
||||
return dp * getResources().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
private float pxToDp(float px) {
|
||||
return px / getResources().getDisplayMetrics().density;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#1E1E2E"
|
||||
android:padding="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<!-- Title -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="SwipeType Demo"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Subtitle / description -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="This app demonstrates the libswipetype gesture-typing library.\nFollow the steps below to try it."
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- ── Step 1 ── -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Step 1: Enable the keyboard"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Open Android Settings and enable "SwipeType Sample IME" as a virtual keyboard."
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_enable_ime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="Open IME Settings" />
|
||||
|
||||
<!-- ── Step 2 ── -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Step 2: Switch to SwipeType"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Tap the field below, then tap the keyboard-picker button (globe icon) and select SwipeType."
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_pick_ime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="Show Keyboard Picker" />
|
||||
|
||||
<!-- Status indicator -->
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:padding="12dp"
|
||||
android:background="#2D2D4E"
|
||||
android:textColor="#AADDAA"
|
||||
android:text="Checking IME status…"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<!-- ── Step 3: text field ── -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Step 3: Type here with gesture"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Swipe across the keys in one fluid motion to enter a word. Tapping a single key inserts that character."
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_text_demo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Swipe to type here…"
|
||||
android:inputType="textMultiLine"
|
||||
android:minLines="4"
|
||||
android:gravity="top"
|
||||
android:background="@android:drawable/editbox_background"
|
||||
android:textColor="#000000"
|
||||
android:textColorHint="#888888"
|
||||
android:padding="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- App theme for the main activity (dark background, light text) -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
|
||||
<item name="colorPrimary">#1E1E2E</item>
|
||||
<item name="colorPrimaryDark">#15152A</item>
|
||||
<item name="colorAccent">#4A90D9</item>
|
||||
<item name="android:windowBackground">#1E1E2E</item>
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Theme applied to the IME service window.
|
||||
Forces a dark, translucent surround so the keyboard looks consistent
|
||||
regardless of whether the system is running a light or dark theme.
|
||||
-->
|
||||
<style name="SwipeTypeImeTheme" parent="@android:style/Theme">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:colorBackground">#2D2D4E</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Input method metadata for SampleInputMethodService.
|
||||
See: https://developer.android.com/reference/android/inputmethodservice/InputMethodService
|
||||
-->
|
||||
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:settingsActivity="dev.dettmer.swipetype.sample.MainActivity">
|
||||
|
||||
<subtype
|
||||
android:label="English (US)"
|
||||
android:imeSubtypeLocale="en_US"
|
||||
android:imeSubtypeMode="keyboard" />
|
||||
|
||||
</input-method>
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen_dict.py — Convert a TSV word list to binary .glide dictionary format.
|
||||
|
||||
Usage:
|
||||
python3 gen_dict.py input.tsv output.glide --lang en-US
|
||||
|
||||
Input format (TSV):
|
||||
word<TAB>frequency
|
||||
hello\t100000
|
||||
world\t95000
|
||||
...
|
||||
|
||||
One word per line. Frequency is a positive integer (higher = more common).
|
||||
Lines starting with # are comments. Empty lines are skipped.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Constants matching SwipeTypeTypes.h
|
||||
DICT_MAGIC = 0x474C4944 # "GLID"
|
||||
DICT_VERSION = 1
|
||||
DICT_HEADER_SIZE = 32
|
||||
MAX_WORD_LENGTH = 64
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert TSV word list to binary .glide dictionary format."
|
||||
)
|
||||
parser.add_argument("input", help="Input TSV file (word<TAB>frequency)")
|
||||
parser.add_argument("output", help="Output .glide binary file")
|
||||
parser.add_argument(
|
||||
"--lang", default="en-US",
|
||||
help="BCP 47 language tag (default: en-US)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sort", action="store_true", default=True,
|
||||
help="Sort entries alphabetically (default: true)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-sort", action="store_false", dest="sort",
|
||||
help="Don't sort entries"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--proper-nouns", action="store_true", default=False,
|
||||
help="Mark capitalized words as proper nouns"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_tsv(filepath):
|
||||
"""Read a TSV file and return list of (word, frequency, flags) tuples."""
|
||||
entries = []
|
||||
line_num = 0
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line_num += 1
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 2:
|
||||
print(f"WARNING: Line {line_num}: expected 'word\\tfrequency', got '{line}'",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
|
||||
word = parts[0].strip()
|
||||
try:
|
||||
frequency = int(parts[1].strip())
|
||||
except ValueError:
|
||||
print(f"WARNING: Line {line_num}: invalid frequency '{parts[1]}'",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Validate
|
||||
word_bytes = word.encode("utf-8")
|
||||
if len(word_bytes) == 0:
|
||||
print(f"WARNING: Line {line_num}: empty word, skipping",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
|
||||
if len(word_bytes) > MAX_WORD_LENGTH:
|
||||
print(f"WARNING: Line {line_num}: word '{word}' exceeds {MAX_WORD_LENGTH} bytes, skipping",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
|
||||
if frequency < 0:
|
||||
print(f"WARNING: Line {line_num}: negative frequency for '{word}', using 0",
|
||||
file=sys.stderr)
|
||||
frequency = 0
|
||||
|
||||
if frequency > 0xFFFFFFFF:
|
||||
print(f"WARNING: Line {line_num}: frequency too large for '{word}', clamping",
|
||||
file=sys.stderr)
|
||||
frequency = 0xFFFFFFFF
|
||||
|
||||
# Flags (optional 3rd column)
|
||||
flags = 0
|
||||
if len(parts) >= 3:
|
||||
flag_str = parts[2].strip().lower()
|
||||
if "proper" in flag_str:
|
||||
flags |= 0x01
|
||||
if "profanity" in flag_str:
|
||||
flags |= 0x02
|
||||
|
||||
entries.append((word, frequency, flags))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def write_glide(entries, output_path, language_tag, sorted_flag):
|
||||
"""Write entries to a binary .glide file."""
|
||||
lang_bytes = language_tag.encode("utf-8")
|
||||
if len(lang_bytes) > 18:
|
||||
print(f"ERROR: Language tag '{language_tag}' exceeds 18 bytes",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Compute header flags
|
||||
hdr_flags = 0
|
||||
if sorted_flag:
|
||||
hdr_flags |= 0x01 # bit 0: sorted alphabetically
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
# === Write header (32 bytes) ===
|
||||
header = bytearray(DICT_HEADER_SIZE)
|
||||
|
||||
# Magic (bytes 0-3)
|
||||
struct.pack_into("<I", header, 0, DICT_MAGIC)
|
||||
# Version (bytes 4-5)
|
||||
struct.pack_into("<H", header, 4, DICT_VERSION)
|
||||
# Flags (bytes 6-7)
|
||||
struct.pack_into("<H", header, 6, hdr_flags)
|
||||
# Entry count (bytes 8-11)
|
||||
struct.pack_into("<I", header, 8, len(entries))
|
||||
# Language tag length (bytes 12-13)
|
||||
struct.pack_into("<H", header, 12, len(lang_bytes))
|
||||
# Language tag (bytes 14+)
|
||||
header[14:14 + len(lang_bytes)] = lang_bytes
|
||||
# Remaining bytes stay zero (padding to 32-byte boundary)
|
||||
|
||||
f.write(header)
|
||||
|
||||
# === Write entries ===
|
||||
for word, frequency, entry_flags in entries:
|
||||
word_bytes = word.encode("utf-8")
|
||||
word_len = len(word_bytes)
|
||||
|
||||
# Word length (1 byte)
|
||||
f.write(struct.pack("<B", word_len))
|
||||
# Word bytes (variable length)
|
||||
f.write(word_bytes)
|
||||
# Frequency (4 bytes, little-endian uint32)
|
||||
f.write(struct.pack("<I", frequency))
|
||||
# Flags (1 byte)
|
||||
f.write(struct.pack("<B", entry_flags))
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
# Read input
|
||||
print(f"Reading: {args.input}")
|
||||
entries = read_tsv(args.input)
|
||||
|
||||
if not entries:
|
||||
print("ERROR: No valid entries found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Mark proper nouns if requested
|
||||
if args.proper_nouns:
|
||||
new_entries = []
|
||||
for word, freq, flags in entries:
|
||||
if word[0].isupper():
|
||||
flags |= 0x01
|
||||
new_entries.append((word.lower(), freq, flags))
|
||||
entries = new_entries
|
||||
else:
|
||||
# Lowercase all words
|
||||
entries = [(word.lower(), freq, flags) for word, freq, flags in entries]
|
||||
|
||||
# Remove duplicates (keep highest frequency)
|
||||
seen = {}
|
||||
for word, freq, flags in entries:
|
||||
if word not in seen or freq > seen[word][1]:
|
||||
seen[word] = (word, freq, flags)
|
||||
entries = list(seen.values())
|
||||
|
||||
# Sort if requested
|
||||
if args.sort:
|
||||
entries.sort(key=lambda e: e[0])
|
||||
|
||||
# Write output
|
||||
print(f"Writing: {args.output}")
|
||||
write_glide(entries, args.output, args.lang, args.sort)
|
||||
|
||||
# Summary
|
||||
file_size = os.path.getsize(args.output)
|
||||
max_freq = max(e[1] for e in entries) if entries else 0
|
||||
min_freq = min(e[1] for e in entries) if entries else 0
|
||||
|
||||
print(f"\n=== Dictionary Summary ===")
|
||||
print(f" Language: {args.lang}")
|
||||
print(f" Words: {len(entries)}")
|
||||
print(f" File size: {file_size:,} bytes ({file_size / 1024:.1f} KB)")
|
||||
print(f" Sorted: {'yes' if args.sort else 'no'}")
|
||||
print(f" Freq range: {min_freq:,} — {max_freq:,}")
|
||||
print(f" Format: GLID v{DICT_VERSION}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "=== libswipetype Test Runner ==="
|
||||
echo ""
|
||||
|
||||
# ---- Core C++ Tests ----
|
||||
echo ">>> Building and running swipetype-core tests..."
|
||||
cd "$ROOT_DIR/swipetype-core"
|
||||
|
||||
cmake -B build \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DGLIDE_BUILD_TESTS=ON \
|
||||
2>&1 | tail -5
|
||||
|
||||
cmake --build build --parallel "$(nproc)" 2>&1 | tail -5
|
||||
|
||||
echo ">>> Running C++ unit tests..."
|
||||
cd build
|
||||
ctest --output-on-failure --verbose
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo ""
|
||||
echo ">>> Core tests complete."
|
||||
echo ""
|
||||
|
||||
# ---- Android Tests (if Gradle available) ----
|
||||
if command -v ./gradlew &> /dev/null || [ -f "$ROOT_DIR/gradlew" ]; then
|
||||
echo ">>> Running Android unit tests..."
|
||||
cd "$ROOT_DIR"
|
||||
chmod +x gradlew
|
||||
./gradlew test --stacktrace 2>&1 | tail -20
|
||||
echo ">>> Android tests complete."
|
||||
else
|
||||
echo ">>> Skipping Android tests (no gradlew found)."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== All tests finished ==="
|
||||
@@ -0,0 +1,21 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'libswipetype'
|
||||
|
||||
include ':swipetype-android'
|
||||
include ':adapters:heliboard'
|
||||
include ':sample-app'
|
||||
@@ -0,0 +1,37 @@
|
||||
cmake_minimum_required(VERSION 3.18)
|
||||
project(swipetype-android-jni VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# ============================================================================
|
||||
# JNI Shared Library
|
||||
# ============================================================================
|
||||
|
||||
add_library(glide_jni SHARED
|
||||
src/main/cpp/GestureLibJNI.cpp
|
||||
)
|
||||
|
||||
# Link against swipetype-core (built from sibling directory)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../swipetype-core
|
||||
${CMAKE_CURRENT_BINARY_DIR}/swipetype-core)
|
||||
|
||||
target_link_libraries(glide_jni
|
||||
PRIVATE
|
||||
swipetype-core
|
||||
log # Android logging
|
||||
)
|
||||
|
||||
target_include_directories(glide_jni
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../swipetype-core/include
|
||||
)
|
||||
|
||||
# 16 KB page-size alignment required for Android 15+ (API 35+)
|
||||
target_link_options(glide_jni PRIVATE -Wl,-z,max-page-size=16384)
|
||||
|
||||
# Strip in release for smaller .so
|
||||
if(CMAKE_BUILD_TYPE STREQUAL "Release")
|
||||
target_link_options(glide_jni PRIVATE -s)
|
||||
endif()
|
||||
@@ -0,0 +1,66 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'dev.dettmer.swipetype.android'
|
||||
compileSdk 34
|
||||
ndkVersion '25.2.9519653'
|
||||
|
||||
defaultConfig {
|
||||
minSdk 21
|
||||
targetSdk 34
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags '-std=c++17 -O2 -Wall -Wextra'
|
||||
arguments '-DANDROID_STL=c++_static',
|
||||
'-DSWIPETYPE_BUILD_TESTS=OFF'
|
||||
}
|
||||
}
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
|
||||
}
|
||||
debug {
|
||||
jniDebuggable true
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path 'CMakeLists.txt'
|
||||
version '3.18.1+'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.srcDirs = ['src/main/java']
|
||||
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||
}
|
||||
test {
|
||||
java.srcDirs = ['src/test/java']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.11.1'
|
||||
testImplementation 'org.mockito:mockito-core:5.8.0'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="dev.dettmer.swipetype.android">
|
||||
<!-- Library module — no application or activities -->
|
||||
</manifest>
|
||||
@@ -0,0 +1,318 @@
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <android/log.h>
|
||||
|
||||
#include "swipetype/GestureEngine.h"
|
||||
#include "swipetype/GesturePath.h"
|
||||
#include "swipetype/GestureCandidate.h"
|
||||
#include "swipetype/KeyboardLayout.h"
|
||||
#include "swipetype/SwipeTypeTypes.h"
|
||||
|
||||
#define LOG_TAG "SwipeTypeJNI"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
/**
|
||||
* @file GestureLibJNI.cpp
|
||||
* @brief JNI bridge between SwipeTypeEngine.java and swipetype-core C++ library.
|
||||
*
|
||||
* This file:
|
||||
* - Converts Java arrays to C++ data structures
|
||||
* - Manages GestureEngine lifetime via opaque handles (jlong pointers)
|
||||
* - Converts C++ results back to Java arrays/strings
|
||||
* - Handles all JNI exceptions to prevent native crashes from reaching Java
|
||||
*
|
||||
* Threading: All methods assume external synchronization (provided by
|
||||
* SwipeTypeEngine.java's synchronized blocks).
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a KeyboardLayout from JNI arrays.
|
||||
*/
|
||||
static swipetype::KeyboardLayout buildLayout(
|
||||
JNIEnv* env,
|
||||
jfloatArray keyPositionsX, jfloatArray keyPositionsY,
|
||||
jfloatArray keyWidths, jfloatArray keyHeights,
|
||||
jintArray keyCodePoints, jint keyCount,
|
||||
jfloat layoutWidth, jfloat layoutHeight,
|
||||
jstring languageTag) {
|
||||
|
||||
swipetype::KeyboardLayout layout;
|
||||
|
||||
// Language tag
|
||||
if (languageTag != nullptr) {
|
||||
const char* langStr = env->GetStringUTFChars(languageTag, nullptr);
|
||||
if (langStr) {
|
||||
layout.languageTag = std::string(langStr);
|
||||
env->ReleaseStringUTFChars(languageTag, langStr);
|
||||
}
|
||||
}
|
||||
|
||||
layout.layoutWidth = layoutWidth;
|
||||
layout.layoutHeight = layoutHeight;
|
||||
|
||||
if (keyCount <= 0) return layout;
|
||||
|
||||
jfloat* xArr = env->GetFloatArrayElements(keyPositionsX, nullptr);
|
||||
jfloat* yArr = env->GetFloatArrayElements(keyPositionsY, nullptr);
|
||||
jfloat* wArr = env->GetFloatArrayElements(keyWidths, nullptr);
|
||||
jfloat* hArr = env->GetFloatArrayElements(keyHeights, nullptr);
|
||||
jint* cpArr = env->GetIntArrayElements(keyCodePoints, nullptr);
|
||||
|
||||
if (xArr && yArr && wArr && hArr && cpArr) {
|
||||
layout.keys.reserve(keyCount);
|
||||
for (jint i = 0; i < keyCount; ++i) {
|
||||
swipetype::KeyDescriptor key;
|
||||
key.centerX = xArr[i];
|
||||
key.centerY = yArr[i];
|
||||
key.width = wArr[i];
|
||||
key.height = hArr[i];
|
||||
key.codePoint = cpArr[i];
|
||||
key.label = (cpArr[i] > 0 && cpArr[i] < 128)
|
||||
? std::string(1, static_cast<char>(cpArr[i]))
|
||||
: std::string();
|
||||
layout.keys.push_back(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (xArr) env->ReleaseFloatArrayElements(keyPositionsX, xArr, JNI_ABORT);
|
||||
if (yArr) env->ReleaseFloatArrayElements(keyPositionsY, yArr, JNI_ABORT);
|
||||
if (wArr) env->ReleaseFloatArrayElements(keyWidths, wArr, JNI_ABORT);
|
||||
if (hArr) env->ReleaseFloatArrayElements(keyHeights, hArr, JNI_ABORT);
|
||||
if (cpArr) env->ReleaseIntArrayElements(keyCodePoints, cpArr, JNI_ABORT);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JNI Method Implementations
|
||||
// ============================================================================
|
||||
|
||||
extern "C" {
|
||||
|
||||
/**
|
||||
* Initialize the native engine with layout and dictionary file path.
|
||||
*
|
||||
* @return Native handle (cast GestureEngine* to jlong), or 0 on failure.
|
||||
*/
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_dev_dettmer_swipetype_android_SwipeTypeEngine_nativeInit(
|
||||
JNIEnv* env, jclass /*clazz*/,
|
||||
jfloatArray keyPositionsX, jfloatArray keyPositionsY,
|
||||
jfloatArray keyWidths, jfloatArray keyHeights,
|
||||
jintArray keyCodePoints, jint keyCount,
|
||||
jfloat layoutWidth, jfloat layoutHeight,
|
||||
jstring languageTag, jstring dictPath) {
|
||||
|
||||
try {
|
||||
swipetype::KeyboardLayout layout = buildLayout(
|
||||
env, keyPositionsX, keyPositionsY, keyWidths, keyHeights,
|
||||
keyCodePoints, keyCount, layoutWidth, layoutHeight, languageTag);
|
||||
|
||||
const char* pathStr = env->GetStringUTFChars(dictPath, nullptr);
|
||||
std::string dictPathStr(pathStr ? pathStr : "");
|
||||
if (pathStr) env->ReleaseStringUTFChars(dictPath, pathStr);
|
||||
|
||||
auto* engine = new swipetype::GestureEngine();
|
||||
if (!engine->init(layout, dictPathStr)) {
|
||||
LOGE("Failed to initialize engine: %s",
|
||||
engine->getLastError().message.c_str());
|
||||
delete engine;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOGI("Engine initialized with dictionary: %s", dictPathStr.c_str());
|
||||
return reinterpret_cast<jlong>(engine);
|
||||
} catch (...) {
|
||||
LOGE("Exception in nativeInit");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with dictionary data from memory (byte array).
|
||||
*/
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_dev_dettmer_swipetype_android_SwipeTypeEngine_nativeInitWithData(
|
||||
JNIEnv* env, jclass /*clazz*/,
|
||||
jfloatArray keyPositionsX, jfloatArray keyPositionsY,
|
||||
jfloatArray keyWidths, jfloatArray keyHeights,
|
||||
jintArray keyCodePoints, jint keyCount,
|
||||
jfloat layoutWidth, jfloat layoutHeight,
|
||||
jstring languageTag, jbyteArray dictData) {
|
||||
|
||||
try {
|
||||
swipetype::KeyboardLayout layout = buildLayout(
|
||||
env, keyPositionsX, keyPositionsY, keyWidths, keyHeights,
|
||||
keyCodePoints, keyCount, layoutWidth, layoutHeight, languageTag);
|
||||
|
||||
jsize dataSize = env->GetArrayLength(dictData);
|
||||
jbyte* dataPtr = env->GetByteArrayElements(dictData, nullptr);
|
||||
|
||||
auto* engine = new swipetype::GestureEngine();
|
||||
bool ok = engine->initWithData(layout,
|
||||
reinterpret_cast<const uint8_t*>(dataPtr),
|
||||
static_cast<size_t>(dataSize));
|
||||
|
||||
env->ReleaseByteArrayElements(dictData, dataPtr, JNI_ABORT);
|
||||
|
||||
if (!ok) {
|
||||
LOGE("Failed to initialize engine from memory: %s",
|
||||
engine->getLastError().message.c_str());
|
||||
delete engine;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOGI("Engine initialized from memory (%d bytes)", (int)dataSize);
|
||||
return reinterpret_cast<jlong>(engine);
|
||||
} catch (...) {
|
||||
LOGE("Exception in nativeInitWithData");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize a gesture path and write results to output arrays.
|
||||
*
|
||||
* @return Number of candidates written, or -1 on error.
|
||||
*/
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_dev_dettmer_swipetype_android_SwipeTypeEngine_nativeRecognize(
|
||||
JNIEnv* env, jclass /*clazz*/,
|
||||
jlong handle,
|
||||
jfloatArray xCoords, jfloatArray yCoords, jlongArray timestamps,
|
||||
jint pointCount, jint maxCandidates,
|
||||
jobjectArray outWords, jfloatArray outScores, jintArray outFlags) {
|
||||
|
||||
try {
|
||||
auto* engine = reinterpret_cast<swipetype::GestureEngine*>(handle);
|
||||
if (engine == nullptr) return -1;
|
||||
|
||||
// Build raw path from JNI arrays
|
||||
jfloat* xArr = env->GetFloatArrayElements(xCoords, nullptr);
|
||||
jfloat* yArr = env->GetFloatArrayElements(yCoords, nullptr);
|
||||
jlong* tArr = env->GetLongArrayElements(timestamps, nullptr);
|
||||
|
||||
swipetype::RawGesturePath raw;
|
||||
raw.points.reserve(pointCount);
|
||||
if (xArr && yArr && tArr) {
|
||||
for (jint i = 0; i < pointCount; ++i) {
|
||||
raw.points.emplace_back(xArr[i], yArr[i], static_cast<int64_t>(tArr[i]));
|
||||
}
|
||||
}
|
||||
|
||||
if (xArr) env->ReleaseFloatArrayElements(xCoords, xArr, JNI_ABORT);
|
||||
if (yArr) env->ReleaseFloatArrayElements(yCoords, yArr, JNI_ABORT);
|
||||
if (tArr) env->ReleaseLongArrayElements(timestamps, tArr, JNI_ABORT);
|
||||
|
||||
// Recognize
|
||||
auto candidates = engine->recognize(raw, maxCandidates);
|
||||
int count = static_cast<int>(
|
||||
std::min(candidates.size(), static_cast<size_t>(maxCandidates)));
|
||||
|
||||
// Debug: log all candidates with scores
|
||||
LOGD("recognize: %d pts -> %d candidates", (int)pointCount, count);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
LOGD(" #%d %-12s conf=%.4f dtw=%.4f freq=%.4f",
|
||||
i + 1,
|
||||
candidates[i].word.c_str(),
|
||||
candidates[i].confidence,
|
||||
candidates[i].dtwScore,
|
||||
candidates[i].frequencyScore);
|
||||
}
|
||||
|
||||
// Write results back to Java arrays
|
||||
jfloat* scoreArr = env->GetFloatArrayElements(outScores, nullptr);
|
||||
jint* flagArr = env->GetIntArrayElements(outFlags, nullptr);
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
jstring word = env->NewStringUTF(candidates[i].word.c_str());
|
||||
env->SetObjectArrayElement(outWords, i, word);
|
||||
env->DeleteLocalRef(word);
|
||||
|
||||
if (scoreArr) scoreArr[i] = candidates[i].confidence;
|
||||
if (flagArr) flagArr[i] = static_cast<jint>(candidates[i].sourceFlags);
|
||||
}
|
||||
|
||||
if (scoreArr) env->ReleaseFloatArrayElements(outScores, scoreArr, 0);
|
||||
if (flagArr) env->ReleaseIntArrayElements(outFlags, flagArr, 0);
|
||||
|
||||
return count;
|
||||
} catch (...) {
|
||||
LOGE("Exception in nativeRecognize");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update keyboard layout without reloading dictionary.
|
||||
*/
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_dev_dettmer_swipetype_android_SwipeTypeEngine_nativeUpdateLayout(
|
||||
JNIEnv* env, jclass /*clazz*/,
|
||||
jlong handle,
|
||||
jfloatArray keyPositionsX, jfloatArray keyPositionsY,
|
||||
jfloatArray keyWidths, jfloatArray keyHeights,
|
||||
jintArray keyCodePoints, jint keyCount,
|
||||
jfloat layoutWidth, jfloat layoutHeight) {
|
||||
|
||||
try {
|
||||
auto* engine = reinterpret_cast<swipetype::GestureEngine*>(handle);
|
||||
if (engine == nullptr) return JNI_FALSE;
|
||||
|
||||
swipetype::KeyboardLayout layout = buildLayout(
|
||||
env, keyPositionsX, keyPositionsY, keyWidths, keyHeights,
|
||||
keyCodePoints, keyCount, layoutWidth, layoutHeight, nullptr);
|
||||
|
||||
return engine->updateLayout(layout) ? JNI_TRUE : JNI_FALSE;
|
||||
} catch (...) {
|
||||
LOGE("Exception in nativeUpdateLayout");
|
||||
return JNI_FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the engine and free resources.
|
||||
*/
|
||||
JNIEXPORT void JNICALL
|
||||
Java_dev_dettmer_swipetype_android_SwipeTypeEngine_nativeShutdown(
|
||||
JNIEnv* /*env*/, jclass /*clazz*/, jlong handle) {
|
||||
auto* engine = reinterpret_cast<swipetype::GestureEngine*>(handle);
|
||||
if (engine != nullptr) {
|
||||
engine->shutdown();
|
||||
delete engine;
|
||||
LOGI("Native engine shut down");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if engine is initialized.
|
||||
*/
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_dev_dettmer_swipetype_android_SwipeTypeEngine_nativeIsInitialized(
|
||||
JNIEnv* /*env*/, jclass /*clazz*/, jlong handle) {
|
||||
auto* engine = reinterpret_cast<swipetype::GestureEngine*>(handle);
|
||||
if (engine == nullptr) return JNI_FALSE;
|
||||
return engine->isInitialized() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JNI Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
JNIEXPORT jint JNI_OnLoad(JavaVM* /*vm*/, void* /*reserved*/) {
|
||||
LOGI("libswipetype JNI loaded");
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNI_OnUnload(JavaVM* /*vm*/, void* /*reserved*/) {
|
||||
LOGI("libswipetype JNI unloaded");
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
/**
|
||||
* A single touch point in a gesture path.
|
||||
*
|
||||
* <p>Coordinates are in the keyboard view's coordinate system using
|
||||
* density-independent pixels (dp). Origin (0,0) is the top-left corner
|
||||
* of the keyboard view.</p>
|
||||
*
|
||||
* <p>This is an immutable value type.</p>
|
||||
*/
|
||||
public final class GesturePoint {
|
||||
|
||||
/** X coordinate in dp. */
|
||||
public final float x;
|
||||
|
||||
/** Y coordinate in dp. */
|
||||
public final float y;
|
||||
|
||||
/** Timestamp in milliseconds since the start of this gesture.
|
||||
* The first point should have timestamp 0. */
|
||||
public final long timestamp;
|
||||
|
||||
/**
|
||||
* Create a new gesture point.
|
||||
*
|
||||
* @param x X coordinate in dp
|
||||
* @param y Y coordinate in dp
|
||||
* @param timestamp Milliseconds since gesture start
|
||||
*/
|
||||
public GesturePoint(float x, float y, long timestamp) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GesturePoint{x=" + x + ", y=" + y + ", t=" + timestamp + "}";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof GesturePoint)) return false;
|
||||
GesturePoint that = (GesturePoint) o;
|
||||
return Float.compare(that.x, x) == 0
|
||||
&& Float.compare(that.y, y) == 0
|
||||
&& timestamp == that.timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Float.floatToIntBits(x);
|
||||
result = 31 * result + Float.floatToIntBits(y);
|
||||
result = 31 * result + Long.hashCode(timestamp);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Describes the complete keyboard layout for gesture recognition.
|
||||
*
|
||||
* <p>Populated by the {@link SwipeTypeAdapter} implementation from the keyboard
|
||||
* app's internal layout representation. All coordinates are in
|
||||
* density-independent pixels (dp).</p>
|
||||
*/
|
||||
public final class KeyboardLayoutDescriptor {
|
||||
|
||||
/** BCP 47 language tag (e.g., "en-US", "de-DE"). */
|
||||
public final String languageTag;
|
||||
|
||||
/** All keys on the keyboard. */
|
||||
public final List<KeyInfo> keys;
|
||||
|
||||
/** Total keyboard width in dp. */
|
||||
public final float layoutWidth;
|
||||
|
||||
/** Total keyboard height in dp. */
|
||||
public final float layoutHeight;
|
||||
|
||||
/**
|
||||
* Create a new layout descriptor.
|
||||
*
|
||||
* @param languageTag BCP 47 language tag
|
||||
* @param keys List of all keys
|
||||
* @param layoutWidth Total keyboard width in dp
|
||||
* @param layoutHeight Total keyboard height in dp
|
||||
*/
|
||||
public KeyboardLayoutDescriptor(String languageTag, List<KeyInfo> keys,
|
||||
float layoutWidth, float layoutHeight) {
|
||||
this.languageTag = languageTag;
|
||||
this.keys = keys;
|
||||
this.layoutWidth = layoutWidth;
|
||||
this.layoutHeight = layoutHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a single key on the keyboard.
|
||||
*/
|
||||
public static final class KeyInfo {
|
||||
|
||||
/** Display label (e.g., "a", "shift"). */
|
||||
public final String label;
|
||||
|
||||
/** Unicode code point. -1 for non-character keys. */
|
||||
public final int codePoint;
|
||||
|
||||
/** Key center X coordinate in dp. */
|
||||
public final float centerX;
|
||||
|
||||
/** Key center Y coordinate in dp. */
|
||||
public final float centerY;
|
||||
|
||||
/** Key width in dp. */
|
||||
public final float width;
|
||||
|
||||
/** Key height in dp. */
|
||||
public final float height;
|
||||
|
||||
/**
|
||||
* Create a new key info.
|
||||
*
|
||||
* @param label Display label
|
||||
* @param codePoint Unicode code point (-1 for non-character keys)
|
||||
* @param centerX Center X in dp
|
||||
* @param centerY Center Y in dp
|
||||
* @param width Width in dp
|
||||
* @param height Height in dp
|
||||
*/
|
||||
public KeyInfo(String label, int codePoint,
|
||||
float centerX, float centerY,
|
||||
float width, float height) {
|
||||
this.label = label;
|
||||
this.codePoint = codePoint;
|
||||
this.centerX = centerX;
|
||||
this.centerY = centerY;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
/** @return true if this key represents a character. */
|
||||
public boolean isCharacterKey() {
|
||||
return codePoint >= 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Interface that every keyboard adapter must implement.
|
||||
*
|
||||
* <p>This is the contract between libswipetype and any keyboard app.
|
||||
* Each keyboard project (HeliBoard, FlorisBoard, etc.) creates one implementation
|
||||
* of this interface that translates between their internal representation and
|
||||
* the generic swipetype API.</p>
|
||||
*
|
||||
* <p>Thread safety: All callbacks are invoked on the thread that called
|
||||
* {@link SwipeTypeEngine#processGesture}. Implementations must be prepared for
|
||||
* calls from any thread.</p>
|
||||
*
|
||||
* <p>Lifecycle: The adapter is passed to {@link SwipeTypeEngine#init} and retained
|
||||
* for the lifetime of the engine. The adapter must outlive the engine.</p>
|
||||
*/
|
||||
public interface SwipeTypeAdapter {
|
||||
|
||||
/**
|
||||
* Called when the engine finishes initialization successfully.
|
||||
*
|
||||
* <p>The adapter should store the engine reference if it needs to call
|
||||
* engine methods later (e.g., to reload dictionaries).</p>
|
||||
*
|
||||
* @param engine The initialized SwipeTypeEngine instance.
|
||||
*/
|
||||
void onInit(SwipeTypeEngine engine);
|
||||
|
||||
/**
|
||||
* Called by the engine to obtain the current keyboard layout.
|
||||
*
|
||||
* <p>The adapter must translate the keyboard app's internal layout
|
||||
* representation into a {@link KeyboardLayoutDescriptor}. This method
|
||||
* may be called multiple times (e.g., when the user switches languages
|
||||
* or rotates the screen).</p>
|
||||
*
|
||||
* @return Current keyboard layout. Must not be null. Must contain at least
|
||||
* one character key.
|
||||
*/
|
||||
KeyboardLayoutDescriptor getKeyboardLayout();
|
||||
|
||||
/**
|
||||
* Called when gesture recognition produces word candidates.
|
||||
*
|
||||
* <p>The adapter should forward these candidates to the keyboard app's
|
||||
* suggestion bar / candidate view.</p>
|
||||
*
|
||||
* @param candidates Ranked list of candidates, best first. Never null,
|
||||
* may be empty if recognition failed.
|
||||
*/
|
||||
void onCandidatesReady(List<SwipeTypeCandidate> candidates);
|
||||
|
||||
/**
|
||||
* Called when an error occurs during recognition or initialization.
|
||||
*
|
||||
* <p>The adapter should log the error and optionally show a user-facing
|
||||
* message (e.g., "Dictionary not found").</p>
|
||||
*
|
||||
* @param error The error that occurred. Never null.
|
||||
*/
|
||||
void onError(SwipeTypeError error);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
/**
|
||||
* A word candidate produced by gesture recognition.
|
||||
*
|
||||
* <p>Candidates are ranked by confidence (highest first). This is an
|
||||
* immutable value type.</p>
|
||||
*/
|
||||
public final class SwipeTypeCandidate {
|
||||
|
||||
/** Source flag: word from main dictionary. */
|
||||
public static final int SOURCE_MAIN_DICT = 0x01;
|
||||
|
||||
/** Source flag: word from user dictionary. */
|
||||
public static final int SOURCE_USER_DICT = 0x02;
|
||||
|
||||
/** Source flag: prefix completion. */
|
||||
public static final int SOURCE_COMPLETION = 0x04;
|
||||
|
||||
/** The recognized word (UTF-8). */
|
||||
public final String word;
|
||||
|
||||
/** Confidence score in [0.0, 1.0]. 1.0 = highest confidence. */
|
||||
public final float confidence;
|
||||
|
||||
/** Source flags bitmask (SOURCE_MAIN_DICT, SOURCE_USER_DICT, etc.). */
|
||||
public final int sourceFlags;
|
||||
|
||||
/**
|
||||
* Create a new candidate.
|
||||
*
|
||||
* @param word The recognized word
|
||||
* @param confidence Confidence score in [0.0, 1.0]
|
||||
* @param sourceFlags Source flags bitmask
|
||||
*/
|
||||
public SwipeTypeCandidate(String word, float confidence, int sourceFlags) {
|
||||
this.word = word;
|
||||
this.confidence = confidence;
|
||||
this.sourceFlags = sourceFlags;
|
||||
}
|
||||
|
||||
/** @return true if this candidate came from the main dictionary. */
|
||||
public boolean isFromMainDict() {
|
||||
return (sourceFlags & SOURCE_MAIN_DICT) != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SwipeTypeCandidate{word='" + word + "', confidence=" + confidence + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Main Android-facing class for gesture recognition.
|
||||
*
|
||||
* <p>This is the primary entry point for keyboard apps using libswipetype.
|
||||
* It manages the native engine lifecycle, handles JNI calls, and delivers results
|
||||
* via the {@link SwipeTypeAdapter} interface.</p>
|
||||
*
|
||||
* <h3>Usage:</h3>
|
||||
* <pre>{@code
|
||||
* SwipeTypeEngine engine = new SwipeTypeEngine();
|
||||
* engine.init(context, myAdapter);
|
||||
* engine.loadDictionary("en-US", dictInputStream);
|
||||
*
|
||||
* // When user swipes:
|
||||
* List<GesturePoint> points = collectTouchPoints();
|
||||
* engine.processGesture(points);
|
||||
* // Results delivered via adapter.onCandidatesReady()
|
||||
*
|
||||
* // When done:
|
||||
* engine.shutdown();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Thread safety: All public methods are synchronized on the engine instance.
|
||||
* {@code processGesture()} can be called from any thread; results are delivered
|
||||
* on the caller's thread via the adapter callback.</p>
|
||||
*/
|
||||
public class SwipeTypeEngine {
|
||||
|
||||
private static final String TAG = "SwipeTypeEngine";
|
||||
private static final String NATIVE_LIB = "glide_jni";
|
||||
private static final int DEFAULT_MAX_CANDIDATES = 8;
|
||||
|
||||
private long nativeHandle = 0;
|
||||
private SwipeTypeAdapter adapter;
|
||||
private boolean initialized = false;
|
||||
private Context appContext;
|
||||
|
||||
static {
|
||||
System.loadLibrary(NATIVE_LIB);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Native method declarations
|
||||
// ========================================================================
|
||||
|
||||
private static native long nativeInit(
|
||||
float[] keyPositionsX, float[] keyPositionsY,
|
||||
float[] keyWidths, float[] keyHeights,
|
||||
int[] keyCodePoints, int keyCount,
|
||||
float layoutWidth, float layoutHeight,
|
||||
String languageTag, String dictPath);
|
||||
|
||||
private static native long nativeInitWithData(
|
||||
float[] keyPositionsX, float[] keyPositionsY,
|
||||
float[] keyWidths, float[] keyHeights,
|
||||
int[] keyCodePoints, int keyCount,
|
||||
float layoutWidth, float layoutHeight,
|
||||
String languageTag, byte[] dictData);
|
||||
|
||||
private static native int nativeRecognize(
|
||||
long handle,
|
||||
float[] xCoords, float[] yCoords, long[] timestamps,
|
||||
int pointCount, int maxCandidates,
|
||||
String[] outWords, float[] outScores, int[] outFlags);
|
||||
|
||||
private static native boolean nativeUpdateLayout(
|
||||
long handle,
|
||||
float[] keyPositionsX, float[] keyPositionsY,
|
||||
float[] keyWidths, float[] keyHeights,
|
||||
int[] keyCodePoints, int keyCount,
|
||||
float layoutWidth, float layoutHeight);
|
||||
|
||||
private static native void nativeShutdown(long handle);
|
||||
|
||||
private static native boolean nativeIsInitialized(long handle);
|
||||
|
||||
// ========================================================================
|
||||
// Public API
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the engine with an adapter.
|
||||
*
|
||||
* @param context Android context. Application context is stored internally.
|
||||
* @param adapter Adapter implementation. Must not be null.
|
||||
* @throws IllegalArgumentException if adapter is null
|
||||
*/
|
||||
public synchronized void init(Context context, SwipeTypeAdapter adapter) {
|
||||
if (adapter == null) {
|
||||
throw new IllegalArgumentException("SwipeTypeAdapter must not be null");
|
||||
}
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.adapter = adapter;
|
||||
Log.i(TAG, "SwipeTypeEngine initialized (dictionary not yet loaded)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a dictionary from an InputStream.
|
||||
*
|
||||
* <p>Copies the dictionary to the app's cache directory, then initializes
|
||||
* the native engine with the keyboard layout from the adapter.</p>
|
||||
*
|
||||
* @param languageTag BCP 47 language tag (e.g., "en-US")
|
||||
* @param dictStream InputStream containing the .glide dictionary file
|
||||
* @return true on success
|
||||
*/
|
||||
public synchronized boolean loadDictionary(String languageTag, InputStream dictStream) {
|
||||
if (appContext == null || adapter == null) {
|
||||
Log.e(TAG, "loadDictionary called before init()");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 1: Copy stream to cache file
|
||||
File dictFile = new File(appContext.getCacheDir(), languageTag + ".glide");
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(dictFile);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = dictStream.read(buffer)) != -1) {
|
||||
fos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Failed to write dictionary to cache: " + e.getMessage());
|
||||
adapter.onError(SwipeTypeError.DICT_NOT_FOUND);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Get layout from adapter
|
||||
KeyboardLayoutDescriptor layout = adapter.getKeyboardLayout();
|
||||
if (layout == null || layout.keys == null || layout.keys.isEmpty()) {
|
||||
adapter.onError(SwipeTypeError.LAYOUT_INVALID);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 3: Convert layout to native arrays
|
||||
int keyCount = layout.keys.size();
|
||||
float[] keyX = new float[keyCount];
|
||||
float[] keyY = new float[keyCount];
|
||||
float[] keyW = new float[keyCount];
|
||||
float[] keyH = new float[keyCount];
|
||||
int[] keyCps = new int[keyCount];
|
||||
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
KeyboardLayoutDescriptor.KeyInfo k = layout.keys.get(i);
|
||||
keyX[i] = k.centerX;
|
||||
keyY[i] = k.centerY;
|
||||
keyW[i] = k.width;
|
||||
keyH[i] = k.height;
|
||||
keyCps[i] = k.codePoint;
|
||||
}
|
||||
|
||||
// Step 4: Initialize native engine
|
||||
nativeHandle = nativeInit(keyX, keyY, keyW, keyH, keyCps, keyCount,
|
||||
layout.layoutWidth, layout.layoutHeight,
|
||||
languageTag, dictFile.getAbsolutePath());
|
||||
|
||||
if (nativeHandle == 0) {
|
||||
Log.e(TAG, "Native engine initialization failed");
|
||||
adapter.onError(SwipeTypeError.DICT_CORRUPT);
|
||||
return false;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
Log.i(TAG, "Dictionary loaded: " + languageTag);
|
||||
adapter.onInit(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a gesture (swipe) and deliver word candidates to the adapter.
|
||||
*
|
||||
* <p>Results are delivered synchronously via
|
||||
* {@link SwipeTypeAdapter#onCandidatesReady} on the calling thread.</p>
|
||||
*
|
||||
* @param points Ordered list of touch points. Must contain >= 2 points.
|
||||
*/
|
||||
public synchronized void processGesture(List<GesturePoint> points) {
|
||||
if (!initialized || nativeHandle == 0) {
|
||||
if (adapter != null) adapter.onError(SwipeTypeError.ENGINE_NOT_INITIALIZED);
|
||||
return;
|
||||
}
|
||||
if (points == null || points.size() < 2) {
|
||||
if (adapter != null) adapter.onError(SwipeTypeError.PATH_TOO_SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert list to arrays
|
||||
int n = points.size();
|
||||
float[] xCoords = new float[n];
|
||||
float[] yCoords = new float[n];
|
||||
long[] timestamps = new long[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
GesturePoint p = points.get(i);
|
||||
xCoords[i] = p.x;
|
||||
yCoords[i] = p.y;
|
||||
timestamps[i] = p.timestamp;
|
||||
}
|
||||
|
||||
// Allocate output arrays
|
||||
String[] outWords = new String[DEFAULT_MAX_CANDIDATES];
|
||||
float[] outScores = new float[DEFAULT_MAX_CANDIDATES];
|
||||
int[] outFlags = new int[DEFAULT_MAX_CANDIDATES];
|
||||
|
||||
int count = nativeRecognize(nativeHandle,
|
||||
xCoords, yCoords, timestamps, n, DEFAULT_MAX_CANDIDATES,
|
||||
outWords, outScores, outFlags);
|
||||
|
||||
if (count < 0) {
|
||||
if (adapter != null) adapter.onError(SwipeTypeError.JNI_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build candidate list
|
||||
List<SwipeTypeCandidate> candidates = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (outWords[i] != null) {
|
||||
candidates.add(new SwipeTypeCandidate(outWords[i], outScores[i], outFlags[i]));
|
||||
}
|
||||
}
|
||||
|
||||
if (adapter != null) {
|
||||
adapter.onCandidatesReady(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the engine that the keyboard layout has changed.
|
||||
*
|
||||
* <p>Call this when the user switches languages, rotates the device, or
|
||||
* the keyboard layout changes for any reason.</p>
|
||||
*/
|
||||
public synchronized void notifyLayoutChanged() {
|
||||
if (!initialized || nativeHandle == 0 || adapter == null) return;
|
||||
|
||||
KeyboardLayoutDescriptor layout = adapter.getKeyboardLayout();
|
||||
if (layout == null || layout.keys == null || layout.keys.isEmpty()) {
|
||||
adapter.onError(SwipeTypeError.LAYOUT_INVALID);
|
||||
return;
|
||||
}
|
||||
|
||||
int keyCount = layout.keys.size();
|
||||
float[] keyX = new float[keyCount];
|
||||
float[] keyY = new float[keyCount];
|
||||
float[] keyW = new float[keyCount];
|
||||
float[] keyH = new float[keyCount];
|
||||
int[] keyCps = new int[keyCount];
|
||||
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
KeyboardLayoutDescriptor.KeyInfo k = layout.keys.get(i);
|
||||
keyX[i] = k.centerX;
|
||||
keyY[i] = k.centerY;
|
||||
keyW[i] = k.width;
|
||||
keyH[i] = k.height;
|
||||
keyCps[i] = k.codePoint;
|
||||
}
|
||||
|
||||
boolean ok = nativeUpdateLayout(nativeHandle,
|
||||
keyX, keyY, keyW, keyH, keyCps, keyCount,
|
||||
layout.layoutWidth, layout.layoutHeight);
|
||||
|
||||
Log.i(TAG, "Layout updated: " + (ok ? "success" : "failed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the engine and free all native resources.
|
||||
*
|
||||
* <p>Safe to call multiple times.</p>
|
||||
*/
|
||||
public synchronized void shutdown() {
|
||||
if (nativeHandle != 0) {
|
||||
nativeShutdown(nativeHandle);
|
||||
nativeHandle = 0;
|
||||
}
|
||||
initialized = false;
|
||||
Log.i(TAG, "SwipeTypeEngine shut down");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the engine is initialized and ready for gesture processing
|
||||
*/
|
||||
public synchronized boolean isInitialized() {
|
||||
return initialized && nativeHandle != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
/**
|
||||
* Error codes from the swipetype engine.
|
||||
*
|
||||
* <p>Delivered to the adapter via {@link SwipeTypeAdapter#onError(SwipeTypeError)}.</p>
|
||||
*/
|
||||
public enum SwipeTypeError {
|
||||
|
||||
/** Dictionary file not found at the specified path. */
|
||||
DICT_NOT_FOUND(1, "Dictionary file not found"),
|
||||
|
||||
/** Dictionary file is corrupt or has an invalid format. */
|
||||
DICT_CORRUPT(2, "Dictionary file is corrupt or invalid format"),
|
||||
|
||||
/** Dictionary format version is not supported by this library version. */
|
||||
DICT_VERSION_MISMATCH(3, "Dictionary format version not supported"),
|
||||
|
||||
/** Keyboard layout is invalid (no keys, zero dimensions, etc.). */
|
||||
LAYOUT_INVALID(4, "Keyboard layout is invalid or empty"),
|
||||
|
||||
/** Gesture path has too few points for recognition. */
|
||||
PATH_TOO_SHORT(5, "Gesture path has too few points"),
|
||||
|
||||
/** Engine is not initialized — call init() and loadDictionary() first. */
|
||||
ENGINE_NOT_INITIALIZED(6, "Engine not initialized"),
|
||||
|
||||
/** Out of memory during processing. */
|
||||
OUT_OF_MEMORY(7, "Out of memory during processing"),
|
||||
|
||||
/** Internal JNI bridge error. */
|
||||
JNI_ERROR(100, "JNI bridge error"),
|
||||
|
||||
/** Unknown error. */
|
||||
UNKNOWN(999, "Unknown error");
|
||||
|
||||
/** Numeric error code. */
|
||||
public final int code;
|
||||
|
||||
/** Human-readable error description. */
|
||||
public final String message;
|
||||
|
||||
SwipeTypeError(int code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a SwipeTypeError by its numeric code.
|
||||
*
|
||||
* @param code Error code from native layer
|
||||
* @return Matching SwipeTypeError, or UNKNOWN if code not recognized
|
||||
*/
|
||||
public static SwipeTypeError fromCode(int code) {
|
||||
for (SwipeTypeError e : values()) {
|
||||
if (e.code == code) return e;
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
/**
|
||||
* A single touch point in a gesture path.
|
||||
*
|
||||
* <p>Coordinates are in the keyboard view's coordinate system using
|
||||
* density-independent pixels (dp). Origin (0,0) is the top-left corner
|
||||
* of the keyboard view.</p>
|
||||
*
|
||||
* <p>This is an immutable value type.</p>
|
||||
*/
|
||||
public final class GesturePoint {
|
||||
|
||||
/** X coordinate in dp. */
|
||||
public final float x;
|
||||
|
||||
/** Y coordinate in dp. */
|
||||
public final float y;
|
||||
|
||||
/** Timestamp in milliseconds since the start of this gesture.
|
||||
* The first point should have timestamp 0. */
|
||||
public final long timestamp;
|
||||
|
||||
/**
|
||||
* Create a new gesture point.
|
||||
*
|
||||
* @param x X coordinate in dp
|
||||
* @param y Y coordinate in dp
|
||||
* @param timestamp Milliseconds since gesture start
|
||||
*/
|
||||
public GesturePoint(float x, float y, long timestamp) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GesturePoint{x=" + x + ", y=" + y + ", t=" + timestamp + "}";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof GesturePoint)) return false;
|
||||
GesturePoint that = (GesturePoint) o;
|
||||
return Float.compare(that.x, x) == 0
|
||||
&& Float.compare(that.y, y) == 0
|
||||
&& timestamp == that.timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Float.floatToIntBits(x);
|
||||
result = 31 * result + Float.floatToIntBits(y);
|
||||
result = 31 * result + Long.hashCode(timestamp);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Describes the complete keyboard layout for gesture recognition.
|
||||
*
|
||||
* <p>Populated by the {@link SwipeTypeAdapter} implementation from the keyboard
|
||||
* app's internal layout representation. All coordinates are in
|
||||
* density-independent pixels (dp).</p>
|
||||
*/
|
||||
public final class KeyboardLayoutDescriptor {
|
||||
|
||||
/** BCP 47 language tag (e.g., "en-US", "de-DE"). */
|
||||
public final String languageTag;
|
||||
|
||||
/** All keys on the keyboard. */
|
||||
public final List<KeyInfo> keys;
|
||||
|
||||
/** Total keyboard width in dp. */
|
||||
public final float layoutWidth;
|
||||
|
||||
/** Total keyboard height in dp. */
|
||||
public final float layoutHeight;
|
||||
|
||||
/**
|
||||
* Create a new layout descriptor.
|
||||
*
|
||||
* @param languageTag BCP 47 language tag
|
||||
* @param keys List of all keys
|
||||
* @param layoutWidth Total keyboard width in dp
|
||||
* @param layoutHeight Total keyboard height in dp
|
||||
*/
|
||||
public KeyboardLayoutDescriptor(String languageTag, List<KeyInfo> keys,
|
||||
float layoutWidth, float layoutHeight) {
|
||||
this.languageTag = languageTag;
|
||||
this.keys = keys;
|
||||
this.layoutWidth = layoutWidth;
|
||||
this.layoutHeight = layoutHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a single key on the keyboard.
|
||||
*/
|
||||
public static final class KeyInfo {
|
||||
|
||||
/** Display label (e.g., "a", "shift"). */
|
||||
public final String label;
|
||||
|
||||
/** Unicode code point. -1 for non-character keys. */
|
||||
public final int codePoint;
|
||||
|
||||
/** Key center X coordinate in dp. */
|
||||
public final float centerX;
|
||||
|
||||
/** Key center Y coordinate in dp. */
|
||||
public final float centerY;
|
||||
|
||||
/** Key width in dp. */
|
||||
public final float width;
|
||||
|
||||
/** Key height in dp. */
|
||||
public final float height;
|
||||
|
||||
/**
|
||||
* Create a new key info.
|
||||
*
|
||||
* @param label Display label
|
||||
* @param codePoint Unicode code point (-1 for non-character keys)
|
||||
* @param centerX Center X in dp
|
||||
* @param centerY Center Y in dp
|
||||
* @param width Width in dp
|
||||
* @param height Height in dp
|
||||
*/
|
||||
public KeyInfo(String label, int codePoint,
|
||||
float centerX, float centerY,
|
||||
float width, float height) {
|
||||
this.label = label;
|
||||
this.codePoint = codePoint;
|
||||
this.centerX = centerX;
|
||||
this.centerY = centerY;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
/** @return true if this key represents a character. */
|
||||
public boolean isCharacterKey() {
|
||||
return codePoint >= 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Interface that every keyboard adapter must implement.
|
||||
*
|
||||
* <p>This is the contract between libswipetype and any keyboard app.
|
||||
* Each keyboard project (HeliBoard, FlorisBoard, etc.) creates one implementation
|
||||
* of this interface that translates between their internal representation and
|
||||
* the generic swipetype API.</p>
|
||||
*
|
||||
* <p>Thread safety: All callbacks are invoked on the thread that called
|
||||
* {@link SwipeTypeEngine#processGesture}. Implementations must be prepared for
|
||||
* calls from any thread.</p>
|
||||
*
|
||||
* <p>Lifecycle: The adapter is passed to {@link SwipeTypeEngine#init} and retained
|
||||
* for the lifetime of the engine. The adapter must outlive the engine.</p>
|
||||
*/
|
||||
public interface SwipeTypeAdapter {
|
||||
|
||||
/**
|
||||
* Called when the engine finishes initialization successfully.
|
||||
*
|
||||
* <p>The adapter should store the engine reference if it needs to call
|
||||
* engine methods later (e.g., to reload dictionaries).</p>
|
||||
*
|
||||
* @param engine The initialized SwipeTypeEngine instance.
|
||||
*/
|
||||
void onInit(SwipeTypeEngine engine);
|
||||
|
||||
/**
|
||||
* Called by the engine to obtain the current keyboard layout.
|
||||
*
|
||||
* <p>The adapter must translate the keyboard app's internal layout
|
||||
* representation into a {@link KeyboardLayoutDescriptor}. This method
|
||||
* may be called multiple times (e.g., when the user switches languages
|
||||
* or rotates the screen).</p>
|
||||
*
|
||||
* @return Current keyboard layout. Must not be null. Must contain at least
|
||||
* one character key.
|
||||
*/
|
||||
KeyboardLayoutDescriptor getKeyboardLayout();
|
||||
|
||||
/**
|
||||
* Called when gesture recognition produces word candidates.
|
||||
*
|
||||
* <p>The adapter should forward these candidates to the keyboard app's
|
||||
* suggestion bar / candidate view.</p>
|
||||
*
|
||||
* @param candidates Ranked list of candidates, best first. Never null,
|
||||
* may be empty if recognition failed.
|
||||
*/
|
||||
void onCandidatesReady(List<SwipeTypeCandidate> candidates);
|
||||
|
||||
/**
|
||||
* Called when an error occurs during recognition or initialization.
|
||||
*
|
||||
* <p>The adapter should log the error and optionally show a user-facing
|
||||
* message (e.g., "Dictionary not found").</p>
|
||||
*
|
||||
* @param error The error that occurred. Never null.
|
||||
*/
|
||||
void onError(SwipeTypeError error);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
/**
|
||||
* A word candidate produced by gesture recognition.
|
||||
*
|
||||
* <p>Candidates are ranked by confidence (highest first). This is an
|
||||
* immutable value type.</p>
|
||||
*/
|
||||
public final class SwipeTypeCandidate {
|
||||
|
||||
/** Source flag: word from main dictionary. */
|
||||
public static final int SOURCE_MAIN_DICT = 0x01;
|
||||
|
||||
/** Source flag: word from user dictionary. */
|
||||
public static final int SOURCE_USER_DICT = 0x02;
|
||||
|
||||
/** Source flag: prefix completion. */
|
||||
public static final int SOURCE_COMPLETION = 0x04;
|
||||
|
||||
/** The recognized word (UTF-8). */
|
||||
public final String word;
|
||||
|
||||
/** Confidence score in [0.0, 1.0]. 1.0 = highest confidence. */
|
||||
public final float confidence;
|
||||
|
||||
/** Source flags bitmask (SOURCE_MAIN_DICT, SOURCE_USER_DICT, etc.). */
|
||||
public final int sourceFlags;
|
||||
|
||||
/**
|
||||
* Create a new candidate.
|
||||
*
|
||||
* @param word The recognized word
|
||||
* @param confidence Confidence score in [0.0, 1.0]
|
||||
* @param sourceFlags Source flags bitmask
|
||||
*/
|
||||
public SwipeTypeCandidate(String word, float confidence, int sourceFlags) {
|
||||
this.word = word;
|
||||
this.confidence = confidence;
|
||||
this.sourceFlags = sourceFlags;
|
||||
}
|
||||
|
||||
/** @return true if this candidate came from the main dictionary. */
|
||||
public boolean isFromMainDict() {
|
||||
return (sourceFlags & SOURCE_MAIN_DICT) != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SwipeTypeCandidate{word='" + word + "', confidence=" + confidence + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Main Android-facing class for gesture recognition.
|
||||
*
|
||||
* <p>This is the primary entry point for keyboard apps using libswipetype.
|
||||
* It manages the native engine lifecycle, handles JNI calls, and delivers results
|
||||
* via the {@link SwipeTypeAdapter} interface.</p>
|
||||
*
|
||||
* <h3>Usage:</h3>
|
||||
* <pre>{@code
|
||||
* SwipeTypeEngine engine = new SwipeTypeEngine();
|
||||
* engine.init(context, myAdapter);
|
||||
* engine.loadDictionary("en-US", dictInputStream);
|
||||
*
|
||||
* // When user swipes:
|
||||
* List<GesturePoint> points = collectTouchPoints();
|
||||
* engine.processGesture(points);
|
||||
* // Results delivered via adapter.onCandidatesReady()
|
||||
*
|
||||
* // When done:
|
||||
* engine.shutdown();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Thread safety: All public methods are synchronized on the engine instance.
|
||||
* {@code processGesture()} can be called from any thread; results are delivered
|
||||
* on the caller's thread via the adapter callback.</p>
|
||||
*/
|
||||
public class SwipeTypeEngine {
|
||||
|
||||
private static final String TAG = "SwipeTypeEngine";
|
||||
private static final String NATIVE_LIB = "glide_jni";
|
||||
private static final int DEFAULT_MAX_CANDIDATES = 8;
|
||||
|
||||
private long nativeHandle = 0;
|
||||
private SwipeTypeAdapter adapter;
|
||||
private boolean initialized = false;
|
||||
private Context appContext;
|
||||
|
||||
static {
|
||||
System.loadLibrary(NATIVE_LIB);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Native method declarations
|
||||
// ========================================================================
|
||||
|
||||
private static native long nativeInit(
|
||||
float[] keyPositionsX, float[] keyPositionsY,
|
||||
float[] keyWidths, float[] keyHeights,
|
||||
int[] keyCodePoints, int keyCount,
|
||||
float layoutWidth, float layoutHeight,
|
||||
String languageTag, String dictPath);
|
||||
|
||||
private static native long nativeInitWithData(
|
||||
float[] keyPositionsX, float[] keyPositionsY,
|
||||
float[] keyWidths, float[] keyHeights,
|
||||
int[] keyCodePoints, int keyCount,
|
||||
float layoutWidth, float layoutHeight,
|
||||
String languageTag, byte[] dictData);
|
||||
|
||||
private static native int nativeRecognize(
|
||||
long handle,
|
||||
float[] xCoords, float[] yCoords, long[] timestamps,
|
||||
int pointCount, int maxCandidates,
|
||||
String[] outWords, float[] outScores, int[] outFlags);
|
||||
|
||||
private static native boolean nativeUpdateLayout(
|
||||
long handle,
|
||||
float[] keyPositionsX, float[] keyPositionsY,
|
||||
float[] keyWidths, float[] keyHeights,
|
||||
int[] keyCodePoints, int keyCount,
|
||||
float layoutWidth, float layoutHeight);
|
||||
|
||||
private static native void nativeShutdown(long handle);
|
||||
|
||||
private static native boolean nativeIsInitialized(long handle);
|
||||
|
||||
// ========================================================================
|
||||
// Public API
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the engine with an adapter.
|
||||
*
|
||||
* @param context Android context. Application context is stored internally.
|
||||
* @param adapter Adapter implementation. Must not be null.
|
||||
* @throws IllegalArgumentException if adapter is null
|
||||
*/
|
||||
public synchronized void init(Context context, SwipeTypeAdapter adapter) {
|
||||
if (adapter == null) {
|
||||
throw new IllegalArgumentException("SwipeTypeAdapter must not be null");
|
||||
}
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.adapter = adapter;
|
||||
Log.i(TAG, "SwipeTypeEngine initialized (dictionary not yet loaded)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a dictionary from an InputStream.
|
||||
*
|
||||
* <p>Copies the dictionary to the app's cache directory, then initializes
|
||||
* the native engine with the keyboard layout from the adapter.</p>
|
||||
*
|
||||
* @param languageTag BCP 47 language tag (e.g., "en-US")
|
||||
* @param dictStream InputStream containing the .glide dictionary file
|
||||
* @return true on success
|
||||
*/
|
||||
public synchronized boolean loadDictionary(String languageTag, InputStream dictStream) {
|
||||
if (appContext == null || adapter == null) {
|
||||
Log.e(TAG, "loadDictionary called before init()");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 1: Copy stream to cache file
|
||||
File dictFile = new File(appContext.getCacheDir(), languageTag + ".glide");
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(dictFile);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = dictStream.read(buffer)) != -1) {
|
||||
fos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Failed to write dictionary to cache: " + e.getMessage());
|
||||
adapter.onError(SwipeTypeError.DICT_NOT_FOUND);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Get layout from adapter
|
||||
KeyboardLayoutDescriptor layout = adapter.getKeyboardLayout();
|
||||
if (layout == null || layout.keys == null || layout.keys.isEmpty()) {
|
||||
adapter.onError(SwipeTypeError.LAYOUT_INVALID);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 3: Convert layout to native arrays
|
||||
int keyCount = layout.keys.size();
|
||||
float[] keyX = new float[keyCount];
|
||||
float[] keyY = new float[keyCount];
|
||||
float[] keyW = new float[keyCount];
|
||||
float[] keyH = new float[keyCount];
|
||||
int[] keyCps = new int[keyCount];
|
||||
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
KeyboardLayoutDescriptor.KeyInfo k = layout.keys.get(i);
|
||||
keyX[i] = k.centerX;
|
||||
keyY[i] = k.centerY;
|
||||
keyW[i] = k.width;
|
||||
keyH[i] = k.height;
|
||||
keyCps[i] = k.codePoint;
|
||||
}
|
||||
|
||||
// Step 4: Initialize native engine
|
||||
nativeHandle = nativeInit(keyX, keyY, keyW, keyH, keyCps, keyCount,
|
||||
layout.layoutWidth, layout.layoutHeight,
|
||||
languageTag, dictFile.getAbsolutePath());
|
||||
|
||||
if (nativeHandle == 0) {
|
||||
Log.e(TAG, "Native engine initialization failed");
|
||||
adapter.onError(SwipeTypeError.DICT_CORRUPT);
|
||||
return false;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
Log.i(TAG, "Dictionary loaded: " + languageTag);
|
||||
adapter.onInit(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a gesture (swipe) and deliver word candidates to the adapter.
|
||||
*
|
||||
* <p>Results are delivered synchronously via
|
||||
* {@link SwipeTypeAdapter#onCandidatesReady} on the calling thread.</p>
|
||||
*
|
||||
* @param points Ordered list of touch points. Must contain >= 2 points.
|
||||
*/
|
||||
public synchronized void processGesture(List<GesturePoint> points) {
|
||||
if (!initialized || nativeHandle == 0) {
|
||||
if (adapter != null) adapter.onError(SwipeTypeError.ENGINE_NOT_INITIALIZED);
|
||||
return;
|
||||
}
|
||||
if (points == null || points.size() < 2) {
|
||||
if (adapter != null) adapter.onError(SwipeTypeError.PATH_TOO_SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert list to arrays
|
||||
int n = points.size();
|
||||
float[] xCoords = new float[n];
|
||||
float[] yCoords = new float[n];
|
||||
long[] timestamps = new long[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
GesturePoint p = points.get(i);
|
||||
xCoords[i] = p.x;
|
||||
yCoords[i] = p.y;
|
||||
timestamps[i] = p.timestamp;
|
||||
}
|
||||
|
||||
// Allocate output arrays
|
||||
String[] outWords = new String[DEFAULT_MAX_CANDIDATES];
|
||||
float[] outScores = new float[DEFAULT_MAX_CANDIDATES];
|
||||
int[] outFlags = new int[DEFAULT_MAX_CANDIDATES];
|
||||
|
||||
int count = nativeRecognize(nativeHandle,
|
||||
xCoords, yCoords, timestamps, n, DEFAULT_MAX_CANDIDATES,
|
||||
outWords, outScores, outFlags);
|
||||
|
||||
if (count < 0) {
|
||||
if (adapter != null) adapter.onError(SwipeTypeError.JNI_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build candidate list
|
||||
List<SwipeTypeCandidate> candidates = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (outWords[i] != null) {
|
||||
candidates.add(new SwipeTypeCandidate(outWords[i], outScores[i], outFlags[i]));
|
||||
}
|
||||
}
|
||||
|
||||
if (adapter != null) {
|
||||
adapter.onCandidatesReady(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the engine that the keyboard layout has changed.
|
||||
*
|
||||
* <p>Call this when the user switches languages, rotates the device, or
|
||||
* the keyboard layout changes for any reason.</p>
|
||||
*/
|
||||
public synchronized void notifyLayoutChanged() {
|
||||
if (!initialized || nativeHandle == 0 || adapter == null) return;
|
||||
|
||||
KeyboardLayoutDescriptor layout = adapter.getKeyboardLayout();
|
||||
if (layout == null || layout.keys == null || layout.keys.isEmpty()) {
|
||||
adapter.onError(SwipeTypeError.LAYOUT_INVALID);
|
||||
return;
|
||||
}
|
||||
|
||||
int keyCount = layout.keys.size();
|
||||
float[] keyX = new float[keyCount];
|
||||
float[] keyY = new float[keyCount];
|
||||
float[] keyW = new float[keyCount];
|
||||
float[] keyH = new float[keyCount];
|
||||
int[] keyCps = new int[keyCount];
|
||||
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
KeyboardLayoutDescriptor.KeyInfo k = layout.keys.get(i);
|
||||
keyX[i] = k.centerX;
|
||||
keyY[i] = k.centerY;
|
||||
keyW[i] = k.width;
|
||||
keyH[i] = k.height;
|
||||
keyCps[i] = k.codePoint;
|
||||
}
|
||||
|
||||
boolean ok = nativeUpdateLayout(nativeHandle,
|
||||
keyX, keyY, keyW, keyH, keyCps, keyCount,
|
||||
layout.layoutWidth, layout.layoutHeight);
|
||||
|
||||
Log.i(TAG, "Layout updated: " + (ok ? "success" : "failed"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the engine and free all native resources.
|
||||
*
|
||||
* <p>Safe to call multiple times.</p>
|
||||
*/
|
||||
public synchronized void shutdown() {
|
||||
if (nativeHandle != 0) {
|
||||
nativeShutdown(nativeHandle);
|
||||
nativeHandle = 0;
|
||||
}
|
||||
initialized = false;
|
||||
Log.i(TAG, "SwipeTypeEngine shut down");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the engine is initialized and ready for gesture processing
|
||||
*/
|
||||
public synchronized boolean isInitialized() {
|
||||
return initialized && nativeHandle != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
/**
|
||||
* Error codes from the swipetype engine.
|
||||
*
|
||||
* <p>Delivered to the adapter via {@link SwipeTypeAdapter#onError(SwipeTypeError)}.</p>
|
||||
*/
|
||||
public enum SwipeTypeError {
|
||||
|
||||
/** Dictionary file not found at the specified path. */
|
||||
DICT_NOT_FOUND(1, "Dictionary file not found"),
|
||||
|
||||
/** Dictionary file is corrupt or has an invalid format. */
|
||||
DICT_CORRUPT(2, "Dictionary file is corrupt or invalid format"),
|
||||
|
||||
/** Dictionary format version is not supported by this library version. */
|
||||
DICT_VERSION_MISMATCH(3, "Dictionary format version not supported"),
|
||||
|
||||
/** Keyboard layout is invalid (no keys, zero dimensions, etc.). */
|
||||
LAYOUT_INVALID(4, "Keyboard layout is invalid or empty"),
|
||||
|
||||
/** Gesture path has too few points for recognition. */
|
||||
PATH_TOO_SHORT(5, "Gesture path has too few points"),
|
||||
|
||||
/** Engine is not initialized — call init() and loadDictionary() first. */
|
||||
ENGINE_NOT_INITIALIZED(6, "Engine not initialized"),
|
||||
|
||||
/** Out of memory during processing. */
|
||||
OUT_OF_MEMORY(7, "Out of memory during processing"),
|
||||
|
||||
/** Internal JNI bridge error. */
|
||||
JNI_ERROR(100, "JNI bridge error"),
|
||||
|
||||
/** Unknown error. */
|
||||
UNKNOWN(999, "Unknown error");
|
||||
|
||||
/** Numeric error code. */
|
||||
public final int code;
|
||||
|
||||
/** Human-readable error description. */
|
||||
public final String message;
|
||||
|
||||
SwipeTypeError(int code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a SwipeTypeError by its numeric code.
|
||||
*
|
||||
* @param code Error code from native layer
|
||||
* @return Matching SwipeTypeError, or UNKNOWN if code not recognized
|
||||
*/
|
||||
public static SwipeTypeError fromCode(int code) {
|
||||
for (SwipeTypeError e : values()) {
|
||||
if (e.code == code) return e;
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.JUnit4;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GesturePoint}.
|
||||
*/
|
||||
@RunWith(JUnit4.class)
|
||||
public class GesturePointTest {
|
||||
|
||||
@Test
|
||||
public void constructorStoresValues() {
|
||||
GesturePoint point = new GesturePoint(12.5f, 34.0f, 1000L);
|
||||
assertEquals(12.5f, point.x, 1e-6f);
|
||||
assertEquals(34.0f, point.y, 1e-6f);
|
||||
assertEquals(1000L, point.timestamp);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void constructorAcceptsZeroValues() {
|
||||
GesturePoint point = new GesturePoint(0.0f, 0.0f, 0L);
|
||||
assertEquals(0.0f, point.x, 1e-6f);
|
||||
assertEquals(0.0f, point.y, 1e-6f);
|
||||
assertEquals(0L, point.timestamp);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void constructorAcceptsNegativeCoordinates() {
|
||||
// Negative dp coordinates can occur before normalization
|
||||
GesturePoint point = new GesturePoint(-1.0f, -2.5f, 500L);
|
||||
assertEquals(-1.0f, point.x, 1e-6f);
|
||||
assertEquals(-2.5f, point.y, 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toStringContainsCoordinates() {
|
||||
GesturePoint point = new GesturePoint(10.0f, 20.0f, 300L);
|
||||
String str = point.toString();
|
||||
assertNotNull(str);
|
||||
assertTrue("toString should contain x value", str.contains("10"));
|
||||
assertTrue("toString should contain y value", str.contains("20"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equalPointsAreEqual() {
|
||||
// TODO(sonnet): If GesturePoint implements equals/hashCode, test that here.
|
||||
// For now, just verify construction succeeds.
|
||||
GesturePoint a = new GesturePoint(5.0f, 10.0f, 100L);
|
||||
GesturePoint b = new GesturePoint(5.0f, 10.0f, 100L);
|
||||
assertNotNull(a);
|
||||
assertNotNull(b);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.dettmer.swipetype.android;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link SwipeTypeEngine} using Robolectric.
|
||||
*
|
||||
* <p>These tests run on the JVM without an Android device or emulator.
|
||||
* They verify the Java-layer logic (init, layout, gesture routing) and
|
||||
* the JNI boundary contract.</p>
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 30)
|
||||
public class SwipeTypeEngineTest {
|
||||
|
||||
private SwipeTypeEngine engine;
|
||||
private TestAdapter adapter;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
adapter = new TestAdapter();
|
||||
try {
|
||||
engine = new SwipeTypeEngine();
|
||||
} catch (UnsatisfiedLinkError | NoClassDefFoundError e) {
|
||||
org.junit.Assume.assumeTrue(
|
||||
"Native library not available in JVM tests: " + e.getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Initialization -----
|
||||
|
||||
@Test
|
||||
public void initWithNullAdapterThrows() {
|
||||
// TODO(sonnet): Assert that engine.init(context, null) throws IllegalArgumentException.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineIsNotInitializedBeforeInit() {
|
||||
// TODO(sonnet): Assert engine.isInitialized() == false before init is called.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
// ----- Gesture handling -----
|
||||
|
||||
@Test
|
||||
public void processGestureWithEmptyPointsReturnsEmpty() {
|
||||
// TODO(sonnet): Init the engine.
|
||||
// Call engine.processGesture(emptyList).
|
||||
// Assert onCandidatesReady was NOT called (or was called with empty list).
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGestureWithNullThrows() {
|
||||
// TODO(sonnet): Assert that engine.processGesture(null) throws NullPointerException.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGestureConvertsPointsCorrectly() {
|
||||
// TODO(sonnet): Verify that GesturePoint x, y, timestamp values
|
||||
// are passed correctly through the JNI boundary.
|
||||
// This requires a mock JNI or instrumented test.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
// ----- Error handling -----
|
||||
|
||||
@Test
|
||||
public void processGestureBeforeInitDeliversError() {
|
||||
// TODO(sonnet): Call engine.processGesture() without calling init first.
|
||||
// Assert adapter.onError() was called with NOT_INITIALIZED error.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shutdownMakesEngineUnavailable() {
|
||||
// TODO(sonnet): Init engine, call shutdown(), then attempt processGesture().
|
||||
// Assert onError is called or exception thrown.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal test adapter implementation
|
||||
// =========================================================================
|
||||
|
||||
private static class TestAdapter implements SwipeTypeAdapter {
|
||||
List<SwipeTypeCandidate> lastCandidates = new ArrayList<>();
|
||||
SwipeTypeError lastError = null;
|
||||
KeyboardLayoutDescriptor lastLayout = null;
|
||||
boolean initCalled = false;
|
||||
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
initCalled = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
return lastLayout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
lastCandidates = new ArrayList<>(candidates);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.JUnit4;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GesturePoint}.
|
||||
*/
|
||||
@RunWith(JUnit4.class)
|
||||
public class GesturePointTest {
|
||||
|
||||
@Test
|
||||
public void constructorStoresValues() {
|
||||
GesturePoint point = new GesturePoint(12.5f, 34.0f, 1000L);
|
||||
assertEquals(12.5f, point.x, 1e-6f);
|
||||
assertEquals(34.0f, point.y, 1e-6f);
|
||||
assertEquals(1000L, point.timestamp);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void constructorAcceptsZeroValues() {
|
||||
GesturePoint point = new GesturePoint(0.0f, 0.0f, 0L);
|
||||
assertEquals(0.0f, point.x, 1e-6f);
|
||||
assertEquals(0.0f, point.y, 1e-6f);
|
||||
assertEquals(0L, point.timestamp);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void constructorAcceptsNegativeCoordinates() {
|
||||
// Negative dp coordinates can occur before normalization
|
||||
GesturePoint point = new GesturePoint(-1.0f, -2.5f, 500L);
|
||||
assertEquals(-1.0f, point.x, 1e-6f);
|
||||
assertEquals(-2.5f, point.y, 1e-6f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toStringContainsCoordinates() {
|
||||
GesturePoint point = new GesturePoint(10.0f, 20.0f, 300L);
|
||||
String str = point.toString();
|
||||
assertNotNull(str);
|
||||
assertTrue("toString should contain x value", str.contains("10"));
|
||||
assertTrue("toString should contain y value", str.contains("20"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equalPointsAreEqual() {
|
||||
// TODO(sonnet): If GesturePoint implements equals/hashCode, test that here.
|
||||
// For now, just verify construction succeeds.
|
||||
GesturePoint a = new GesturePoint(5.0f, 10.0f, 100L);
|
||||
GesturePoint b = new GesturePoint(5.0f, 10.0f, 100L);
|
||||
assertNotNull(a);
|
||||
assertNotNull(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package dev.swipetype.android;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link SwipeTypeEngine} using Robolectric.
|
||||
*
|
||||
* <p>These tests run on the JVM without an Android device or emulator.
|
||||
* They verify the Java-layer logic (init, layout, gesture routing) and
|
||||
* the JNI boundary contract.</p>
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 30)
|
||||
public class SwipeTypeEngineTest {
|
||||
|
||||
private SwipeTypeEngine engine;
|
||||
private TestAdapter adapter;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
adapter = new TestAdapter();
|
||||
try {
|
||||
engine = new SwipeTypeEngine();
|
||||
} catch (UnsatisfiedLinkError | NoClassDefFoundError e) {
|
||||
org.junit.Assume.assumeTrue(
|
||||
"Native library not available in JVM tests: " + e.getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Initialization -----
|
||||
|
||||
@Test
|
||||
public void initWithNullAdapterThrows() {
|
||||
// TODO(sonnet): Assert that engine.init(context, null) throws IllegalArgumentException.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineIsNotInitializedBeforeInit() {
|
||||
// TODO(sonnet): Assert engine.isInitialized() == false before init is called.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
// ----- Gesture handling -----
|
||||
|
||||
@Test
|
||||
public void processGestureWithEmptyPointsReturnsEmpty() {
|
||||
// TODO(sonnet): Init the engine.
|
||||
// Call engine.processGesture(emptyList).
|
||||
// Assert onCandidatesReady was NOT called (or was called with empty list).
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGestureWithNullThrows() {
|
||||
// TODO(sonnet): Assert that engine.processGesture(null) throws NullPointerException.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGestureConvertsPointsCorrectly() {
|
||||
// TODO(sonnet): Verify that GesturePoint x, y, timestamp values
|
||||
// are passed correctly through the JNI boundary.
|
||||
// This requires a mock JNI or instrumented test.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
// ----- Error handling -----
|
||||
|
||||
@Test
|
||||
public void processGestureBeforeInitDeliversError() {
|
||||
// TODO(sonnet): Call engine.processGesture() without calling init first.
|
||||
// Assert adapter.onError() was called with NOT_INITIALIZED error.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shutdownMakesEngineUnavailable() {
|
||||
// TODO(sonnet): Init engine, call shutdown(), then attempt processGesture().
|
||||
// Assert onError is called or exception thrown.
|
||||
org.junit.Assume.assumeTrue("TODO(sonnet): implement", false);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal test adapter implementation
|
||||
// =========================================================================
|
||||
|
||||
private static class TestAdapter implements SwipeTypeAdapter {
|
||||
List<SwipeTypeCandidate> lastCandidates = new ArrayList<>();
|
||||
SwipeTypeError lastError = null;
|
||||
KeyboardLayoutDescriptor lastLayout = null;
|
||||
boolean initCalled = false;
|
||||
|
||||
@Override
|
||||
public void onInit(SwipeTypeEngine engine) {
|
||||
initCalled = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public KeyboardLayoutDescriptor getKeyboardLayout() {
|
||||
return lastLayout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCandidatesReady(List<SwipeTypeCandidate> candidates) {
|
||||
lastCandidates = new ArrayList<>(candidates);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(SwipeTypeError error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
cmake_minimum_required(VERSION 3.18)
|
||||
project(swipetype-core VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# Compiler warnings
|
||||
add_compile_options(-Wall -Wextra -Wpedantic -Wno-unused-parameter)
|
||||
|
||||
# Release optimization
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
if(CMAKE_BUILD_TYPE STREQUAL "Release")
|
||||
add_compile_options(-O2)
|
||||
endif()
|
||||
|
||||
# ============================================================================
|
||||
# Core Library
|
||||
# ============================================================================
|
||||
|
||||
set(SWIPETYPE_CORE_SOURCES
|
||||
src/PathProcessor.cpp
|
||||
src/IdealPathGenerator.cpp
|
||||
src/Scorer.cpp
|
||||
src/DictionaryLoader.cpp
|
||||
src/GestureEngine.cpp
|
||||
src/AdjacencyMap.cpp
|
||||
)
|
||||
|
||||
set(SWIPETYPE_CORE_HEADERS
|
||||
include/swipetype/SwipeTypeTypes.h
|
||||
include/swipetype/GesturePoint.h
|
||||
include/swipetype/GesturePath.h
|
||||
include/swipetype/GestureCandidate.h
|
||||
include/swipetype/KeyboardLayout.h
|
||||
include/swipetype/PathProcessor.h
|
||||
include/swipetype/IdealPathGenerator.h
|
||||
include/swipetype/Scorer.h
|
||||
include/swipetype/DictionaryLoader.h
|
||||
include/swipetype/GestureEngine.h
|
||||
)
|
||||
|
||||
add_library(swipetype-core STATIC ${SWIPETYPE_CORE_SOURCES} ${SWIPETYPE_CORE_HEADERS})
|
||||
|
||||
target_include_directories(swipetype-core
|
||||
PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:include>
|
||||
)
|
||||
|
||||
# Link Android log library when building for Android
|
||||
if(ANDROID)
|
||||
find_library(log-lib log)
|
||||
target_link_libraries(swipetype-core PUBLIC ${log-lib})
|
||||
endif()
|
||||
|
||||
# ============================================================================
|
||||
# Tests (Google Test)
|
||||
# ============================================================================
|
||||
|
||||
option(SWIPETYPE_BUILD_TESTS "Build unit tests" ON)
|
||||
|
||||
if(SWIPETYPE_BUILD_TESTS)
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
GIT_REPOSITORY https://github.com/google/googletest.git
|
||||
GIT_TAG v1.14.0
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
# ============================================================================
|
||||
# Install (for use as a CMake package)
|
||||
# ============================================================================
|
||||
|
||||
install(TARGETS swipetype-core
|
||||
ARCHIVE DESTINATION lib
|
||||
LIBRARY DESTINATION lib
|
||||
)
|
||||
|
||||
install(DIRECTORY include/swipetype/
|
||||
DESTINATION include/swipetype
|
||||
FILES_MATCHING PATTERN "*.h"
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include "SwipeTypeTypes.h"
|
||||
|
||||
/**
|
||||
* @file DictionaryLoader.h
|
||||
* @brief Loads binary .glide dictionary files.
|
||||
*
|
||||
* The dictionary loader reads the custom binary format produced by
|
||||
* scripts/gen_dict.py. It validates the file header, reads all entries,
|
||||
* and provides lookup by word and iteration over all entries.
|
||||
*
|
||||
* Thread safety: After loading, read-only operations (lookup, iteration)
|
||||
* are thread-safe. Loading/unloading are NOT thread-safe.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief A single dictionary entry.
|
||||
*/
|
||||
struct DictionaryEntry {
|
||||
std::string word; ///< UTF-8 encoded word string
|
||||
uint32_t frequency = 0; ///< Frequency (higher = more common)
|
||||
uint8_t flags = 0; ///< Bitmask: DICT_FLAG_PROPER_NOUN, DICT_FLAG_PROFANITY
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Parsed dictionary file header.
|
||||
*/
|
||||
struct DictionaryHeader {
|
||||
uint32_t magic = 0;
|
||||
uint16_t version = 0;
|
||||
uint16_t flags = 0;
|
||||
uint32_t entryCount = 0;
|
||||
std::string languageTag;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Loads and provides access to a binary .glide dictionary.
|
||||
*
|
||||
* Usage:
|
||||
* @code
|
||||
* DictionaryLoader loader;
|
||||
* if (loader.load("/path/to/en-us.glide")) {
|
||||
* auto entries = loader.getEntriesStartingWith('h');
|
||||
* // ... use entries ...
|
||||
* }
|
||||
* loader.unload();
|
||||
* @endcode
|
||||
*/
|
||||
class DictionaryLoader {
|
||||
public:
|
||||
DictionaryLoader();
|
||||
~DictionaryLoader();
|
||||
|
||||
// Non-copyable, movable
|
||||
DictionaryLoader(const DictionaryLoader&) = delete;
|
||||
DictionaryLoader& operator=(const DictionaryLoader&) = delete;
|
||||
DictionaryLoader(DictionaryLoader&&) noexcept;
|
||||
DictionaryLoader& operator=(DictionaryLoader&&) noexcept;
|
||||
|
||||
/**
|
||||
* @brief Load a dictionary from a binary .glide file.
|
||||
*
|
||||
* Validates the header (magic, version) and reads all entries into memory.
|
||||
* If a dictionary is already loaded, it is unloaded first.
|
||||
*
|
||||
* @param filePath Absolute path to the .glide dictionary file.
|
||||
* @return true on success; false on file not found, corrupt header,
|
||||
* or version mismatch. Check getLastError() for details.
|
||||
*/
|
||||
bool load(const std::string& filePath);
|
||||
|
||||
/**
|
||||
* @brief Load a dictionary from a memory buffer.
|
||||
*
|
||||
* @param data Pointer to the buffer containing the dictionary file contents.
|
||||
* @param size Size of the buffer in bytes.
|
||||
* @return true on success; false on invalid data.
|
||||
*/
|
||||
bool loadFromMemory(const uint8_t* data, size_t size);
|
||||
|
||||
/**
|
||||
* @brief Unload the current dictionary and free memory.
|
||||
*/
|
||||
void unload();
|
||||
|
||||
/**
|
||||
* @brief Check if a dictionary is currently loaded.
|
||||
* @return true if a dictionary is loaded and ready for queries.
|
||||
*/
|
||||
bool isLoaded() const;
|
||||
|
||||
/**
|
||||
* @brief Get the dictionary header information.
|
||||
* @return Header struct. Fields are zero/empty if no dictionary is loaded.
|
||||
*/
|
||||
DictionaryHeader getHeader() const;
|
||||
|
||||
/**
|
||||
* @brief Get the total number of entries in the loaded dictionary.
|
||||
* @return Entry count, or 0 if no dictionary is loaded.
|
||||
*/
|
||||
uint32_t getEntryCount() const;
|
||||
|
||||
/**
|
||||
* @brief Get the maximum frequency value in the dictionary.
|
||||
*
|
||||
* Used for frequency normalization in scoring.
|
||||
* @return Maximum frequency, or 0 if no dictionary loaded.
|
||||
*/
|
||||
uint32_t getMaxFrequency() const;
|
||||
|
||||
/**
|
||||
* @brief Get all dictionary entries.
|
||||
* @return Reference to the internal entry vector. Empty if not loaded.
|
||||
*/
|
||||
const std::vector<DictionaryEntry>& getAllEntries() const;
|
||||
|
||||
/**
|
||||
* @brief Get entries whose word starts with the given character.
|
||||
*
|
||||
* @param startChar Starting character (lowercase ASCII expected).
|
||||
* @return Vector of matching entries. Empty if none found or not loaded.
|
||||
*/
|
||||
std::vector<const DictionaryEntry*> getEntriesStartingWith(char startChar) const;
|
||||
|
||||
/**
|
||||
* @brief Get entries whose word starts with startChar and ends with endChar.
|
||||
*
|
||||
* This is the primary candidate filtering method used during recognition.
|
||||
*
|
||||
* @param startChar First character (lowercase ASCII)
|
||||
* @param endChar Last character (lowercase ASCII)
|
||||
* @return Vector of matching entries. Empty if none found.
|
||||
*/
|
||||
std::vector<const DictionaryEntry*> getEntriesWithStartEnd(char startChar,
|
||||
char endChar) const;
|
||||
|
||||
/**
|
||||
* @brief Look up a specific word.
|
||||
*
|
||||
* @param word Word to look up (case-insensitive).
|
||||
* @return Pointer to the entry, or nullptr if not found.
|
||||
*/
|
||||
const DictionaryEntry* lookup(const std::string& word) const;
|
||||
|
||||
/**
|
||||
* @brief Get the last error that occurred.
|
||||
* @return ErrorInfo with code and message. Code is NONE if no error.
|
||||
*/
|
||||
ErrorInfo getLastError() const;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* pImpl;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @file GestureCandidate.h
|
||||
* @brief Word candidate produced by the gesture recognition pipeline.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief A word candidate with confidence score and metadata.
|
||||
*
|
||||
* Candidates are returned sorted by confidence descending (best first).
|
||||
*/
|
||||
struct GestureCandidate {
|
||||
/** UTF-8 encoded word string. */
|
||||
std::string word;
|
||||
|
||||
/** Confidence score in [0.0, 1.0]. 1.0 = highest confidence.
|
||||
* Computed as: 1.0 - finalScore, where finalScore combines DTW and frequency. */
|
||||
float confidence = 0.0f;
|
||||
|
||||
/** Source flags bitmask:
|
||||
* - SOURCE_MAIN_DICT (0x01): word from main dictionary
|
||||
* - SOURCE_USER_DICT (0x02): word from user dictionary (future)
|
||||
* - SOURCE_COMPLETION (0x04): prefix completion (future) */
|
||||
uint32_t sourceFlags = 0;
|
||||
|
||||
/** Raw DTW distance (for debugging/tuning). Lower = better match.
|
||||
* Not normalized — depends on path length and scoring config. */
|
||||
float dtwScore = 0.0f;
|
||||
|
||||
/** Dictionary frequency contribution to final score. Higher = more common word.
|
||||
* Normalized to [0.0, 1.0] within the candidate set. */
|
||||
float frequencyScore = 0.0f;
|
||||
|
||||
GestureCandidate() = default;
|
||||
GestureCandidate(const std::string& word, float confidence, uint32_t sourceFlags)
|
||||
: word(word), confidence(confidence), sourceFlags(sourceFlags) {}
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,159 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "GesturePath.h"
|
||||
#include "GestureCandidate.h"
|
||||
#include "KeyboardLayout.h"
|
||||
#include "SwipeTypeTypes.h"
|
||||
|
||||
/**
|
||||
* @file GestureEngine.h
|
||||
* @brief Main entry point for gesture recognition.
|
||||
*
|
||||
* GestureEngine orchestrates the entire recognition pipeline:
|
||||
* 1. Path normalization (PathProcessor)
|
||||
* 2. Candidate generation (DictionaryLoader + start/end key filtering)
|
||||
* 3. Ideal path generation (IdealPathGenerator)
|
||||
* 4. DTW scoring (Scorer)
|
||||
* 5. Ranking and pruning
|
||||
*
|
||||
* Usage:
|
||||
* @code
|
||||
* GestureEngine engine;
|
||||
* engine.init(layout, "/path/to/dictionary.glide");
|
||||
*
|
||||
* RawGesturePath raw;
|
||||
* raw.points = { {100, 200, 0}, {110, 210, 10}, ... };
|
||||
* auto candidates = engine.recognize(raw, 5);
|
||||
* for (const auto& c : candidates) {
|
||||
* printf("%s (%.2f)\n", c.word.c_str(), c.confidence);
|
||||
* }
|
||||
*
|
||||
* engine.shutdown();
|
||||
* @endcode
|
||||
*
|
||||
* Thread safety: NOT thread-safe. External synchronization required.
|
||||
* Callers must not call recognize() concurrently on the same instance.
|
||||
*
|
||||
* Ownership: Caller retains ownership of all passed objects.
|
||||
* The engine copies layout and dictionary data internally.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
class GestureEngine {
|
||||
public:
|
||||
GestureEngine();
|
||||
~GestureEngine();
|
||||
|
||||
// Non-copyable, movable
|
||||
GestureEngine(const GestureEngine&) = delete;
|
||||
GestureEngine& operator=(const GestureEngine&) = delete;
|
||||
GestureEngine(GestureEngine&&) noexcept;
|
||||
GestureEngine& operator=(GestureEngine&&) noexcept;
|
||||
|
||||
/**
|
||||
* @brief Initialize the engine with a keyboard layout and dictionary.
|
||||
*
|
||||
* Must be called before any recognize() invocation. Can be called
|
||||
* again to change layout or dictionary (re-initialization).
|
||||
*
|
||||
* @param layout Keyboard layout descriptor. Must have at least one
|
||||
* character key. Coordinates in dp units.
|
||||
* @param dictPath Absolute path to the binary .glide dictionary file.
|
||||
* @return true on success; false if dictionary file not found, corrupt,
|
||||
* or layout is invalid.
|
||||
*
|
||||
* @post isInitialized() == true on success
|
||||
*/
|
||||
bool init(const KeyboardLayout& layout, const std::string& dictPath);
|
||||
|
||||
/**
|
||||
* @brief Initialize with a pre-loaded dictionary from memory.
|
||||
*
|
||||
* @param layout Keyboard layout descriptor.
|
||||
* @param dictData Pointer to dictionary file contents in memory.
|
||||
* @param dictSize Size of dictionary data in bytes.
|
||||
* @return true on success.
|
||||
*/
|
||||
bool initWithData(const KeyboardLayout& layout,
|
||||
const uint8_t* dictData, size_t dictSize);
|
||||
|
||||
/**
|
||||
* @brief Recognize a gesture path and return ranked word candidates.
|
||||
*
|
||||
* Pipeline: normalize → filter candidates → score → rank → return.
|
||||
*
|
||||
* @param rawPath Raw gesture path. Must contain >= 2 points.
|
||||
* @param maxCandidates Maximum results to return. Clamped to [1, 20].
|
||||
* Default: 8.
|
||||
* @return Ranked candidates, best first (highest confidence).
|
||||
* Empty vector if engine not initialized, path too short,
|
||||
* or no candidates found.
|
||||
*
|
||||
* @pre isInitialized() == true
|
||||
* @pre rawPath.points.size() >= MIN_GESTURE_POINTS
|
||||
*/
|
||||
std::vector<GestureCandidate> recognize(const RawGesturePath& rawPath,
|
||||
int maxCandidates = DEFAULT_MAX_CANDIDATES);
|
||||
|
||||
/**
|
||||
* @brief Shut down the engine and free all resources.
|
||||
*
|
||||
* Safe to call multiple times. After shutdown, isInitialized() returns false
|
||||
* and recognize() returns empty results.
|
||||
*
|
||||
* @post isInitialized() == false
|
||||
*/
|
||||
void shutdown();
|
||||
|
||||
/**
|
||||
* @brief Check whether the engine is initialized and ready.
|
||||
* @return true if init() succeeded and shutdown() has not been called.
|
||||
*/
|
||||
bool isInitialized() const;
|
||||
|
||||
/**
|
||||
* @brief Update the keyboard layout without reloading the dictionary.
|
||||
*
|
||||
* Clears cached ideal paths (since key positions changed).
|
||||
* The engine must already be initialized.
|
||||
*
|
||||
* @param layout New keyboard layout.
|
||||
* @return true on success; false if layout is invalid.
|
||||
*/
|
||||
bool updateLayout(const KeyboardLayout& layout);
|
||||
|
||||
/**
|
||||
* @brief Configure scoring parameters.
|
||||
*
|
||||
* Can be called before or after init(). Parameters take effect
|
||||
* on the next recognize() call.
|
||||
*
|
||||
* @param config Scoring configuration.
|
||||
*/
|
||||
void configure(const ScoringConfig& config);
|
||||
|
||||
/**
|
||||
* @brief Set an error callback for asynchronous error reporting.
|
||||
*
|
||||
* The callback is invoked synchronously from the thread that
|
||||
* encounters the error.
|
||||
*
|
||||
* @param callback Error callback function. Pass nullptr to clear.
|
||||
*/
|
||||
void setErrorCallback(ErrorCallback callback);
|
||||
|
||||
/**
|
||||
* @brief Get the last error that occurred.
|
||||
* @return ErrorInfo with code and message.
|
||||
*/
|
||||
ErrorInfo getLastError() const;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* pImpl;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include "GesturePoint.h"
|
||||
|
||||
/**
|
||||
* @file GesturePath.h
|
||||
* @brief Path data structures for raw and processed gesture input.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief Raw gesture path — unprocessed touch input from the keyboard.
|
||||
*
|
||||
* Contains the sequence of touch points as captured by the input system.
|
||||
* Points are ordered by timestamp. May contain duplicates, noise, and
|
||||
* varying density.
|
||||
*/
|
||||
struct RawGesturePath {
|
||||
std::vector<GesturePoint> points; ///< Ordered touch points, >= 0 elements
|
||||
|
||||
/** @return true if the path has fewer than MIN_GESTURE_POINTS points. */
|
||||
bool isEmpty() const { return points.size() < 2; }
|
||||
|
||||
/** @return Number of points in the raw path. */
|
||||
size_t size() const { return points.size(); }
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Normalized gesture path — the input to the scoring algorithm.
|
||||
*
|
||||
* After processing by PathProcessor::normalize(), this path has exactly
|
||||
* RESAMPLE_COUNT (64) points in a [0.0, 1.0] bounding box with
|
||||
* preserved aspect ratio.
|
||||
*/
|
||||
struct GesturePath {
|
||||
/** Exactly RESAMPLE_COUNT normalized points. */
|
||||
std::vector<NormalizedPoint> points;
|
||||
|
||||
/** Original aspect ratio (width/height) before normalization.
|
||||
* Used as a scoring heuristic. */
|
||||
float aspectRatio = 1.0f;
|
||||
|
||||
/** Total arc length of the original path in dp (before normalization).
|
||||
* Used for word length estimation. */
|
||||
float totalArcLength = 0.0f;
|
||||
|
||||
/** Index into KeyboardLayout::keys for the key nearest to the
|
||||
* first raw touch point. -1 if not determined. */
|
||||
int32_t startKeyIndex = -1;
|
||||
|
||||
/** Index into KeyboardLayout::keys for the key nearest to the
|
||||
* last raw touch point. -1 if not determined. */
|
||||
int32_t endKeyIndex = -1;
|
||||
|
||||
/** @return true if the path has the expected number of points. */
|
||||
bool isValid() const {
|
||||
return static_cast<int>(points.size()) == 64; // RESAMPLE_COUNT
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @file GesturePoint.h
|
||||
* @brief Point data structures for raw and normalized gesture paths.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief A single raw touch point from the keyboard input.
|
||||
*
|
||||
* Coordinates are in density-independent pixels (dp) relative to the
|
||||
* top-left corner of the keyboard view.
|
||||
*/
|
||||
struct GesturePoint {
|
||||
float x; ///< X coordinate in dp
|
||||
float y; ///< Y coordinate in dp
|
||||
int64_t timestamp; ///< Milliseconds since gesture start (monotonic, 0-based)
|
||||
|
||||
GesturePoint() : x(0.0f), y(0.0f), timestamp(0) {}
|
||||
GesturePoint(float x, float y, int64_t timestamp)
|
||||
: x(x), y(y), timestamp(timestamp) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A normalized point after path processing.
|
||||
*
|
||||
* Coordinates are in [0.0, 1.0] after bounding-box normalization.
|
||||
* Time is normalized to [0.0, 1.0] (0 = gesture start, 1 = gesture end).
|
||||
*/
|
||||
struct NormalizedPoint {
|
||||
float x; ///< Normalized X in [0.0, 1.0]
|
||||
float y; ///< Normalized Y in [0.0, 1.0]
|
||||
float t; ///< Normalized time in [0.0, 1.0]
|
||||
|
||||
NormalizedPoint() : x(0.0f), y(0.0f), t(0.0f) {}
|
||||
NormalizedPoint(float x, float y, float t)
|
||||
: x(x), y(y), t(t) {}
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,87 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include "GesturePath.h"
|
||||
#include "KeyboardLayout.h"
|
||||
#include "SwipeTypeTypes.h"
|
||||
|
||||
/**
|
||||
* @file IdealPathGenerator.h
|
||||
* @brief Generates ideal (reference) gesture paths for dictionary words.
|
||||
*
|
||||
* For each word, the ideal path connects the key centers of each character
|
||||
* in sequence, then resamples and normalizes the result to match the format
|
||||
* of a normalized gesture path.
|
||||
*
|
||||
* Ideal paths are cached after first generation for performance.
|
||||
*
|
||||
* Thread safety: NOT thread-safe.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief Generates and caches ideal swipe paths for words given a keyboard layout.
|
||||
*/
|
||||
class IdealPathGenerator {
|
||||
public:
|
||||
IdealPathGenerator();
|
||||
~IdealPathGenerator();
|
||||
|
||||
// Non-copyable, movable
|
||||
IdealPathGenerator(const IdealPathGenerator&) = delete;
|
||||
IdealPathGenerator& operator=(const IdealPathGenerator&) = delete;
|
||||
IdealPathGenerator(IdealPathGenerator&&) noexcept;
|
||||
IdealPathGenerator& operator=(IdealPathGenerator&&) noexcept;
|
||||
|
||||
/**
|
||||
* @brief Set the keyboard layout used for path generation.
|
||||
*
|
||||
* Clears the path cache (since key positions have changed).
|
||||
*
|
||||
* @param layout Keyboard layout with character key positions.
|
||||
*/
|
||||
void setLayout(const KeyboardLayout& layout);
|
||||
|
||||
/**
|
||||
* @brief Generate or retrieve the ideal path for a word.
|
||||
*
|
||||
* If the path has been generated before for the current layout,
|
||||
* returns the cached version. Otherwise generates, caches, and returns it.
|
||||
*
|
||||
* @param word UTF-8 encoded word string. Only ASCII lowercase letters
|
||||
* are used for path generation; other characters are skipped.
|
||||
* @return Normalized ideal path. Empty path if word has no mappable characters
|
||||
* or layout not set.
|
||||
*/
|
||||
GesturePath getIdealPath(const std::string& word);
|
||||
|
||||
/**
|
||||
* @brief Pre-generate ideal paths for a batch of words.
|
||||
*
|
||||
* Useful for warming up the cache during initialization.
|
||||
*
|
||||
* @param words List of words to pre-generate paths for.
|
||||
*/
|
||||
void pregenerate(const std::vector<std::string>& words);
|
||||
|
||||
/**
|
||||
* @brief Clear the path cache.
|
||||
*
|
||||
* Call when the keyboard layout changes or to free memory.
|
||||
*/
|
||||
void clearCache();
|
||||
|
||||
/**
|
||||
* @return Number of cached paths.
|
||||
*/
|
||||
size_t cacheSize() const;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* pImpl;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @file KeyboardLayout.h
|
||||
* @brief Keyboard layout descriptor — key positions and dimensions.
|
||||
*
|
||||
* This is the primary contract between the keyboard app and swipetype-core.
|
||||
* The keyboard app (via its adapter) populates this structure with key
|
||||
* positions in density-independent pixels (dp).
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief Describes a single key on the keyboard.
|
||||
*/
|
||||
struct KeyDescriptor {
|
||||
/** Display label (e.g., "a", "shift", "123"). Used for debugging only. */
|
||||
std::string label;
|
||||
|
||||
/** Unicode code point for this key's primary character.
|
||||
* E.g., 0x0061 for 'a', 0x0041 for 'A'.
|
||||
* Set to -1 for non-character keys (shift, backspace, space, etc.).
|
||||
* Only keys with codePoint >= 0 participate in gesture recognition. */
|
||||
int32_t codePoint = -1;
|
||||
|
||||
/** Key center X coordinate in dp, relative to keyboard top-left. */
|
||||
float centerX = 0.0f;
|
||||
|
||||
/** Key center Y coordinate in dp, relative to keyboard top-left. */
|
||||
float centerY = 0.0f;
|
||||
|
||||
/** Key width in dp. */
|
||||
float width = 0.0f;
|
||||
|
||||
/** Key height in dp. */
|
||||
float height = 0.0f;
|
||||
|
||||
KeyDescriptor() = default;
|
||||
KeyDescriptor(const std::string& label, int32_t codePoint,
|
||||
float centerX, float centerY, float width, float height)
|
||||
: label(label), codePoint(codePoint),
|
||||
centerX(centerX), centerY(centerY),
|
||||
width(width), height(height) {}
|
||||
|
||||
/** @return true if this key represents a character (participates in gestures). */
|
||||
bool isCharacterKey() const { return codePoint >= 0; }
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Complete keyboard layout descriptor.
|
||||
*
|
||||
* Populated by the adapter from the keyboard app's internal layout representation.
|
||||
* The adjacency map is computed internally by GestureEngine during init.
|
||||
*/
|
||||
struct KeyboardLayout {
|
||||
/** BCP 47 language tag (e.g., "en-US", "de-DE"). */
|
||||
std::string languageTag;
|
||||
|
||||
/** All keys on the keyboard, including non-character keys. */
|
||||
std::vector<KeyDescriptor> keys;
|
||||
|
||||
/** Total keyboard width in dp. */
|
||||
float layoutWidth = 0.0f;
|
||||
|
||||
/** Total keyboard height in dp. */
|
||||
float layoutHeight = 0.0f;
|
||||
|
||||
/**
|
||||
* @brief Find the index of the key nearest to the given point.
|
||||
*
|
||||
* Only considers character keys (codePoint >= 0).
|
||||
*
|
||||
* @param x X coordinate in dp
|
||||
* @param y Y coordinate in dp
|
||||
* @return Index into keys vector, or -1 if no character keys exist.
|
||||
*/
|
||||
int32_t findNearestKey(float x, float y) const;
|
||||
|
||||
/**
|
||||
* @brief Find the index of the key with the given code point.
|
||||
*
|
||||
* @param codePoint Unicode code point to search for (case-insensitive for ASCII).
|
||||
* @return Index into keys vector, or -1 if not found.
|
||||
*/
|
||||
int32_t findKeyByCodePoint(int32_t codePoint) const;
|
||||
|
||||
/** @return true if the layout has at least one character key. */
|
||||
bool isValid() const;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include "GesturePath.h"
|
||||
#include "KeyboardLayout.h"
|
||||
#include "SwipeTypeTypes.h"
|
||||
|
||||
/**
|
||||
* @file PathProcessor.h
|
||||
* @brief Path normalization — converts raw touch input to normalized gesture paths.
|
||||
*
|
||||
* The PathProcessor is responsible for:
|
||||
* 1. Removing duplicate/near-duplicate consecutive points
|
||||
* 2. Resampling to exactly RESAMPLE_COUNT equidistant points
|
||||
* 3. Normalizing coordinates to [0.0, 1.0] bounding box
|
||||
* 4. Determining start/end keys from the gesture endpoints
|
||||
*
|
||||
* Thread safety: NOT thread-safe. Use one instance per thread.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief Converts raw gesture input to a normalized, resampled path.
|
||||
*
|
||||
* Uses the pImpl pattern to hide implementation details.
|
||||
*/
|
||||
class PathProcessor {
|
||||
public:
|
||||
PathProcessor();
|
||||
~PathProcessor();
|
||||
|
||||
// Non-copyable, movable
|
||||
PathProcessor(const PathProcessor&) = delete;
|
||||
PathProcessor& operator=(const PathProcessor&) = delete;
|
||||
PathProcessor(PathProcessor&&) noexcept;
|
||||
PathProcessor& operator=(PathProcessor&&) noexcept;
|
||||
|
||||
/**
|
||||
* @brief Normalize a raw gesture path.
|
||||
*
|
||||
* Performs deduplication, resampling, bounding-box normalization,
|
||||
* and start/end key detection.
|
||||
*
|
||||
* @param raw Raw gesture path (must contain >= 2 points)
|
||||
* @param layout Keyboard layout for start/end key detection
|
||||
* @return Normalized path with exactly RESAMPLE_COUNT points.
|
||||
* Returns empty GesturePath if raw.isEmpty().
|
||||
*
|
||||
* @pre raw.points.size() >= MIN_GESTURE_POINTS
|
||||
* @post result.points.size() == RESAMPLE_COUNT || result.points.empty()
|
||||
*/
|
||||
GesturePath normalize(const RawGesturePath& raw,
|
||||
const KeyboardLayout& layout) const;
|
||||
|
||||
/**
|
||||
* @brief Configure the minimum point distance for deduplication.
|
||||
*
|
||||
* @param distanceDp Minimum distance in dp. Default: MIN_POINT_DISTANCE_DP.
|
||||
*/
|
||||
void setMinPointDistance(float distanceDp);
|
||||
|
||||
/**
|
||||
* @brief Configure the resample count.
|
||||
*
|
||||
* @param count Number of points in output. Default: RESAMPLE_COUNT.
|
||||
* Must be >= 2.
|
||||
*/
|
||||
void setResampleCount(int count);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* pImpl;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include "GesturePath.h"
|
||||
#include "GestureCandidate.h"
|
||||
#include "SwipeTypeTypes.h"
|
||||
|
||||
/**
|
||||
* @file Scorer.h
|
||||
* @brief DTW-based scoring for comparing gesture paths against ideal paths.
|
||||
*
|
||||
* Uses Dynamic Time Warping (DTW) with Sakoe-Chiba band constraint
|
||||
* (Sakoe & Chiba, 1978) to compute similarity between a user's gesture
|
||||
* and the ideal swipe path for each candidate word.
|
||||
*
|
||||
* Thread safety: NOT thread-safe. Use one instance per thread.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
/**
|
||||
* @brief Scores gesture paths against ideal reference paths using DTW.
|
||||
*/
|
||||
class Scorer {
|
||||
public:
|
||||
Scorer();
|
||||
~Scorer();
|
||||
|
||||
// Non-copyable, movable
|
||||
Scorer(const Scorer&) = delete;
|
||||
Scorer& operator=(const Scorer&) = delete;
|
||||
Scorer(Scorer&&) noexcept;
|
||||
Scorer& operator=(Scorer&&) noexcept;
|
||||
|
||||
/**
|
||||
* @brief Configure the scorer with custom parameters.
|
||||
*
|
||||
* @param config Scoring configuration. See ScoringConfig for defaults.
|
||||
*/
|
||||
void configure(const ScoringConfig& config);
|
||||
|
||||
/**
|
||||
* @brief Compute the DTW distance between two normalized paths.
|
||||
*
|
||||
* Uses Sakoe-Chiba band constraint with bandwidth = DTW_BANDWIDTH.
|
||||
* Both paths must have exactly RESAMPLE_COUNT points.
|
||||
*
|
||||
* @param gesture Normalized gesture path from user input
|
||||
* @param idealPath Normalized ideal path for a candidate word
|
||||
* @return DTW distance (>= 0.0). Lower = better match.
|
||||
* Returns FLT_MAX if either path is invalid.
|
||||
*
|
||||
* @pre gesture.points.size() == RESAMPLE_COUNT
|
||||
* @pre idealPath.points.size() == RESAMPLE_COUNT
|
||||
*/
|
||||
float computeDTWDistance(const GesturePath& gesture,
|
||||
const GesturePath& idealPath) const;
|
||||
|
||||
/**
|
||||
* @brief Score a candidate by combining DTW distance with frequency.
|
||||
*
|
||||
* finalScore = (1 - α) * normalizedDTW + α * (1 - normalizedFreq)
|
||||
* confidence = 1.0 - finalScore
|
||||
*
|
||||
* @param dtwDistance Raw DTW distance (from computeDTWDistance)
|
||||
* @param maxDTWDistance Maximum DTW distance in the candidate set (for normalization)
|
||||
* @param frequency Dictionary frequency of the word
|
||||
* @param maxFrequency Maximum frequency in the dictionary (for normalization)
|
||||
* @return Confidence score in [0.0, 1.0].
|
||||
*/
|
||||
float computeConfidence(float dtwDistance, float maxDTWDistance,
|
||||
uint32_t frequency, uint32_t maxFrequency) const;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* pImpl;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,146 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
||||
/**
|
||||
* @file SwipeTypeTypes.h
|
||||
* @brief Shared type definitions, constants, and enums for the swipetype-core library.
|
||||
*
|
||||
* This file contains all fundamental types used across the library.
|
||||
* It has no dependencies beyond the C++ standard library.
|
||||
*/
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
// ============================================================================
|
||||
// Path Processing Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Number of points after resampling. All normalized paths have exactly this many points. */
|
||||
static constexpr int RESAMPLE_COUNT = 64;
|
||||
|
||||
/** Minimum Euclidean distance (in dp) between consecutive points to keep. */
|
||||
static constexpr float MIN_POINT_DISTANCE_DP = 2.0f;
|
||||
|
||||
/** Minimum number of points for a valid gesture. */
|
||||
static constexpr int MIN_GESTURE_POINTS = 2;
|
||||
|
||||
/** Maximum number of raw input points accepted. */
|
||||
static constexpr int MAX_GESTURE_POINTS = 10000;
|
||||
|
||||
// ============================================================================
|
||||
// Scoring Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Sakoe-Chiba band width as a fraction of RESAMPLE_COUNT. */
|
||||
static constexpr float DTW_BANDWIDTH_RATIO = 0.10f;
|
||||
|
||||
/** Absolute Sakoe-Chiba band width: ceil(RESAMPLE_COUNT * DTW_BANDWIDTH_RATIO). */
|
||||
static constexpr int DTW_BANDWIDTH = 6;
|
||||
|
||||
/** Weight of dictionary frequency in final score (α). Range [0.0, 1.0].
|
||||
* finalScore = (1 - α) * dtwScore + α * freqScore */
|
||||
static constexpr float FREQUENCY_WEIGHT = 0.30f;
|
||||
|
||||
/** Default maximum candidates returned by recognize(). */
|
||||
static constexpr int DEFAULT_MAX_CANDIDATES = 8;
|
||||
|
||||
/** Hard upper limit for maxCandidates parameter. */
|
||||
static constexpr int MAX_MAX_CANDIDATES = 20;
|
||||
|
||||
/** Word length estimate tolerance (±). Used for candidate filtering.
|
||||
* With key-transition estimation this can be tighter than the old arc-length heuristic. */
|
||||
static constexpr float LENGTH_FILTER_TOLERANCE = 3.0f;
|
||||
|
||||
/** Floor for maxDTW normalization. Prevents single-candidate results from
|
||||
* always receiving normalizedDTW=1.0 and thus near-zero confidence.
|
||||
* A good gesture match typically yields DTW ~0.2–0.5; poor ~2–4. */
|
||||
static constexpr float MAX_DTW_FLOOR = 3.0f;
|
||||
|
||||
// ============================================================================
|
||||
// Dictionary Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Magic bytes for .glide dictionary files: ASCII "GLID". */
|
||||
static constexpr uint32_t DICT_MAGIC = 0x474C4944;
|
||||
|
||||
/** Current dictionary format version. */
|
||||
static constexpr uint16_t DICT_VERSION = 1;
|
||||
|
||||
/** Fixed size of the dictionary file header in bytes. */
|
||||
static constexpr uint32_t DICT_HEADER_SIZE = 32;
|
||||
|
||||
/** Maximum allowed word length in UTF-8 bytes. */
|
||||
static constexpr uint32_t MAX_WORD_LENGTH = 64;
|
||||
|
||||
// ============================================================================
|
||||
// Candidate Source Flags (bitmask)
|
||||
// ============================================================================
|
||||
|
||||
static constexpr uint32_t SOURCE_MAIN_DICT = 0x01;
|
||||
static constexpr uint32_t SOURCE_USER_DICT = 0x02;
|
||||
static constexpr uint32_t SOURCE_COMPLETION = 0x04;
|
||||
|
||||
// ============================================================================
|
||||
// Dictionary Entry Flags (bitmask)
|
||||
// ============================================================================
|
||||
|
||||
static constexpr uint8_t DICT_FLAG_PROPER_NOUN = 0x01;
|
||||
static constexpr uint8_t DICT_FLAG_PROFANITY = 0x02;
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Error codes used throughout the library.
|
||||
*/
|
||||
enum class ErrorCode : int {
|
||||
NONE = 0,
|
||||
DICT_NOT_FOUND = 1,
|
||||
DICT_CORRUPT = 2,
|
||||
DICT_VERSION_MISMATCH = 3,
|
||||
LAYOUT_INVALID = 4,
|
||||
PATH_TOO_SHORT = 5,
|
||||
ENGINE_NOT_INITIALIZED = 6,
|
||||
OUT_OF_MEMORY = 7
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Error information structure for callback-based error reporting.
|
||||
*/
|
||||
struct ErrorInfo {
|
||||
ErrorCode code = ErrorCode::NONE;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Error callback function type.
|
||||
*
|
||||
* Set via GestureEngine::setErrorCallback() to receive error notifications.
|
||||
* Called synchronously from the thread that encounters the error.
|
||||
*/
|
||||
using ErrorCallback = std::function<void(const ErrorInfo& error)>;
|
||||
|
||||
// ============================================================================
|
||||
// Scoring Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Tunable parameters for the scoring algorithm.
|
||||
*
|
||||
* All fields have sensible defaults. Override via GestureEngine::configure().
|
||||
*/
|
||||
struct ScoringConfig {
|
||||
int resampleCount = RESAMPLE_COUNT;
|
||||
float minPointDistance = MIN_POINT_DISTANCE_DP;
|
||||
float dtwBandwidthRatio = DTW_BANDWIDTH_RATIO;
|
||||
float frequencyWeight = FREQUENCY_WEIGHT;
|
||||
int maxCandidatesEvaluated = MAX_MAX_CANDIDATES;
|
||||
float lengthFilterTolerance = LENGTH_FILTER_TOLERANCE;
|
||||
float maxDTWFloor = MAX_DTW_FLOOR;
|
||||
};
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "swipetype/KeyboardLayout.h"
|
||||
#include <cmath>
|
||||
#include <cfloat>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
int32_t KeyboardLayout::findNearestKey(float x, float y) const {
|
||||
int32_t bestIndex = -1;
|
||||
float bestDist = FLT_MAX;
|
||||
|
||||
for (size_t i = 0; i < keys.size(); ++i) {
|
||||
const KeyDescriptor& key = keys[i];
|
||||
if (!key.isCharacterKey()) continue;
|
||||
|
||||
float dx = key.centerX - x;
|
||||
float dy = key.centerY - y;
|
||||
float dist = std::sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIndex = static_cast<int32_t>(i);
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
int32_t KeyboardLayout::findKeyByCodePoint(int32_t codePoint) const {
|
||||
// Lowercase the search code point if ASCII uppercase
|
||||
int32_t searchCp = codePoint;
|
||||
if (searchCp >= 'A' && searchCp <= 'Z') {
|
||||
searchCp = searchCp - 'A' + 'a';
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < keys.size(); ++i) {
|
||||
int32_t kcp = keys[i].codePoint;
|
||||
if (kcp >= 'A' && kcp <= 'Z') {
|
||||
kcp = kcp - 'A' + 'a';
|
||||
}
|
||||
if (kcp == searchCp) {
|
||||
return static_cast<int32_t>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool KeyboardLayout::isValid() const {
|
||||
if (keys.empty()) return false;
|
||||
if (layoutWidth <= 0.0f || layoutHeight <= 0.0f) return false;
|
||||
|
||||
for (const auto& key : keys) {
|
||||
if (key.isCharacterKey()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,251 @@
|
||||
#include "swipetype/DictionaryLoader.h"
|
||||
#include "swipetype/SwipeTypeTypes.h"
|
||||
#include <fstream>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
struct DictionaryLoader::Impl {
|
||||
std::vector<DictionaryEntry> entries;
|
||||
DictionaryHeader header;
|
||||
uint32_t maxFrequency = 0;
|
||||
ErrorInfo lastError;
|
||||
bool loaded = false;
|
||||
|
||||
void setError(ErrorCode code, const std::string& msg) {
|
||||
lastError.code = code;
|
||||
lastError.message = msg;
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
lastError.code = ErrorCode::NONE;
|
||||
lastError.message.clear();
|
||||
}
|
||||
|
||||
// Read uint16_t little-endian from buffer at offset
|
||||
static uint16_t readU16LE(const uint8_t* buf, size_t offset) {
|
||||
return static_cast<uint16_t>(buf[offset]) |
|
||||
(static_cast<uint16_t>(buf[offset + 1]) << 8);
|
||||
}
|
||||
|
||||
// Read uint32_t little-endian from buffer at offset
|
||||
static uint32_t readU32LE(const uint8_t* buf, size_t offset) {
|
||||
return static_cast<uint32_t>(buf[offset]) |
|
||||
(static_cast<uint32_t>(buf[offset + 1]) << 8) |
|
||||
(static_cast<uint32_t>(buf[offset + 2]) << 16) |
|
||||
(static_cast<uint32_t>(buf[offset + 3]) << 24);
|
||||
}
|
||||
|
||||
bool parseFromBuffer(const uint8_t* data, size_t size) {
|
||||
clearError();
|
||||
entries.clear();
|
||||
header = DictionaryHeader();
|
||||
maxFrequency = 0;
|
||||
|
||||
if (size < DICT_HEADER_SIZE) {
|
||||
setError(ErrorCode::DICT_CORRUPT, "File too small for header");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse header fields
|
||||
header.magic = readU32LE(data, 0);
|
||||
header.version = readU16LE(data, 4);
|
||||
header.flags = readU16LE(data, 6);
|
||||
header.entryCount = readU32LE(data, 8);
|
||||
|
||||
uint16_t langLen = readU16LE(data, 12);
|
||||
// languageTag fits within the 32-byte header (max 18 bytes after offset 14)
|
||||
if (langLen > 0 && static_cast<uint32_t>(14) + langLen <= DICT_HEADER_SIZE) {
|
||||
header.languageTag = std::string(
|
||||
reinterpret_cast<const char*>(data + 14), langLen);
|
||||
}
|
||||
|
||||
if (header.magic != DICT_MAGIC) {
|
||||
setError(ErrorCode::DICT_CORRUPT, "Invalid magic bytes");
|
||||
return false;
|
||||
}
|
||||
if (header.version != DICT_VERSION) {
|
||||
setError(ErrorCode::DICT_VERSION_MISMATCH,
|
||||
"Unsupported dictionary version: " + std::to_string(header.version));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse entries
|
||||
size_t pos = DICT_HEADER_SIZE;
|
||||
entries.reserve(header.entryCount);
|
||||
|
||||
for (uint32_t i = 0; i < header.entryCount; ++i) {
|
||||
if (pos + 1 > size) {
|
||||
setError(ErrorCode::DICT_CORRUPT, "Unexpected end of data at entry " + std::to_string(i));
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t wordLen = data[pos++];
|
||||
if (wordLen > MAX_WORD_LENGTH) {
|
||||
setError(ErrorCode::DICT_CORRUPT, "Word length exceeds maximum");
|
||||
return false;
|
||||
}
|
||||
if (pos + wordLen + 4 + 1 > size) {
|
||||
setError(ErrorCode::DICT_CORRUPT, "Truncated entry at index " + std::to_string(i));
|
||||
return false;
|
||||
}
|
||||
|
||||
DictionaryEntry entry;
|
||||
entry.word = std::string(reinterpret_cast<const char*>(data + pos), wordLen);
|
||||
pos += wordLen;
|
||||
entry.frequency = readU32LE(data, pos);
|
||||
pos += 4;
|
||||
entry.flags = data[pos++];
|
||||
|
||||
if (entry.frequency > maxFrequency) {
|
||||
maxFrequency = entry.frequency;
|
||||
}
|
||||
entries.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
DictionaryLoader::DictionaryLoader() : pImpl(new Impl()) {}
|
||||
DictionaryLoader::~DictionaryLoader() { delete pImpl; }
|
||||
|
||||
DictionaryLoader::DictionaryLoader(DictionaryLoader&& other) noexcept
|
||||
: pImpl(other.pImpl) { other.pImpl = nullptr; }
|
||||
|
||||
DictionaryLoader& DictionaryLoader::operator=(DictionaryLoader&& other) noexcept {
|
||||
if (this != &other) {
|
||||
delete pImpl;
|
||||
pImpl = other.pImpl;
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
bool DictionaryLoader::load(const std::string& filePath) {
|
||||
if (!pImpl) return false;
|
||||
unload();
|
||||
|
||||
std::ifstream file(filePath, std::ios::binary | std::ios::ate);
|
||||
if (!file.is_open()) {
|
||||
pImpl->setError(ErrorCode::DICT_NOT_FOUND,
|
||||
"Cannot open file: " + filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::streamsize fileSize = file.tellg();
|
||||
file.seekg(0, std::ios::beg);
|
||||
|
||||
std::vector<uint8_t> buffer(static_cast<size_t>(fileSize));
|
||||
if (!file.read(reinterpret_cast<char*>(buffer.data()), fileSize)) {
|
||||
pImpl->setError(ErrorCode::DICT_CORRUPT, "Failed to read file: " + filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return pImpl->parseFromBuffer(buffer.data(), buffer.size());
|
||||
}
|
||||
|
||||
bool DictionaryLoader::loadFromMemory(const uint8_t* data, size_t size) {
|
||||
if (!pImpl) return false;
|
||||
unload();
|
||||
return pImpl->parseFromBuffer(data, size);
|
||||
}
|
||||
|
||||
void DictionaryLoader::unload() {
|
||||
if (pImpl) {
|
||||
pImpl->entries.clear();
|
||||
pImpl->header = DictionaryHeader();
|
||||
pImpl->maxFrequency = 0;
|
||||
pImpl->loaded = false;
|
||||
pImpl->clearError();
|
||||
}
|
||||
}
|
||||
|
||||
bool DictionaryLoader::isLoaded() const {
|
||||
return pImpl && pImpl->loaded;
|
||||
}
|
||||
|
||||
DictionaryHeader DictionaryLoader::getHeader() const {
|
||||
return pImpl ? pImpl->header : DictionaryHeader();
|
||||
}
|
||||
|
||||
uint32_t DictionaryLoader::getEntryCount() const {
|
||||
return pImpl ? static_cast<uint32_t>(pImpl->entries.size()) : 0;
|
||||
}
|
||||
|
||||
uint32_t DictionaryLoader::getMaxFrequency() const {
|
||||
return pImpl ? pImpl->maxFrequency : 0;
|
||||
}
|
||||
|
||||
const std::vector<DictionaryEntry>& DictionaryLoader::getAllEntries() const {
|
||||
static const std::vector<DictionaryEntry> empty;
|
||||
return (pImpl && pImpl->loaded) ? pImpl->entries : empty;
|
||||
}
|
||||
|
||||
std::vector<const DictionaryEntry*> DictionaryLoader::getEntriesStartingWith(char startChar) const {
|
||||
std::vector<const DictionaryEntry*> result;
|
||||
if (!pImpl || !pImpl->loaded) return result;
|
||||
|
||||
char lc = static_cast<char>(std::tolower(static_cast<unsigned char>(startChar)));
|
||||
for (const auto& entry : pImpl->entries) {
|
||||
if (!entry.word.empty() &&
|
||||
std::tolower(static_cast<unsigned char>(entry.word[0])) ==
|
||||
static_cast<unsigned char>(lc)) {
|
||||
result.push_back(&entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<const DictionaryEntry*> DictionaryLoader::getEntriesWithStartEnd(
|
||||
char startChar, char endChar) const {
|
||||
std::vector<const DictionaryEntry*> result;
|
||||
if (!pImpl || !pImpl->loaded) return result;
|
||||
|
||||
char lcS = static_cast<char>(std::tolower(static_cast<unsigned char>(startChar)));
|
||||
char lcE = static_cast<char>(std::tolower(static_cast<unsigned char>(endChar)));
|
||||
|
||||
for (const auto& entry : pImpl->entries) {
|
||||
if (entry.word.empty()) continue;
|
||||
char first = static_cast<char>(std::tolower(
|
||||
static_cast<unsigned char>(entry.word.front())));
|
||||
char last = static_cast<char>(std::tolower(
|
||||
static_cast<unsigned char>(entry.word.back())));
|
||||
if (first == lcS && last == lcE) {
|
||||
result.push_back(&entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const DictionaryEntry* DictionaryLoader::lookup(const std::string& word) const {
|
||||
if (!pImpl || !pImpl->loaded || word.empty()) return nullptr;
|
||||
|
||||
// Create lowercase query
|
||||
std::string lcWord;
|
||||
lcWord.reserve(word.size());
|
||||
for (char ch : word) {
|
||||
lcWord.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(ch))));
|
||||
}
|
||||
|
||||
for (const auto& entry : pImpl->entries) {
|
||||
std::string lcEntry;
|
||||
lcEntry.reserve(entry.word.size());
|
||||
for (char ch : entry.word) {
|
||||
lcEntry.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(ch))));
|
||||
}
|
||||
if (lcEntry == lcWord) return &entry;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ErrorInfo DictionaryLoader::getLastError() const {
|
||||
return pImpl ? pImpl->lastError : ErrorInfo();
|
||||
}
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,341 @@
|
||||
#include "swipetype/GestureEngine.h"
|
||||
#include "swipetype/PathProcessor.h"
|
||||
#include "swipetype/IdealPathGenerator.h"
|
||||
#include "swipetype/Scorer.h"
|
||||
#include "swipetype/DictionaryLoader.h"
|
||||
#include "swipetype/SwipeTypeTypes.h"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cfloat>
|
||||
#include <cstdio>
|
||||
|
||||
#ifdef __ANDROID__
|
||||
#include <android/log.h>
|
||||
#define ST_LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "SwipeTypeCore", __VA_ARGS__)
|
||||
#else
|
||||
#define ST_LOGD(...) do { std::fprintf(stderr, __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
|
||||
#endif
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
struct GestureEngine::Impl {
|
||||
PathProcessor pathProcessor;
|
||||
IdealPathGenerator idealPathGen;
|
||||
Scorer scorer;
|
||||
DictionaryLoader dictLoader;
|
||||
KeyboardLayout layout;
|
||||
ScoringConfig config;
|
||||
ErrorCallback errorCallback;
|
||||
ErrorInfo lastError;
|
||||
bool initialized = false;
|
||||
|
||||
void reportError(ErrorCode code, const std::string& msg) {
|
||||
lastError = {code, msg};
|
||||
if (errorCallback) {
|
||||
errorCallback(lastError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate word length by counting distinct key transitions along the raw path.
|
||||
*
|
||||
* Walks the raw gesture points, snapping each to the nearest key center.
|
||||
* When the nearest key changes, that counts as one key transition.
|
||||
* The estimated word length is the number of distinct keys visited.
|
||||
*
|
||||
* This replaces the previous arc-length heuristic which overestimated
|
||||
* zigzag words (e.g. "hello" estimated as 17+ chars instead of 5).
|
||||
*/
|
||||
float estimateWordLengthByKeyTransitions(const RawGesturePath& rawPath) const {
|
||||
if (rawPath.points.size() < 2) return 1.0f;
|
||||
|
||||
int32_t prevKey = -1;
|
||||
int transitions = 0;
|
||||
for (const auto& pt : rawPath.points) {
|
||||
int32_t key = layout.findNearestKey(pt.x, pt.y);
|
||||
if (key >= 0 && key != prevKey) {
|
||||
transitions++;
|
||||
prevKey = key;
|
||||
}
|
||||
}
|
||||
return std::max(1.0f, static_cast<float>(transitions));
|
||||
}
|
||||
};
|
||||
|
||||
GestureEngine::GestureEngine() : pImpl(new Impl()) {}
|
||||
GestureEngine::~GestureEngine() { delete pImpl; }
|
||||
|
||||
GestureEngine::GestureEngine(GestureEngine&& other) noexcept
|
||||
: pImpl(other.pImpl) { other.pImpl = nullptr; }
|
||||
|
||||
GestureEngine& GestureEngine::operator=(GestureEngine&& other) noexcept {
|
||||
if (this != &other) {
|
||||
delete pImpl;
|
||||
pImpl = other.pImpl;
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
bool GestureEngine::init(const KeyboardLayout& layout, const std::string& dictPath) {
|
||||
if (!pImpl) return false;
|
||||
|
||||
if (!layout.isValid()) {
|
||||
pImpl->reportError(ErrorCode::LAYOUT_INVALID, "KeyboardLayout is invalid");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pImpl->dictLoader.load(dictPath)) {
|
||||
auto err = pImpl->dictLoader.getLastError();
|
||||
pImpl->reportError(err.code, err.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
pImpl->layout = layout;
|
||||
pImpl->idealPathGen.setLayout(layout);
|
||||
pImpl->scorer.configure(pImpl->config);
|
||||
pImpl->initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GestureEngine::initWithData(const KeyboardLayout& layout,
|
||||
const uint8_t* dictData, size_t dictSize) {
|
||||
if (!pImpl) return false;
|
||||
|
||||
if (!layout.isValid()) {
|
||||
pImpl->reportError(ErrorCode::LAYOUT_INVALID, "KeyboardLayout is invalid");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pImpl->dictLoader.loadFromMemory(dictData, dictSize)) {
|
||||
auto err = pImpl->dictLoader.getLastError();
|
||||
pImpl->reportError(err.code, err.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
pImpl->layout = layout;
|
||||
pImpl->idealPathGen.setLayout(layout);
|
||||
pImpl->scorer.configure(pImpl->config);
|
||||
pImpl->initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<GestureCandidate> GestureEngine::recognize(const RawGesturePath& rawPath,
|
||||
int maxCandidates) {
|
||||
std::vector<GestureCandidate> results;
|
||||
if (!pImpl) return results;
|
||||
|
||||
// Step 0: Validation
|
||||
if (!pImpl->initialized) {
|
||||
pImpl->reportError(ErrorCode::ENGINE_NOT_INITIALIZED, "Engine not initialized");
|
||||
return results;
|
||||
}
|
||||
maxCandidates = std::max(1, std::min(maxCandidates, MAX_MAX_CANDIDATES));
|
||||
if (rawPath.isEmpty()) {
|
||||
pImpl->reportError(ErrorCode::PATH_TOO_SHORT, "Gesture path too short");
|
||||
return results;
|
||||
}
|
||||
|
||||
// Step 1: Path Normalization
|
||||
GesturePath normalizedPath = pImpl->pathProcessor.normalize(rawPath, pImpl->layout);
|
||||
if (!normalizedPath.isValid()) return results;
|
||||
|
||||
// Step 2: Determine start/end key characters
|
||||
char startChar = 0, endChar = 0;
|
||||
bool hasStartEnd = false;
|
||||
|
||||
if (normalizedPath.startKeyIndex >= 0 &&
|
||||
normalizedPath.startKeyIndex < static_cast<int>(pImpl->layout.keys.size()) &&
|
||||
normalizedPath.endKeyIndex >= 0 &&
|
||||
normalizedPath.endKeyIndex < static_cast<int>(pImpl->layout.keys.size())) {
|
||||
|
||||
int32_t cpS = pImpl->layout.keys[static_cast<size_t>(normalizedPath.startKeyIndex)].codePoint;
|
||||
int32_t cpE = pImpl->layout.keys[static_cast<size_t>(normalizedPath.endKeyIndex)].codePoint;
|
||||
|
||||
if (cpS >= 'a' && cpS <= 'z') startChar = static_cast<char>(cpS);
|
||||
else if (cpS >= 'A' && cpS <= 'Z') startChar = static_cast<char>(std::tolower(cpS));
|
||||
|
||||
if (cpE >= 'a' && cpE <= 'z') endChar = static_cast<char>(cpE);
|
||||
else if (cpE >= 'A' && cpE <= 'Z') endChar = static_cast<char>(std::tolower(cpE));
|
||||
|
||||
hasStartEnd = (startChar != 0 && endChar != 0);
|
||||
}
|
||||
|
||||
ST_LOGD("PIPELINE: startKey='%c' endKey='%c' hasStartEnd=%d rawPts=%zu",
|
||||
startChar ? startChar : '?', endChar ? endChar : '?',
|
||||
(int)hasStartEnd, rawPath.points.size());
|
||||
|
||||
// Step 3: Candidate Filtering
|
||||
std::vector<const DictionaryEntry*> dictEntries;
|
||||
if (hasStartEnd) {
|
||||
dictEntries = pImpl->dictLoader.getEntriesWithStartEnd(startChar, endChar);
|
||||
}
|
||||
if (dictEntries.empty() && startChar != 0) {
|
||||
dictEntries = pImpl->dictLoader.getEntriesStartingWith(startChar);
|
||||
}
|
||||
if (dictEntries.empty()) {
|
||||
// Last resort: score a sample of all entries
|
||||
const auto& all = pImpl->dictLoader.getAllEntries();
|
||||
for (const auto& e : all) {
|
||||
dictEntries.push_back(&e);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply word-length filter (key-transition count, not arc length)
|
||||
float estimatedLen = pImpl->estimateWordLengthByKeyTransitions(rawPath);
|
||||
float tol = pImpl->config.lengthFilterTolerance;
|
||||
|
||||
std::vector<const DictionaryEntry*> filtered;
|
||||
filtered.reserve(dictEntries.size());
|
||||
for (const auto* entry : dictEntries) {
|
||||
float wordLen = static_cast<float>(entry->word.size());
|
||||
if (std::abs(wordLen - estimatedLen) <= tol) {
|
||||
filtered.push_back(entry);
|
||||
}
|
||||
}
|
||||
ST_LOGD("PIPELINE: estWordLen=%.1f dictEntries=%zu afterLenFilter=%zu tol=%.1f",
|
||||
estimatedLen, dictEntries.size(), filtered.size(), tol);
|
||||
|
||||
// If filter removed everything, fall back to unfiltered
|
||||
if (filtered.empty()) {
|
||||
ST_LOGD("PIPELINE: length filter removed ALL — falling back to unfiltered (%zu)",
|
||||
dictEntries.size());
|
||||
filtered = dictEntries;
|
||||
}
|
||||
|
||||
// Step 4: Scoring
|
||||
struct ScoredEntry {
|
||||
const DictionaryEntry* entry;
|
||||
float dtwDistance;
|
||||
};
|
||||
std::vector<ScoredEntry> scored;
|
||||
scored.reserve(filtered.size());
|
||||
|
||||
for (const auto* entry : filtered) {
|
||||
GesturePath ideal = pImpl->idealPathGen.getIdealPath(entry->word);
|
||||
if (!ideal.isValid()) continue;
|
||||
|
||||
float dtw = pImpl->scorer.computeDTWDistance(normalizedPath, ideal);
|
||||
scored.push_back({entry, dtw});
|
||||
}
|
||||
|
||||
if (scored.empty()) return results;
|
||||
|
||||
// Step 5: Max DTW normalization.
|
||||
// For RANKING multiple candidates: use the actual max candidate DTW so
|
||||
// shape differences are properly reflected. A small safety floor prevents
|
||||
// division by zero but never compresses real differences.
|
||||
// For SINGLE candidate confidence: use the larger maxDTWFloor so the
|
||||
// candidate gets a meaningful absolute confidence value.
|
||||
float rawMaxDTW = 0.0f;
|
||||
float minCandDTW = FLT_MAX;
|
||||
for (const auto& s : scored) {
|
||||
if (s.dtwDistance < FLT_MAX) {
|
||||
if (s.dtwDistance > rawMaxDTW) rawMaxDTW = s.dtwDistance;
|
||||
if (s.dtwDistance < minCandDTW) minCandDTW = s.dtwDistance;
|
||||
}
|
||||
}
|
||||
float maxDTW;
|
||||
if (scored.size() <= 1) {
|
||||
maxDTW = std::max(rawMaxDTW, pImpl->config.maxDTWFloor);
|
||||
} else {
|
||||
maxDTW = std::max(rawMaxDTW, 0.01f);
|
||||
}
|
||||
|
||||
// Step 5b: Adaptive frequency weight.
|
||||
// Uses the RAW DTW range (before any floor) to detect when candidates
|
||||
// have similar shape scores. When the spread is small, frequency weight
|
||||
// is scaled down proportionally so shape dominates the ranking.
|
||||
float rawRange = (minCandDTW < FLT_MAX) ? (rawMaxDTW - minCandDTW) : 0.0f;
|
||||
float effectiveAlpha = pImpl->config.frequencyWeight;
|
||||
if (scored.size() > 1 && rawRange < 0.5f) {
|
||||
effectiveAlpha *= std::max(0.1f, rawRange / 0.5f);
|
||||
}
|
||||
|
||||
ST_LOGD("PIPELINE: scored=%zu minDTW=%.4f rawMaxDTW=%.4f maxDTW=%.4f rawRange=%.4f alpha=%.3f(eff=%.3f)",
|
||||
scored.size(), minCandDTW, rawMaxDTW, maxDTW, rawRange,
|
||||
pImpl->config.frequencyWeight, effectiveAlpha);
|
||||
|
||||
// Step 6: Compute confidence scores (inlined with adaptive alpha)
|
||||
uint32_t maxFreq = pImpl->dictLoader.getMaxFrequency();
|
||||
results.reserve(scored.size());
|
||||
|
||||
for (const auto& s : scored) {
|
||||
float normalizedDTW = 1.0f;
|
||||
if (maxDTW > 0.0f && s.dtwDistance < FLT_MAX) {
|
||||
normalizedDTW = std::min(1.0f, s.dtwDistance / maxDTW);
|
||||
}
|
||||
|
||||
float normalizedFreq = 0.0f;
|
||||
if (maxFreq > 0) {
|
||||
normalizedFreq = std::min(1.0f,
|
||||
static_cast<float>(s.entry->frequency) / static_cast<float>(maxFreq));
|
||||
}
|
||||
|
||||
float finalScore = (1.0f - effectiveAlpha) * normalizedDTW
|
||||
+ effectiveAlpha * (1.0f - normalizedFreq);
|
||||
float confidence = 1.0f - std::max(0.0f, std::min(1.0f, finalScore));
|
||||
|
||||
GestureCandidate candidate;
|
||||
candidate.word = s.entry->word;
|
||||
candidate.confidence = confidence;
|
||||
candidate.sourceFlags = SOURCE_MAIN_DICT;
|
||||
candidate.dtwScore = s.dtwDistance;
|
||||
candidate.frequencyScore = (maxFreq > 0)
|
||||
? static_cast<float>(s.entry->frequency) / static_cast<float>(maxFreq)
|
||||
: 0.0f;
|
||||
results.push_back(std::move(candidate));
|
||||
}
|
||||
|
||||
// Step 7: Sort and prune
|
||||
std::sort(results.begin(), results.end(),
|
||||
[](const GestureCandidate& a, const GestureCandidate& b) {
|
||||
return a.confidence > b.confidence;
|
||||
});
|
||||
|
||||
if (static_cast<int>(results.size()) > maxCandidates) {
|
||||
results.resize(static_cast<size_t>(maxCandidates));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
void GestureEngine::shutdown() {
|
||||
if (pImpl) {
|
||||
pImpl->dictLoader.unload();
|
||||
pImpl->idealPathGen.clearCache();
|
||||
pImpl->initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool GestureEngine::isInitialized() const {
|
||||
return pImpl && pImpl->initialized;
|
||||
}
|
||||
|
||||
bool GestureEngine::updateLayout(const KeyboardLayout& layout) {
|
||||
if (!pImpl || !pImpl->initialized) return false;
|
||||
if (!layout.isValid()) {
|
||||
pImpl->reportError(ErrorCode::LAYOUT_INVALID, "KeyboardLayout is invalid");
|
||||
return false;
|
||||
}
|
||||
pImpl->layout = layout;
|
||||
pImpl->idealPathGen.setLayout(layout); // clears cache
|
||||
return true;
|
||||
}
|
||||
|
||||
void GestureEngine::configure(const ScoringConfig& config) {
|
||||
if (pImpl) {
|
||||
pImpl->config = config;
|
||||
pImpl->scorer.configure(config);
|
||||
}
|
||||
}
|
||||
|
||||
void GestureEngine::setErrorCallback(ErrorCallback callback) {
|
||||
if (pImpl) pImpl->errorCallback = std::move(callback);
|
||||
}
|
||||
|
||||
ErrorInfo GestureEngine::getLastError() const {
|
||||
return pImpl ? pImpl->lastError : ErrorInfo();
|
||||
}
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,235 @@
|
||||
#include "swipetype/IdealPathGenerator.h"
|
||||
#include "swipetype/SwipeTypeTypes.h"
|
||||
#include <unordered_map>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cctype>
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
struct IdealPathGenerator::Impl {
|
||||
KeyboardLayout layout;
|
||||
bool layoutSet = false;
|
||||
std::unordered_map<std::string, GesturePath> cache;
|
||||
|
||||
static float euclidean(float x1, float y1, float x2, float y2) {
|
||||
float dx = x2 - x1;
|
||||
float dy = y2 - y1;
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
// Resample helper: same algorithm as PathProcessor
|
||||
static std::vector<GesturePoint> resamplePoints(
|
||||
const std::vector<GesturePoint>& points, int count) {
|
||||
if (points.size() < 2 || count < 2) return points;
|
||||
|
||||
float totalLen = 0.0f;
|
||||
for (size_t i = 1; i < points.size(); ++i) {
|
||||
totalLen += euclidean(points[i-1].x, points[i-1].y,
|
||||
points[i].x, points[i].y);
|
||||
}
|
||||
if (totalLen < 1e-6f) {
|
||||
return std::vector<GesturePoint>(count, points[0]);
|
||||
}
|
||||
|
||||
float interval = totalLen / static_cast<float>(count - 1);
|
||||
std::vector<GesturePoint> result;
|
||||
result.reserve(count);
|
||||
result.push_back(points[0]);
|
||||
|
||||
float D = 0.0f;
|
||||
std::vector<GesturePoint> pts = points;
|
||||
size_t i = 1;
|
||||
|
||||
while (i < pts.size() && static_cast<int>(result.size()) < count - 1) {
|
||||
float dx = pts[i].x - pts[i-1].x;
|
||||
float dy = pts[i].y - pts[i-1].y;
|
||||
float d = std::sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (D + d >= interval) {
|
||||
float t = (interval - D) / d;
|
||||
GesturePoint np;
|
||||
np.x = pts[i-1].x + t * dx;
|
||||
np.y = pts[i-1].y + t * dy;
|
||||
np.timestamp = pts[i-1].timestamp +
|
||||
static_cast<int64_t>(t * static_cast<float>(pts[i].timestamp - pts[i-1].timestamp));
|
||||
result.push_back(np);
|
||||
pts.insert(pts.begin() + static_cast<int>(i), np);
|
||||
D = 0.0f;
|
||||
++i;
|
||||
} else {
|
||||
D += d;
|
||||
++i;
|
||||
}
|
||||
}
|
||||
while (static_cast<int>(result.size()) < count) {
|
||||
result.push_back(pts.back());
|
||||
}
|
||||
result.resize(count);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Normalize to [0,1] bounding box
|
||||
static GesturePath normalizeBB(const std::vector<GesturePoint>& points, float arcLen) {
|
||||
GesturePath result;
|
||||
if (points.empty()) return result;
|
||||
|
||||
float minX = points[0].x, maxX = points[0].x;
|
||||
float minY = points[0].y, maxY = points[0].y;
|
||||
for (const auto& p : points) {
|
||||
minX = std::min(minX, p.x);
|
||||
maxX = std::max(maxX, p.x);
|
||||
minY = std::min(minY, p.y);
|
||||
maxY = std::max(maxY, p.y);
|
||||
}
|
||||
float width = maxX - minX;
|
||||
float height = maxY - minY;
|
||||
|
||||
if (width < 0.001f && height < 0.001f) {
|
||||
result.points.assign(points.size(), NormalizedPoint(0.5f, 0.5f, 0.5f));
|
||||
result.aspectRatio = 1.0f;
|
||||
result.totalArcLength = arcLen;
|
||||
return result;
|
||||
}
|
||||
|
||||
float scale = std::max(width, height);
|
||||
result.aspectRatio = (height > 0.001f) ? (width / height) : 1.0f;
|
||||
result.totalArcLength = arcLen;
|
||||
|
||||
int64_t firstTs = points.front().timestamp;
|
||||
int64_t lastTs = points.back().timestamp;
|
||||
float tsRange = static_cast<float>(lastTs - firstTs);
|
||||
|
||||
result.points.reserve(points.size());
|
||||
for (const auto& p : points) {
|
||||
float nx = (p.x - minX) / scale;
|
||||
float ny = (p.y - minY) / scale;
|
||||
float nt = (tsRange > 0.0f)
|
||||
? static_cast<float>(p.timestamp - firstTs) / tsRange
|
||||
: 0.5f;
|
||||
result.points.emplace_back(nx, ny, nt);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the ideal path for a word by connecting key centers.
|
||||
*/
|
||||
GesturePath generate(const std::string& word) const {
|
||||
if (!layoutSet) return GesturePath();
|
||||
|
||||
std::vector<GesturePoint> keyPoints;
|
||||
int32_t prevKeyIdx = -1;
|
||||
int charIdx = 0;
|
||||
|
||||
for (char ch : word) {
|
||||
int cp = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
int32_t keyIdx = layout.findKeyByCodePoint(cp);
|
||||
if (keyIdx < 0) continue;
|
||||
|
||||
// Skip duplicate consecutive key (repeated letters in swipe typing)
|
||||
if (keyIdx == prevKeyIdx) continue;
|
||||
|
||||
const KeyDescriptor& key = layout.keys[static_cast<size_t>(keyIdx)];
|
||||
GesturePoint pt;
|
||||
pt.x = key.centerX;
|
||||
pt.y = key.centerY;
|
||||
// Synthetic timestamp: 100ms per character
|
||||
pt.timestamp = static_cast<int64_t>(charIdx) * 100LL;
|
||||
keyPoints.push_back(pt);
|
||||
prevKeyIdx = keyIdx;
|
||||
++charIdx;
|
||||
}
|
||||
|
||||
if (keyPoints.size() < 2) return GesturePath();
|
||||
|
||||
// Compute arc length through key centers
|
||||
float arcLen = 0.0f;
|
||||
for (size_t i = 1; i < keyPoints.size(); ++i) {
|
||||
arcLen += euclidean(keyPoints[i-1].x, keyPoints[i-1].y,
|
||||
keyPoints[i].x, keyPoints[i].y);
|
||||
}
|
||||
|
||||
// Resample and normalize
|
||||
auto resampled = resamplePoints(keyPoints, RESAMPLE_COUNT);
|
||||
auto path = normalizeBB(resampled, arcLen);
|
||||
|
||||
// Set start/end key indices
|
||||
if (!keyPoints.empty()) {
|
||||
// Find the first/last key indices again
|
||||
int cp0 = -1, cpN = -1;
|
||||
for (char ch : word) {
|
||||
int cp = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
if (layout.findKeyByCodePoint(cp) >= 0) { cp0 = cp; break; }
|
||||
}
|
||||
for (int wi = static_cast<int>(word.size()) - 1; wi >= 0; --wi) {
|
||||
int cp = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(word[static_cast<size_t>(wi)])));
|
||||
if (layout.findKeyByCodePoint(cp) >= 0) { cpN = cp; break; }
|
||||
}
|
||||
path.startKeyIndex = (cp0 >= 0) ? layout.findKeyByCodePoint(cp0) : -1;
|
||||
path.endKeyIndex = (cpN >= 0) ? layout.findKeyByCodePoint(cpN) : -1;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
IdealPathGenerator::IdealPathGenerator() : pImpl(new Impl()) {}
|
||||
IdealPathGenerator::~IdealPathGenerator() { delete pImpl; }
|
||||
|
||||
IdealPathGenerator::IdealPathGenerator(IdealPathGenerator&& other) noexcept
|
||||
: pImpl(other.pImpl) { other.pImpl = nullptr; }
|
||||
|
||||
IdealPathGenerator& IdealPathGenerator::operator=(IdealPathGenerator&& other) noexcept {
|
||||
if (this != &other) {
|
||||
delete pImpl;
|
||||
pImpl = other.pImpl;
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
void IdealPathGenerator::setLayout(const KeyboardLayout& layout) {
|
||||
if (pImpl) {
|
||||
pImpl->layout = layout;
|
||||
pImpl->layoutSet = true;
|
||||
pImpl->cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
GesturePath IdealPathGenerator::getIdealPath(const std::string& word) {
|
||||
if (!pImpl || !pImpl->layoutSet) return GesturePath();
|
||||
|
||||
// Lowercase the word for cache key
|
||||
std::string key;
|
||||
key.reserve(word.size());
|
||||
for (char ch : word) {
|
||||
key.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(ch))));
|
||||
}
|
||||
|
||||
auto it = pImpl->cache.find(key);
|
||||
if (it != pImpl->cache.end()) {
|
||||
return it->second;
|
||||
}
|
||||
|
||||
GesturePath path = pImpl->generate(key);
|
||||
pImpl->cache[key] = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
void IdealPathGenerator::pregenerate(const std::vector<std::string>& words) {
|
||||
for (const auto& word : words) {
|
||||
getIdealPath(word);
|
||||
}
|
||||
}
|
||||
|
||||
void IdealPathGenerator::clearCache() {
|
||||
if (pImpl) pImpl->cache.clear();
|
||||
}
|
||||
|
||||
size_t IdealPathGenerator::cacheSize() const {
|
||||
return pImpl ? pImpl->cache.size() : 0;
|
||||
}
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,214 @@
|
||||
#include "swipetype/PathProcessor.h"
|
||||
#include "swipetype/SwipeTypeTypes.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <numeric>
|
||||
#include <vector>
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
// ============================================================================
|
||||
// Impl
|
||||
// ============================================================================
|
||||
|
||||
struct PathProcessor::Impl {
|
||||
float minPointDistance = MIN_POINT_DISTANCE_DP;
|
||||
int resampleCount = RESAMPLE_COUNT;
|
||||
|
||||
/**
|
||||
* Remove consecutive points that are closer than minPointDistance.
|
||||
* Always keeps the first and last points.
|
||||
*/
|
||||
std::vector<GesturePoint> deduplicate(const std::vector<GesturePoint>& points) const {
|
||||
if (points.size() <= 2) return points;
|
||||
|
||||
std::vector<GesturePoint> result;
|
||||
result.reserve(points.size());
|
||||
result.push_back(points[0]);
|
||||
|
||||
for (size_t i = 1; i < points.size() - 1; ++i) {
|
||||
const GesturePoint& last = result.back();
|
||||
const GesturePoint& cur = points[i];
|
||||
float dx = cur.x - last.x;
|
||||
float dy = cur.y - last.y;
|
||||
float dist = std::sqrt(dx * dx + dy * dy);
|
||||
if (dist >= minPointDistance) {
|
||||
result.push_back(cur);
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the last point
|
||||
result.push_back(points.back());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute total arc length of a sequence of points.
|
||||
*/
|
||||
float computeArcLength(const std::vector<GesturePoint>& points) const {
|
||||
float totalLength = 0.0f;
|
||||
for (size_t i = 1; i < points.size(); ++i) {
|
||||
float dx = points[i].x - points[i - 1].x;
|
||||
float dy = points[i].y - points[i - 1].y;
|
||||
totalLength += std::sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
return totalLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resample to exactly resampleCount equidistant points along the path.
|
||||
* Based on $1 Unistroke Recognizer algorithm (Wobbrock et al., 2007).
|
||||
*/
|
||||
std::vector<GesturePoint> resample(const std::vector<GesturePoint>& points) const {
|
||||
if (points.size() < 2) return points;
|
||||
|
||||
float totalLen = computeArcLength(points);
|
||||
if (totalLen < 1e-6f) {
|
||||
// Degenerate path: return duplicated first point
|
||||
std::vector<GesturePoint> filled(resampleCount, points[0]);
|
||||
return filled;
|
||||
}
|
||||
|
||||
float interval = totalLen / static_cast<float>(resampleCount - 1);
|
||||
std::vector<GesturePoint> result;
|
||||
result.reserve(resampleCount);
|
||||
result.push_back(points[0]);
|
||||
|
||||
float D = 0.0f;
|
||||
size_t i = 1;
|
||||
// Copy to allow modification during traversal
|
||||
std::vector<GesturePoint> pts = points;
|
||||
|
||||
while (i < pts.size() && static_cast<int>(result.size()) < resampleCount - 1) {
|
||||
float dx = pts[i].x - pts[i - 1].x;
|
||||
float dy = pts[i].y - pts[i - 1].y;
|
||||
float d = std::sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (D + d >= interval) {
|
||||
float t = (interval - D) / d;
|
||||
GesturePoint newPt;
|
||||
newPt.x = pts[i - 1].x + t * dx;
|
||||
newPt.y = pts[i - 1].y + t * dy;
|
||||
// Linear interpolation of timestamp
|
||||
newPt.timestamp = pts[i - 1].timestamp +
|
||||
static_cast<int64_t>(t * static_cast<float>(pts[i].timestamp - pts[i - 1].timestamp));
|
||||
result.push_back(newPt);
|
||||
|
||||
// Insert new point back and re-process current segment
|
||||
pts.insert(pts.begin() + static_cast<int>(i), newPt);
|
||||
D = 0.0f;
|
||||
++i; // skip to segment starting at newPt
|
||||
} else {
|
||||
D += d;
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining (floating-point drift)
|
||||
while (static_cast<int>(result.size()) < resampleCount) {
|
||||
result.push_back(pts.back());
|
||||
}
|
||||
// Truncate if over
|
||||
result.resize(resampleCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize coordinates to [0,1] bounding box preserving aspect ratio.
|
||||
*/
|
||||
GesturePath normalizeBoundingBox(const std::vector<GesturePoint>& points,
|
||||
float totalArcLength) const {
|
||||
GesturePath result;
|
||||
|
||||
if (points.empty()) return result;
|
||||
|
||||
float minX = points[0].x, maxX = points[0].x;
|
||||
float minY = points[0].y, maxY = points[0].y;
|
||||
for (const auto& p : points) {
|
||||
minX = std::min(minX, p.x);
|
||||
maxX = std::max(maxX, p.x);
|
||||
minY = std::min(minY, p.y);
|
||||
maxY = std::max(maxY, p.y);
|
||||
}
|
||||
|
||||
float width = maxX - minX;
|
||||
float height = maxY - minY;
|
||||
|
||||
// Degenerate: near-point path
|
||||
if (width < 0.001f && height < 0.001f) {
|
||||
result.points.resize(points.size(), NormalizedPoint(0.5f, 0.5f, 0.5f));
|
||||
result.aspectRatio = 1.0f;
|
||||
result.totalArcLength = totalArcLength;
|
||||
return result;
|
||||
}
|
||||
|
||||
float scale = std::max(width, height);
|
||||
result.aspectRatio = (height > 0.001f) ? (width / height) : 1.0f;
|
||||
result.totalArcLength = totalArcLength;
|
||||
|
||||
int64_t firstTs = points.front().timestamp;
|
||||
int64_t lastTs = points.back().timestamp;
|
||||
float tsRange = static_cast<float>(lastTs - firstTs);
|
||||
|
||||
result.points.reserve(points.size());
|
||||
for (const auto& p : points) {
|
||||
float nx = (p.x - minX) / scale;
|
||||
float ny = (p.y - minY) / scale;
|
||||
float nt = (tsRange > 0.0f)
|
||||
? static_cast<float>(p.timestamp - firstTs) / tsRange
|
||||
: 0.5f;
|
||||
result.points.emplace_back(nx, ny, nt);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
PathProcessor::PathProcessor() : pImpl(new Impl()) {}
|
||||
|
||||
PathProcessor::~PathProcessor() { delete pImpl; }
|
||||
|
||||
PathProcessor::PathProcessor(PathProcessor&& other) noexcept : pImpl(other.pImpl) {
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
|
||||
PathProcessor& PathProcessor::operator=(PathProcessor&& other) noexcept {
|
||||
if (this != &other) {
|
||||
delete pImpl;
|
||||
pImpl = other.pImpl;
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
GesturePath PathProcessor::normalize(const RawGesturePath& raw,
|
||||
const KeyboardLayout& layout) const {
|
||||
if (raw.isEmpty()) return GesturePath();
|
||||
|
||||
auto deduped = pImpl->deduplicate(raw.points);
|
||||
if (deduped.size() < 2) return GesturePath();
|
||||
|
||||
float arcLen = pImpl->computeArcLength(deduped);
|
||||
auto resampled = pImpl->resample(deduped);
|
||||
auto path = pImpl->normalizeBoundingBox(resampled, arcLen);
|
||||
|
||||
// Determine start/end keys from original (not resampled) endpoints
|
||||
path.startKeyIndex = layout.findNearestKey(raw.points.front().x, raw.points.front().y);
|
||||
path.endKeyIndex = layout.findNearestKey(raw.points.back().x, raw.points.back().y);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
void PathProcessor::setMinPointDistance(float distanceDp) {
|
||||
if (pImpl) pImpl->minPointDistance = distanceDp;
|
||||
}
|
||||
|
||||
void PathProcessor::setResampleCount(int count) {
|
||||
if (pImpl && count >= 2) pImpl->resampleCount = count;
|
||||
}
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "swipetype/Scorer.h"
|
||||
#include "swipetype/SwipeTypeTypes.h"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <cfloat>
|
||||
|
||||
namespace swipetype {
|
||||
|
||||
struct Scorer::Impl {
|
||||
ScoringConfig config;
|
||||
|
||||
/**
|
||||
* Euclidean distance between two NormalizedPoints (x,y only).
|
||||
*/
|
||||
static float pointDistance(const NormalizedPoint& a, const NormalizedPoint& b) {
|
||||
float dx = a.x - b.x;
|
||||
float dy = a.y - b.y;
|
||||
return std::sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
};
|
||||
|
||||
Scorer::Scorer() : pImpl(new Impl()) {}
|
||||
Scorer::~Scorer() { delete pImpl; }
|
||||
|
||||
Scorer::Scorer(Scorer&& other) noexcept : pImpl(other.pImpl) {
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
|
||||
Scorer& Scorer::operator=(Scorer&& other) noexcept {
|
||||
if (this != &other) {
|
||||
delete pImpl;
|
||||
pImpl = other.pImpl;
|
||||
other.pImpl = nullptr;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
void Scorer::configure(const ScoringConfig& config) {
|
||||
if (pImpl) pImpl->config = config;
|
||||
}
|
||||
|
||||
float Scorer::computeDTWDistance(const GesturePath& gesture,
|
||||
const GesturePath& idealPath) const {
|
||||
const int N = RESAMPLE_COUNT;
|
||||
|
||||
if (static_cast<int>(gesture.points.size()) != N ||
|
||||
static_cast<int>(idealPath.points.size()) != N) {
|
||||
return FLT_MAX;
|
||||
}
|
||||
|
||||
// Sakoe-Chiba band width
|
||||
int W = static_cast<int>(std::ceil(pImpl->config.dtwBandwidthRatio * static_cast<float>(N)));
|
||||
if (W < 1) W = 1;
|
||||
|
||||
// Use two-row rolling DTW
|
||||
std::vector<float> dtw_prev(N, FLT_MAX);
|
||||
std::vector<float> dtw_curr(N, FLT_MAX);
|
||||
|
||||
// Initialize first row
|
||||
dtw_prev[0] = Impl::pointDistance(gesture.points[0], idealPath.points[0]);
|
||||
for (int j = 1; j <= std::min(W, N - 1); ++j) {
|
||||
if (dtw_prev[j - 1] < FLT_MAX) {
|
||||
dtw_prev[j] = dtw_prev[j - 1] +
|
||||
Impl::pointDistance(gesture.points[0], idealPath.points[j]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fill row by row
|
||||
for (int i = 1; i < N; ++i) {
|
||||
std::fill(dtw_curr.begin(), dtw_curr.end(), FLT_MAX);
|
||||
|
||||
int jMin = std::max(0, i - W);
|
||||
int jMax = std::min(N - 1, i + W);
|
||||
|
||||
for (int j = jMin; j <= jMax; ++j) {
|
||||
float cost = Impl::pointDistance(gesture.points[i], idealPath.points[j]);
|
||||
|
||||
float best = FLT_MAX;
|
||||
if (dtw_prev[j] < FLT_MAX)
|
||||
best = std::min(best, dtw_prev[j]);
|
||||
if (j > 0 && dtw_curr[j - 1] < FLT_MAX)
|
||||
best = std::min(best, dtw_curr[j - 1]);
|
||||
if (j > 0 && dtw_prev[j - 1] < FLT_MAX)
|
||||
best = std::min(best, dtw_prev[j - 1]);
|
||||
|
||||
dtw_curr[j] = (best < FLT_MAX) ? (cost + best) : FLT_MAX;
|
||||
}
|
||||
|
||||
std::swap(dtw_prev, dtw_curr);
|
||||
}
|
||||
|
||||
float raw = dtw_prev[N - 1];
|
||||
if (raw >= FLT_MAX) return FLT_MAX;
|
||||
|
||||
// Normalize by path length
|
||||
return raw / static_cast<float>(N);
|
||||
}
|
||||
|
||||
float Scorer::computeConfidence(float dtwDistance, float maxDTWDistance,
|
||||
uint32_t frequency, uint32_t maxFrequency) const {
|
||||
float normalizedDTW = 1.0f;
|
||||
if (maxDTWDistance > 0.0f && dtwDistance < FLT_MAX) {
|
||||
normalizedDTW = std::min(1.0f, dtwDistance / maxDTWDistance);
|
||||
}
|
||||
|
||||
float normalizedFreq = 0.0f;
|
||||
if (maxFrequency > 0) {
|
||||
normalizedFreq = std::min(1.0f, static_cast<float>(frequency) /
|
||||
static_cast<float>(maxFrequency));
|
||||
}
|
||||
|
||||
float alpha = pImpl->config.frequencyWeight;
|
||||
float finalScore = (1.0f - alpha) * normalizedDTW + alpha * (1.0f - normalizedFreq);
|
||||
float confidence = 1.0f - std::max(0.0f, std::min(1.0f, finalScore));
|
||||
return confidence;
|
||||
}
|
||||
|
||||
} // namespace swipetype
|
||||
@@ -0,0 +1,25 @@
|
||||
# Test executable
|
||||
set(SWIPETYPE_TEST_SOURCES
|
||||
PathProcessorTest.cpp
|
||||
ScorerTest.cpp
|
||||
DictionaryLoaderTest.cpp
|
||||
GestureEngineTest.cpp
|
||||
IdealPathGeneratorTest.cpp
|
||||
)
|
||||
|
||||
add_executable(swipetype-core-tests ${SWIPETYPE_TEST_SOURCES})
|
||||
|
||||
target_link_libraries(swipetype-core-tests
|
||||
PRIVATE
|
||||
swipetype-core
|
||||
GTest::gtest
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
target_include_directories(swipetype-core-tests
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
)
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(swipetype-core-tests)
|
||||
@@ -0,0 +1,176 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <swipetype/DictionaryLoader.h>
|
||||
#include <swipetype/SwipeTypeTypes.h>
|
||||
#include "TestHelpers.h"
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace swipetype;
|
||||
|
||||
class DictionaryLoaderTest : public ::testing::Test {
|
||||
protected:
|
||||
DictionaryLoader loader;
|
||||
std::vector<std::string> tempFiles;
|
||||
|
||||
void TearDown() override {
|
||||
for (const auto& path : tempFiles) {
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Write little-endian uint16 to buf at offset
|
||||
static void writeU16LE(std::vector<uint8_t>& buf, size_t offset, uint16_t val) {
|
||||
buf[offset] = static_cast<uint8_t>(val & 0xFF);
|
||||
buf[offset + 1] = static_cast<uint8_t>((val >> 8) & 0xFF);
|
||||
}
|
||||
static void writeU32LE(std::vector<uint8_t>& buf, size_t offset, uint32_t val) {
|
||||
buf[offset] = static_cast<uint8_t>(val & 0xFF);
|
||||
buf[offset + 1] = static_cast<uint8_t>((val >> 8) & 0xFF);
|
||||
buf[offset + 2] = static_cast<uint8_t>((val >> 16) & 0xFF);
|
||||
buf[offset + 3] = static_cast<uint8_t>((val >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
/// Create a minimal valid .glide file in memory and return as byte vector.
|
||||
/// Format:
|
||||
/// 32-byte header: magic(4) version(2) flags(2) entryCount(4) langLen(2) langTag(N) pad
|
||||
/// Each entry: wordLen(1) word(N) frequency(4) flags(1)
|
||||
std::vector<uint8_t> makeMinimalDict(
|
||||
const std::string& lang,
|
||||
const std::vector<std::pair<std::string, uint32_t>>& words)
|
||||
{
|
||||
// Build entries first, then header
|
||||
std::vector<uint8_t> entries;
|
||||
for (const auto& [word, freq] : words) {
|
||||
uint8_t wlen = static_cast<uint8_t>(word.size());
|
||||
entries.push_back(wlen);
|
||||
for (char c : word) entries.push_back(static_cast<uint8_t>(c));
|
||||
// frequency: 4 bytes LE
|
||||
entries.push_back(static_cast<uint8_t>(freq & 0xFF));
|
||||
entries.push_back(static_cast<uint8_t>((freq >> 8) & 0xFF));
|
||||
entries.push_back(static_cast<uint8_t>((freq >> 16) & 0xFF));
|
||||
entries.push_back(static_cast<uint8_t>((freq >> 24) & 0xFF));
|
||||
// flags: 1 byte
|
||||
entries.push_back(0x00);
|
||||
}
|
||||
|
||||
// Header: exactly 32 bytes
|
||||
std::vector<uint8_t> buf(DICT_HEADER_SIZE, 0);
|
||||
writeU32LE(buf, 0, DICT_MAGIC);
|
||||
writeU16LE(buf, 4, DICT_VERSION);
|
||||
writeU16LE(buf, 6, 0); // flags
|
||||
writeU32LE(buf, 8, static_cast<uint32_t>(words.size()));
|
||||
uint16_t langLen = static_cast<uint16_t>(std::min(lang.size(), size_t(18)));
|
||||
writeU16LE(buf, 12, langLen);
|
||||
for (size_t i = 0; i < langLen; ++i) buf[14 + i] = static_cast<uint8_t>(lang[i]);
|
||||
|
||||
// Append entries
|
||||
buf.insert(buf.end(), entries.begin(), entries.end());
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// Write byte vector to a temporary file and return its path.
|
||||
std::string writeToTempFile(const std::vector<uint8_t>& data) {
|
||||
char tmp[] = "/tmp/swipetype_test_XXXXXX";
|
||||
int fd = mkstemp(tmp);
|
||||
if (fd < 0) return "";
|
||||
|
||||
std::string path(tmp);
|
||||
tempFiles.push_back(path);
|
||||
|
||||
// Write via fstream; fd is still open, so use fdopen or just write to path
|
||||
if (!data.empty()) {
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out.write(reinterpret_cast<const char*>(data.data()),
|
||||
static_cast<std::streamsize>(data.size()));
|
||||
}
|
||||
close(fd);
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(DictionaryLoaderTest, LoadValidDictionary) {
|
||||
auto data = makeMinimalDict("en-US", {{"hello", 100}, {"world", 200}, {"test", 50}});
|
||||
std::string path = writeToTempFile(data);
|
||||
ASSERT_FALSE(path.empty());
|
||||
|
||||
bool ok = loader.load(path);
|
||||
EXPECT_TRUE(ok) << loader.getLastError().message;
|
||||
EXPECT_TRUE(loader.isLoaded());
|
||||
EXPECT_EQ(loader.getEntryCount(), 3u);
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, LoadFromMemory) {
|
||||
auto data = makeMinimalDict("en-US", {{"foo", 1000}, {"bar", 2000}});
|
||||
bool ok = loader.loadFromMemory(data.data(), data.size());
|
||||
EXPECT_TRUE(ok) << loader.getLastError().message;
|
||||
EXPECT_EQ(loader.getEntryCount(), 2u);
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, RejectInvalidMagic) {
|
||||
auto data = makeMinimalDict("en-US", {{"hello", 100}});
|
||||
// Corrupt magic bytes
|
||||
data[0] = 0xDE; data[1] = 0xAD; data[2] = 0xBE; data[3] = 0xEF;
|
||||
bool ok = loader.loadFromMemory(data.data(), data.size());
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_NE(loader.getLastError().code, ErrorCode::NONE);
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, RejectUnsupportedVersion) {
|
||||
auto data = makeMinimalDict("en-US", {{"hello", 100}});
|
||||
// Set version to 99
|
||||
data[4] = 99; data[5] = 0;
|
||||
bool ok = loader.loadFromMemory(data.data(), data.size());
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_EQ(loader.getLastError().code, ErrorCode::DICT_VERSION_MISMATCH);
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, RejectTruncatedFile) {
|
||||
auto data = makeMinimalDict("en-US", {{"hello", 100}, {"world", 200}});
|
||||
// Truncate halfway through entries
|
||||
data.resize(DICT_HEADER_SIZE + 3);
|
||||
bool ok = loader.loadFromMemory(data.data(), data.size());
|
||||
EXPECT_FALSE(ok);
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, LookupByPrefix) {
|
||||
auto data = makeMinimalDict("en-US",
|
||||
{{"hello", 100}, {"help", 80}, {"hero", 60}, {"world", 200}});
|
||||
ASSERT_TRUE(loader.loadFromMemory(data.data(), data.size()));
|
||||
|
||||
// getEntriesStartingWith('h') returns hello, help, hero (all start with 'h')
|
||||
auto hEntries = loader.getEntriesStartingWith('h');
|
||||
EXPECT_EQ(hEntries.size(), 3u);
|
||||
|
||||
// Verify "hello" and "help" are present, "world" is not
|
||||
bool hasHello = false, hasHelp = false, hasWorld = false;
|
||||
for (const auto* e : hEntries) {
|
||||
if (e->word == "hello") hasHello = true;
|
||||
if (e->word == "help") hasHelp = true;
|
||||
if (e->word == "world") hasWorld = true;
|
||||
}
|
||||
EXPECT_TRUE(hasHello);
|
||||
EXPECT_TRUE(hasHelp);
|
||||
EXPECT_FALSE(hasWorld);
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, LookupByStartAndEndKey) {
|
||||
auto data = makeMinimalDict("en-US",
|
||||
{{"hello", 100}, {"help", 80}, {"world", 200}, {"happy", 150}});
|
||||
ASSERT_TRUE(loader.loadFromMemory(data.data(), data.size()));
|
||||
|
||||
// getEntriesWithStartEnd('h', 'o') — starts with 'h', ends with 'o' → only "hello"
|
||||
auto matches = loader.getEntriesWithStartEnd('h', 'o');
|
||||
ASSERT_EQ(matches.size(), 1u);
|
||||
EXPECT_EQ(matches[0]->word, "hello");
|
||||
}
|
||||
|
||||
TEST_F(DictionaryLoaderTest, EmptyFileFails) {
|
||||
std::string path = writeToTempFile({});
|
||||
ASSERT_FALSE(path.empty());
|
||||
bool ok = loader.load(path);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_NE(loader.getLastError().code, ErrorCode::NONE);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <swipetype/GestureEngine.h>
|
||||
#include <swipetype/KeyboardLayout.h>
|
||||
#include <swipetype/GesturePoint.h>
|
||||
#include <swipetype/GestureCandidate.h>
|
||||
#include <swipetype/DictionaryLoader.h>
|
||||
#include <swipetype/SwipeTypeTypes.h>
|
||||
#include "TestHelpers.h"
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <fstream>
|
||||
#include <cstdio>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace swipetype;
|
||||
using namespace swipetype::test;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build a small in-memory dictionary with words relevant for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::vector<uint8_t> buildTestDict() {
|
||||
// Header helpers
|
||||
auto writeU16 = [](std::vector<uint8_t>& b, size_t off, uint16_t v) {
|
||||
b[off] = v & 0xFF; b[off+1] = (v>>8) & 0xFF;
|
||||
};
|
||||
auto writeU32 = [](std::vector<uint8_t>& b, size_t off, uint32_t v) {
|
||||
b[off] = v & 0xFF; b[off+1] = (v>>8) & 0xFF;
|
||||
b[off+2] = (v>>16) & 0xFF; b[off+3] = (v>>24) & 0xFF;
|
||||
};
|
||||
|
||||
const std::vector<std::pair<std::string, uint32_t>> words = {
|
||||
{"the", 1'000'000},
|
||||
{"and", 800'000},
|
||||
{"hello", 50'000},
|
||||
{"world", 40'000},
|
||||
{"help", 30'000},
|
||||
{"hero", 20'000},
|
||||
{"go", 200'000},
|
||||
{"do", 180'000},
|
||||
{"a", 900'000},
|
||||
};
|
||||
|
||||
std::vector<uint8_t> entries;
|
||||
for (const auto& [w, freq] : words) {
|
||||
entries.push_back(static_cast<uint8_t>(w.size()));
|
||||
for (char c : w) entries.push_back(static_cast<uint8_t>(c));
|
||||
entries.push_back(freq & 0xFF);
|
||||
entries.push_back((freq >> 8) & 0xFF);
|
||||
entries.push_back((freq >> 16) & 0xFF);
|
||||
entries.push_back((freq >> 24) & 0xFF);
|
||||
entries.push_back(0x00); // flags
|
||||
}
|
||||
|
||||
std::vector<uint8_t> buf(DICT_HEADER_SIZE, 0);
|
||||
writeU32(buf, 0, DICT_MAGIC);
|
||||
writeU16(buf, 4, DICT_VERSION);
|
||||
writeU16(buf, 6, 0);
|
||||
writeU32(buf, 8, static_cast<uint32_t>(words.size()));
|
||||
const char* lang = "en";
|
||||
buf[12] = 2; buf[13] = 0; // langLen = 2
|
||||
buf[14] = 'e'; buf[15] = 'n';
|
||||
|
||||
buf.insert(buf.end(), entries.begin(), entries.end());
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GestureEngineTest : public ::testing::Test {
|
||||
protected:
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
std::vector<uint8_t> testDict = buildTestDict();
|
||||
std::unique_ptr<GestureEngine> engine;
|
||||
|
||||
void SetUp() override {
|
||||
engine = std::make_unique<GestureEngine>();
|
||||
bool ok = engine->initWithData(layout, testDict.data(), testDict.size());
|
||||
ASSERT_TRUE(ok) << "Engine init failed: " << (ok ? "" : "initWithData returned false");
|
||||
}
|
||||
};
|
||||
|
||||
// ----- Initialization -----
|
||||
|
||||
TEST_F(GestureEngineTest, InitializeWithValidDict) {
|
||||
EXPECT_TRUE(engine->isInitialized());
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, InitializeWithInvalidDictFails) {
|
||||
GestureEngine badEngine;
|
||||
bool ok = badEngine.init(layout, "/nonexistent/path/dict.glide");
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_FALSE(badEngine.isInitialized());
|
||||
}
|
||||
|
||||
// ----- Recognition (end-to-end) -----
|
||||
|
||||
TEST_F(GestureEngineTest, RecognizeHello) {
|
||||
auto rawPts = makePathForWord(layout, "hello");
|
||||
ASSERT_GE(rawPts.size(), 2u);
|
||||
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 5);
|
||||
|
||||
// "hello" must appear in the top 5
|
||||
ASSERT_CONTAINS_WORD(candidates, "hello");
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, RecognizeThe) {
|
||||
auto rawPts = makePathForWord(layout, "the");
|
||||
ASSERT_GE(rawPts.size(), 2u);
|
||||
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 5);
|
||||
|
||||
// "the" is the highest-frequency word that matches start 't' / end 'e'
|
||||
ASSERT_FALSE(candidates.empty()) << "No candidates returned for 'the'";
|
||||
ASSERT_CONTAINS_WORD(candidates, "the");
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, RecognizeWithNoise) {
|
||||
auto rawPts = makePathForWord(layout, "hello");
|
||||
addNoise(rawPts, 5.0f, 5.0f);
|
||||
ASSERT_GE(rawPts.size(), 2u);
|
||||
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 8);
|
||||
// Even with noise, "hello" should be recoverable (or candidates non-empty)
|
||||
EXPECT_FALSE(candidates.empty()) << "No candidates after noisy 'hello' gesture";
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, RecognizeReturnsSortedByScore) {
|
||||
// Use "hello" — both "hello" and "hero" start with 'h' / end with 'o'
|
||||
// so we expect >= 2 candidates
|
||||
auto rawPts = makePathForWord(layout, "hello");
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 8);
|
||||
// Must have at least 1; if only 1 the sort is trivially correct
|
||||
ASSERT_FALSE(candidates.empty()) << "No candidates for 'hello'";
|
||||
|
||||
for (size_t i = 1; i < candidates.size(); ++i) {
|
||||
EXPECT_GE(candidates[i - 1].confidence, candidates[i].confidence)
|
||||
<< "Candidates not sorted at index " << i;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Performance -----
|
||||
|
||||
TEST_F(GestureEngineTest, RecognizeCompletesWithin50ms) {
|
||||
auto rawPts = makePathForWord(layout, "hello");
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
|
||||
auto t0 = std::chrono::high_resolution_clock::now();
|
||||
engine->recognize(raw, 8);
|
||||
auto t1 = std::chrono::high_resolution_clock::now();
|
||||
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
|
||||
EXPECT_LT(ms, 50) << "recognize() exceeded 50ms performance budget (" << ms << "ms)";
|
||||
}
|
||||
|
||||
// ----- Layout updates -----
|
||||
|
||||
TEST_F(GestureEngineTest, UpdateLayoutChangesResults) {
|
||||
// Get candidates with standard QWERTY
|
||||
auto rawPts = makePathForWord(layout, "hello");
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto before = engine->recognize(raw, 5);
|
||||
ASSERT_FALSE(before.empty());
|
||||
|
||||
// Swap 'h' and 'j' key positions
|
||||
KeyboardLayout modified = makeQwertyLayout();
|
||||
for (auto& key : modified.keys) {
|
||||
if (key.codePoint == 'h') key.centerX = 224.0f; // was 192
|
||||
else if (key.codePoint == 'j') key.centerX = 192.0f; // was 224
|
||||
}
|
||||
engine->updateLayout(modified);
|
||||
|
||||
auto after = engine->recognize(raw, 5);
|
||||
// Results with swapped layout should differ OR still produce candidates
|
||||
// (just verifying no crash and recognition still runs)
|
||||
// The top candidate may differ
|
||||
SUCCEED(); // Layout update should not crash
|
||||
}
|
||||
|
||||
// ----- Edge cases -----
|
||||
|
||||
TEST_F(GestureEngineTest, EmptyGestureReturnsEmpty) {
|
||||
RawGesturePath raw; // no points
|
||||
auto candidates = engine->recognize(raw, 8);
|
||||
EXPECT_TRUE(candidates.empty()) << "Empty gesture should return no candidates";
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, SinglePointGesture) {
|
||||
RawGesturePath raw;
|
||||
raw.points.push_back(GesturePoint(32.0f, 80.0f, 0)); // near 'a' key
|
||||
auto candidates = engine->recognize(raw, 8);
|
||||
// Single point < MIN_GESTURE_POINTS, should return empty or "a"
|
||||
// Either behavior is acceptable — just must not crash
|
||||
SUCCEED();
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, VeryLongGesture) {
|
||||
// 500-point gesture traversing multiple keys
|
||||
RawGesturePath raw;
|
||||
const std::string word = "world";
|
||||
auto pts = makePathForWord(layout, word, 100); // 100 points per segment
|
||||
raw.points = pts;
|
||||
// Must complete without crash
|
||||
auto candidates = engine->recognize(raw, 8);
|
||||
SUCCEED(); // Just verify no crash/hang
|
||||
}
|
||||
|
||||
// ----- Structural accuracy regression tests -----
|
||||
|
||||
TEST_F(GestureEngineTest, ZigzagWordNotFilteredByLengthEstimate) {
|
||||
// "hello" visits h,e,l,o — 4 distinct keys.
|
||||
// The old arc-length estimator guessed ~17.8 chars and risked filtering
|
||||
// the 5-char word "hello". Key-transition count should estimate ~4-5.
|
||||
auto rawPts = makePathForWord(layout, "hello");
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 5);
|
||||
|
||||
ASSERT_CONTAINS_WORD(candidates, "hello");
|
||||
// "hello" should rank as top-1 or top-2 for a clean, noiseless gesture
|
||||
ASSERT_FALSE(candidates.empty());
|
||||
EXPECT_TRUE(candidates[0].word == "hello" ||
|
||||
(candidates.size() > 1 && candidates[1].word == "hello"))
|
||||
<< "hello should be top-2 for a clean gesture, top was: " << candidates[0].word;
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, SingleCandidateGetsReasonableConfidence) {
|
||||
// "hero" starts with 'h' and ends with 'o', matching "hello" and "hero".
|
||||
// Even if only one candidate survives filtering, its confidence should
|
||||
// be meaningful (> 0.3) thanks to the absolute DTW floor.
|
||||
auto rawPts = makePathForWord(layout, "hero");
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 5);
|
||||
|
||||
ASSERT_CONTAINS_WORD(candidates, "hero");
|
||||
for (const auto& c : candidates) {
|
||||
if (c.word == "hero") {
|
||||
EXPECT_GT(c.confidence, 0.3f)
|
||||
<< "hero confidence should be > 0.3, was " << c.confidence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(GestureEngineTest, ShapeBeatFrequencyForWorldGesture) {
|
||||
// "world" and "would" both start with 'w' and end with 'd'.
|
||||
// "would" has much higher frequency (not in test dict, but this
|
||||
// verifies shape dominance). A gesture tracing w-o-r-l-d should
|
||||
// rank "world" at top-1 because adaptive alpha reduces frequency
|
||||
// influence when DTW scores are compressed.
|
||||
auto rawPts = makePathForWord(layout, "world");
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
auto candidates = engine->recognize(raw, 5);
|
||||
|
||||
ASSERT_CONTAINS_WORD(candidates, "world");
|
||||
// "world" should be top candidate for its own gesture
|
||||
EXPECT_EQ(candidates[0].word, "world")
|
||||
<< "world should be top-1 for world gesture, got: " << candidates[0].word;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <swipetype/IdealPathGenerator.h>
|
||||
#include <swipetype/KeyboardLayout.h>
|
||||
#include "TestHelpers.h"
|
||||
#include <string>
|
||||
#include <cmath>
|
||||
|
||||
using namespace swipetype;
|
||||
using namespace swipetype::test;
|
||||
|
||||
class IdealPathGeneratorTest : public ::testing::Test {
|
||||
protected:
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
IdealPathGenerator generator;
|
||||
|
||||
void SetUp() override {
|
||||
generator.setLayout(layout);
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, IdealPathHas64Points) {
|
||||
GesturePath path = generator.getIdealPath("hello");
|
||||
ASSERT_TRUE(path.isValid()) << "getIdealPath('hello') should return a valid path";
|
||||
EXPECT_EQ(static_cast<int>(path.points.size()), RESAMPLE_COUNT);
|
||||
}
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, IdealPathStartsAtFirstKeyCenter) {
|
||||
// "the": t(144,26) → h(192,80) → e(80,26)
|
||||
// Bounding-box: xmin=80, xmax=192, so t at x=(144-80)/(192-80)≈0.571
|
||||
// The key insight: path is bounding-box normalised, NOT layout-normalised.
|
||||
GesturePath path = generator.getIdealPath("the");
|
||||
ASSERT_TRUE(path.isValid());
|
||||
|
||||
// t.x=144 is between e.x=80 and h.x=192 → normalised to ~0.571
|
||||
// Verify the first point is in (0, 1) and the path covers the full x range
|
||||
float frontX = path.points.front().x;
|
||||
float backX = path.points.back().x;
|
||||
EXPECT_GE(frontX, 0.0f);
|
||||
EXPECT_LE(frontX, 1.0f);
|
||||
// t.x(144) > e.x(80), so after bounding-box norm t normalises higher than e
|
||||
EXPECT_GT(frontX, backX)
|
||||
<< "'t' key (x=144) should normalise higher than 'e' key (x=80) in the path";
|
||||
}
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, IdealPathEndsAtLastKeyCenter) {
|
||||
// "the": path ends at 'e' (x=80, y=26)
|
||||
// After bounding-box norm: e.x is the minimum x in the path → normalises to 0.0
|
||||
GesturePath path = generator.getIdealPath("the");
|
||||
ASSERT_TRUE(path.isValid());
|
||||
|
||||
// 'e' key is the leftmost key in the path (x_min=80) → normalised x = 0.0
|
||||
// 'e' is on the same y as 't' (y_min=26) → normalised y = 0.0
|
||||
EXPECT_NEAR(path.points.back().x, 0.0f, 0.05f)
|
||||
<< "'e' key (x_min in path) should normalise to ~0";
|
||||
EXPECT_NEAR(path.points.back().y, 0.0f, 0.05f)
|
||||
<< "'e' key (y_min in path) should normalise to ~0";
|
||||
}
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, SingleCharWordProducesSinglePoint64Times) {
|
||||
// Single-character words have a zero bounding-box.
|
||||
// The IdealPathGenerator may return an invalid/empty path for this case.
|
||||
// Two-character same-key word (impossible with real keyboard) → use "as" which
|
||||
// has two distinct keys and verify it produces a valid path instead.
|
||||
GesturePath singlePath = generator.getIdealPath("a");
|
||||
// Either valid (all 64 points identical) or invalid (zero bounding-box rejected):
|
||||
// just verify no crash and no assertion.
|
||||
if (singlePath.isValid()) {
|
||||
EXPECT_EQ(static_cast<int>(singlePath.points.size()), RESAMPLE_COUNT);
|
||||
// All x and y must be equal (same key center)
|
||||
float x0 = singlePath.points[0].x;
|
||||
float y0 = singlePath.points[0].y;
|
||||
for (const auto& pt : singlePath.points) {
|
||||
EXPECT_NEAR(pt.x, x0, 0.01f);
|
||||
EXPECT_NEAR(pt.y, y0, 0.01f);
|
||||
}
|
||||
} else {
|
||||
// Verify a two-char word still works
|
||||
GesturePath twoChar = generator.getIdealPath("as");
|
||||
EXPECT_TRUE(twoChar.isValid());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, CachingReturnsSameResult) {
|
||||
GesturePath first = generator.getIdealPath("hello");
|
||||
GesturePath second = generator.getIdealPath("hello");
|
||||
ASSERT_TRUE(first.isValid());
|
||||
ASSERT_TRUE(second.isValid());
|
||||
ASSERT_EQ(first.points.size(), second.points.size());
|
||||
for (size_t i = 0; i < first.points.size(); ++i) {
|
||||
EXPECT_FLOAT_EQ(first.points[i].x, second.points[i].x);
|
||||
EXPECT_FLOAT_EQ(first.points[i].y, second.points[i].y);
|
||||
}
|
||||
// Cache should have grown by 1 (only 1 unique word was generated in this test so far)
|
||||
EXPECT_GE(generator.cacheSize(), 1u);
|
||||
}
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, DifferentWordsProduceDifferentPaths) {
|
||||
GesturePath hello = generator.getIdealPath("hello");
|
||||
GesturePath world = generator.getIdealPath("world");
|
||||
ASSERT_TRUE(hello.isValid());
|
||||
ASSERT_TRUE(world.isValid());
|
||||
|
||||
// At least one point must differ between "hello" and "world"
|
||||
bool anyDiff = false;
|
||||
for (size_t i = 0; i < hello.points.size() && i < world.points.size(); ++i) {
|
||||
if (std::fabs(hello.points[i].x - world.points[i].x) > 0.01f ||
|
||||
std::fabs(hello.points[i].y - world.points[i].y) > 0.01f) {
|
||||
anyDiff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(anyDiff) << "Ideal paths for 'hello' and 'world' should differ";
|
||||
}
|
||||
|
||||
TEST_F(IdealPathGeneratorTest, LayoutChangeInvalidatesCache) {
|
||||
GesturePath before = generator.getIdealPath("hello");
|
||||
ASSERT_TRUE(before.isValid());
|
||||
|
||||
// Create a modified layout where 'h' key is moved significantly
|
||||
KeyboardLayout modified = makeQwertyLayout();
|
||||
for (auto& key : modified.keys) {
|
||||
if (key.codePoint == 'h') {
|
||||
key.centerX = 16.0f; // move from 192 dp to 16 dp (far left)
|
||||
break;
|
||||
}
|
||||
}
|
||||
generator.setLayout(modified);
|
||||
EXPECT_EQ(generator.cacheSize(), 0u) << "setLayout should clear cache";
|
||||
|
||||
GesturePath after = generator.getIdealPath("hello");
|
||||
ASSERT_TRUE(after.isValid());
|
||||
|
||||
// The paths should differ because 'h' moved
|
||||
bool anyDiff = false;
|
||||
for (size_t i = 0; i < before.points.size(); ++i) {
|
||||
if (std::fabs(before.points[i].x - after.points[i].x) > 0.05f) {
|
||||
anyDiff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(anyDiff) << "Path for 'hello' should change after layout update";
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <swipetype/PathProcessor.h>
|
||||
#include <swipetype/GesturePoint.h>
|
||||
#include <swipetype/GesturePath.h>
|
||||
#include "TestHelpers.h"
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
|
||||
using namespace swipetype;
|
||||
using namespace swipetype::test;
|
||||
|
||||
class PathProcessorTest : public ::testing::Test {
|
||||
protected:
|
||||
PathProcessor processor;
|
||||
|
||||
// Build a horizontal straight-line RawGesturePath from x=x0 to x=x1 at y=y0
|
||||
static RawGesturePath makeLine(float x0, float x1, float y0, int nPoints = 30) {
|
||||
RawGesturePath raw;
|
||||
for (int i = 0; i < nPoints; ++i) {
|
||||
float t = static_cast<float>(i) / (nPoints - 1);
|
||||
raw.points.push_back(GesturePoint(x0 + (x1 - x0) * t, y0,
|
||||
static_cast<int64_t>(i * 10)));
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
// ----- Deduplication (observable through normalize) -----
|
||||
|
||||
TEST_F(PathProcessorTest, DeduplicateRemovesDuplicateConsecutivePoints) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw;
|
||||
// 15 identical points at (50, 50), then 15 identical at (250, 130)
|
||||
for (int i = 0; i < 15; ++i)
|
||||
raw.points.push_back(GesturePoint(50.0f, 50.0f, static_cast<int64_t>(i * 10)));
|
||||
for (int i = 0; i < 15; ++i)
|
||||
raw.points.push_back(GesturePoint(250.0f, 130.0f, static_cast<int64_t>(150 + i * 10)));
|
||||
|
||||
// Two distinct endpoints — normalization should succeed despite many duplicates
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
EXPECT_TRUE(result.isValid());
|
||||
EXPECT_EQ(static_cast<int>(result.points.size()), RESAMPLE_COUNT);
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, DeduplicatePreservesPointsAboveThreshold) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
// Points 10 dp apart — well above MIN_POINT_DISTANCE_DP (2 dp)
|
||||
RawGesturePath raw;
|
||||
for (int i = 0; i <= 20; ++i)
|
||||
raw.points.push_back(GesturePoint(static_cast<float>(i * 10), 80.0f,
|
||||
static_cast<int64_t>(i * 10)));
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
EXPECT_TRUE(result.isValid());
|
||||
EXPECT_EQ(static_cast<int>(result.points.size()), RESAMPLE_COUNT);
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, DeduplicateHandlesEmptyInput) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw; // no points
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
EXPECT_FALSE(result.isValid());
|
||||
EXPECT_TRUE(result.points.empty());
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, DeduplicateHandlesSinglePoint) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw;
|
||||
raw.points.push_back(GesturePoint(50.0f, 80.0f, 0));
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
// 1 point < MIN_GESTURE_POINTS(2) — should return invalid/empty
|
||||
EXPECT_FALSE(result.isValid());
|
||||
}
|
||||
|
||||
// ----- Resampling -----
|
||||
|
||||
TEST_F(PathProcessorTest, ResampleProducesExactly64Points) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw = makeLine(16.0f, 304.0f, 80.0f, 30);
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
ASSERT_TRUE(result.isValid());
|
||||
EXPECT_EQ(static_cast<int>(result.points.size()), RESAMPLE_COUNT);
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, ResamplePreservesStartAndEndPoints) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
// Horizontal leftward-to-rightward path — first x should be < last x after normalization
|
||||
RawGesturePath raw = makeLine(16.0f, 304.0f, 80.0f, 30);
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
ASSERT_TRUE(result.isValid());
|
||||
EXPECT_LT(result.points.front().x, result.points.back().x);
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, ResampleEvenlySpreadsPoints) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
// Dense straight line — resampled output should have approximately equal spacing
|
||||
RawGesturePath raw = makeLine(16.0f, 304.0f, 80.0f, 100);
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
ASSERT_TRUE(result.isValid());
|
||||
ASSERT_GE(result.points.size(), 2u);
|
||||
|
||||
float firstDist = -1.0f;
|
||||
for (size_t i = 1; i < result.points.size(); ++i) {
|
||||
float dx = result.points[i].x - result.points[i - 1].x;
|
||||
float dy = result.points[i].y - result.points[i - 1].y;
|
||||
float dist = std::sqrt(dx * dx + dy * dy);
|
||||
if (firstDist < 0.0f) {
|
||||
firstDist = dist;
|
||||
} else {
|
||||
EXPECT_NEAR(dist, firstDist, firstDist * 0.1f)
|
||||
<< "Uneven spacing at index " << i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, ResampleHandlesCurvedPath) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw;
|
||||
// Semi-circular arc within the keyboard area
|
||||
for (int i = 0; i <= 60; ++i) {
|
||||
float angle = static_cast<float>(i) / 60.0f * 3.14159265f;
|
||||
float x = 160.0f + 120.0f * std::cos(angle);
|
||||
float y = 50.0f + 40.0f * std::sin(angle);
|
||||
raw.points.push_back(GesturePoint(x, y, static_cast<int64_t>(i * 10)));
|
||||
}
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
EXPECT_TRUE(result.isValid());
|
||||
EXPECT_EQ(static_cast<int>(result.points.size()), RESAMPLE_COUNT);
|
||||
}
|
||||
|
||||
// ----- Normalization -----
|
||||
|
||||
TEST_F(PathProcessorTest, NormalizeScalesToUnitSquare) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw = makeLine(16.0f, 304.0f, 50.0f, 40);
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
ASSERT_TRUE(result.isValid());
|
||||
for (const auto& p : result.points) {
|
||||
EXPECT_GE(p.x, -0.01f) << "x below 0";
|
||||
EXPECT_LE(p.x, 1.01f) << "x above 1";
|
||||
EXPECT_GE(p.y, -0.01f) << "y below 0";
|
||||
EXPECT_LE(p.y, 1.01f) << "y above 1";
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(PathProcessorTest, NormalizePreservesRelativePositions) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
RawGesturePath raw;
|
||||
// Monotonically increasing x — first point must have smaller normalized x than last
|
||||
raw.points.push_back(GesturePoint(50.0f, 80.0f, 0));
|
||||
raw.points.push_back(GesturePoint(160.0f, 80.0f, 100));
|
||||
raw.points.push_back(GesturePoint(270.0f, 80.0f, 200));
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
ASSERT_TRUE(result.isValid());
|
||||
EXPECT_LT(result.points.front().x, result.points.back().x);
|
||||
}
|
||||
|
||||
// ----- Full pipeline -----
|
||||
|
||||
TEST_F(PathProcessorTest, ProcessFullPipeline) {
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
std::vector<GesturePoint> rawPts = makePathForWord(layout, "hello");
|
||||
ASSERT_GE(rawPts.size(), 2u) << "makePathForWord returned < 2 points for 'hello'";
|
||||
|
||||
RawGesturePath raw;
|
||||
raw.points = rawPts;
|
||||
GesturePath result = processor.normalize(raw, layout);
|
||||
|
||||
EXPECT_TRUE(result.isValid());
|
||||
EXPECT_EQ(static_cast<int>(result.points.size()), RESAMPLE_COUNT);
|
||||
for (const auto& p : result.points) {
|
||||
EXPECT_GE(p.x, -0.01f);
|
||||
EXPECT_LE(p.x, 1.01f);
|
||||
EXPECT_GE(p.y, -0.01f);
|
||||
EXPECT_LE(p.y, 1.01f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <swipetype/Scorer.h>
|
||||
#include <swipetype/GesturePath.h>
|
||||
#include <swipetype/GestureCandidate.h>
|
||||
#include "TestHelpers.h"
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
using namespace swipetype;
|
||||
using namespace swipetype::test;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: create 64-point GesturePath objects directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static GesturePath makeFlatPath(float x, float y) {
|
||||
GesturePath p;
|
||||
p.aspectRatio = 1.0f;
|
||||
p.totalArcLength = 1.0f;
|
||||
for (int i = 0; i < RESAMPLE_COUNT; ++i) {
|
||||
float t = static_cast<float>(i) / static_cast<float>(RESAMPLE_COUNT - 1);
|
||||
p.points.push_back(NormalizedPoint(x, y, t));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
static GesturePath makeLinePath(float x0, float y0, float x1, float y1) {
|
||||
GesturePath p;
|
||||
p.aspectRatio = 1.0f;
|
||||
p.totalArcLength = std::sqrt((x1-x0)*(x1-x0) + (y1-y0)*(y1-y0));
|
||||
for (int i = 0; i < RESAMPLE_COUNT; ++i) {
|
||||
float t = static_cast<float>(i) / static_cast<float>(RESAMPLE_COUNT - 1);
|
||||
p.points.push_back(NormalizedPoint(x0 + (x1 - x0) * t,
|
||||
y0 + (y1 - y0) * t, t));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ScorerTest : public ::testing::Test {
|
||||
protected:
|
||||
Scorer scorer;
|
||||
KeyboardLayout layout = makeQwertyLayout();
|
||||
};
|
||||
|
||||
// ----- DTW Scoring -----
|
||||
|
||||
TEST_F(ScorerTest, IdenticalPathsScorePerfect) {
|
||||
GesturePath path = makeLinePath(0.0f, 0.1f, 1.0f, 0.1f);
|
||||
float dist = scorer.computeDTWDistance(path, path);
|
||||
EXPECT_NEAR(dist, 0.0f, 1e-4f) << "Identical paths should have DTW distance ~0";
|
||||
}
|
||||
|
||||
TEST_F(ScorerTest, CompletelyDifferentPathsScoreHigh) {
|
||||
// Top-row path (y~0) vs bottom-row path (y~1) — very different
|
||||
GesturePath top = makeLinePath(0.0f, 0.0f, 1.0f, 0.0f);
|
||||
GesturePath bottom = makeLinePath(0.0f, 1.0f, 1.0f, 1.0f);
|
||||
float dist = scorer.computeDTWDistance(top, bottom);
|
||||
EXPECT_GT(dist, 0.3f) << "Very different paths should have large DTW distance";
|
||||
}
|
||||
|
||||
TEST_F(ScorerTest, SakoeChibaBandConstraintApplied) {
|
||||
// Two paths that are identical except one is shifted by half a period —
|
||||
// the band should prevent a low-cost diagonal alignment.
|
||||
GesturePath a = makeLinePath(0.0f, 0.0f, 1.0f, 0.5f);
|
||||
GesturePath b = makeLinePath(0.0f, 0.5f, 1.0f, 0.0f);
|
||||
float dist = scorer.computeDTWDistance(a, b);
|
||||
// Just verify the scorer runs without crashing and produces a positive distance
|
||||
EXPECT_GE(dist, 0.0f);
|
||||
// A reversed path of the same length should have non-zero distance (band prevents alignment)
|
||||
EXPECT_GT(dist, 1e-6f);
|
||||
}
|
||||
|
||||
TEST_F(ScorerTest, DTWIsSymmetric) {
|
||||
GesturePath a = makeLinePath(0.0f, 0.1f, 1.0f, 0.9f);
|
||||
GesturePath b = makeLinePath(0.1f, 0.5f, 0.9f, 0.2f);
|
||||
float dAB = scorer.computeDTWDistance(a, b);
|
||||
float dBA = scorer.computeDTWDistance(b, a);
|
||||
EXPECT_NEAR(dAB, dBA, 1e-4f) << "DTW distance should be symmetric";
|
||||
}
|
||||
|
||||
// ----- Confidence / Frequency Weighting -----
|
||||
|
||||
TEST_F(ScorerTest, FrequencyWeightingBoostsHighFrequencyWord) {
|
||||
// Same DTW distance, but different frequencies
|
||||
float dtwDist = 0.3f;
|
||||
float maxDTW = 1.0f;
|
||||
uint32_t highFreq = 1'000'000;
|
||||
uint32_t lowFreq = 1'000;
|
||||
uint32_t maxFreq = highFreq;
|
||||
|
||||
float confHigh = scorer.computeConfidence(dtwDist, maxDTW, highFreq, maxFreq);
|
||||
float confLow = scorer.computeConfidence(dtwDist, maxDTW, lowFreq, maxFreq);
|
||||
EXPECT_GT(confHigh, confLow) << "Higher frequency should yield higher confidence";
|
||||
}
|
||||
|
||||
TEST_F(ScorerTest, AlphaControlsFrequencyInfluence) {
|
||||
// With default α=0.30, a perfect DTW match with max freq should score high
|
||||
float confPerfect = scorer.computeConfidence(0.0f, 1.0f, 1'000'000, 1'000'000);
|
||||
EXPECT_GT(confPerfect, 0.5f) << "Perfect match + max freq should give high confidence";
|
||||
|
||||
// A terrible DTW match with zero frequency should score low
|
||||
float confBad = scorer.computeConfidence(1.0f, 1.0f, 0, 1'000'000);
|
||||
EXPECT_LT(confBad, 0.5f) << "Bad DTW + zero freq should give low confidence";
|
||||
|
||||
// Perfect match should always beat terrible match
|
||||
EXPECT_GT(confPerfect, confBad);
|
||||
}
|
||||
|
||||
// ----- Scorer pipeline helpers -----
|
||||
|
||||
TEST_F(ScorerTest, ScoreCandidatesReturnsSortedResults) {
|
||||
// Verify computeConfidence produces values in [0, 1]
|
||||
for (float dtw : {0.0f, 0.2f, 0.5f, 0.8f, 1.0f}) {
|
||||
float conf = scorer.computeConfidence(dtw, 1.0f, 500'000, 1'000'000);
|
||||
EXPECT_GE(conf, 0.0f) << "Confidence must be >= 0";
|
||||
EXPECT_LE(conf, 1.0f) << "Confidence must be <= 1";
|
||||
}
|
||||
// A lower DTW distance should produce greater or equal confidence
|
||||
float conf1 = scorer.computeConfidence(0.1f, 1.0f, 500'000, 1'000'000);
|
||||
float conf2 = scorer.computeConfidence(0.9f, 1.0f, 500'000, 1'000'000);
|
||||
EXPECT_GE(conf1, conf2) << "Lower DTW distance should give >= confidence";
|
||||
}
|
||||
|
||||
TEST_F(ScorerTest, ScoreCandidatesRespectsMaxResults) {
|
||||
// Zero DTW distance = confidence 1.0 (best possible)
|
||||
float confMax = scorer.computeConfidence(0.0f, 1.0f, 1'000'000, 1'000'000);
|
||||
EXPECT_NEAR(confMax, 1.0f, 0.01f);
|
||||
// Zero frequency worst DTW = confidence ~0
|
||||
float confMin = scorer.computeConfidence(1.0f, 1.0f, 0, 1'000'000);
|
||||
EXPECT_LT(confMin, 0.1f);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#pragma once
|
||||
// TestHelpers.h — Shared test utilities and fixtures
|
||||
|
||||
#include <swipetype/SwipeTypeTypes.h>
|
||||
#include <swipetype/KeyboardLayout.h>
|
||||
#include <swipetype/GesturePath.h>
|
||||
#include <swipetype/GesturePoint.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace swipetype::test {
|
||||
|
||||
// ============================================================
|
||||
// QWERTY layout for testing (320×160 dp, 26 keys)
|
||||
// ============================================================
|
||||
inline swipetype::KeyboardLayout makeQwertyLayout() {
|
||||
KeyboardLayout layout;
|
||||
layout.languageTag = "en-US";
|
||||
layout.layoutWidth = 320.0f;
|
||||
layout.layoutHeight = 160.0f;
|
||||
|
||||
struct KeyDef { char label; int32_t cp; float cx; float cy; float w; float h; };
|
||||
const KeyDef keys[] = {
|
||||
// Row 1: Q W E R T Y U I O P
|
||||
{'q', 113, 16, 26, 32, 52}, {'w', 119, 48, 26, 32, 52},
|
||||
{'e', 101, 80, 26, 32, 52}, {'r', 114, 112, 26, 32, 52},
|
||||
{'t', 116, 144, 26, 32, 52}, {'y', 121, 176, 26, 32, 52},
|
||||
{'u', 117, 208, 26, 32, 52}, {'i', 105, 240, 26, 32, 52},
|
||||
{'o', 111, 272, 26, 32, 52}, {'p', 112, 304, 26, 32, 52},
|
||||
// Row 2: A S D F G H J K L
|
||||
{'a', 97, 32, 80, 32, 52}, {'s', 115, 64, 80, 32, 52},
|
||||
{'d', 100, 96, 80, 32, 52}, {'f', 102, 128, 80, 32, 52},
|
||||
{'g', 103, 160, 80, 32, 52}, {'h', 104, 192, 80, 32, 52},
|
||||
{'j', 106, 224, 80, 32, 52}, {'k', 107, 256, 80, 32, 52},
|
||||
{'l', 108, 288, 80, 32, 52},
|
||||
// Row 3: Z X C V B N M
|
||||
{'z', 122, 64, 134, 32, 52}, {'x', 120, 96, 134, 32, 52},
|
||||
{'c', 99, 128, 134, 32, 52}, {'v', 118, 160, 134, 32, 52},
|
||||
{'b', 98, 192, 134, 32, 52}, {'n', 110, 224, 134, 32, 52},
|
||||
{'m', 109, 256, 134, 32, 52},
|
||||
};
|
||||
|
||||
for (const auto& k : keys) {
|
||||
layout.keys.push_back(KeyDescriptor(
|
||||
std::string(1, k.label), k.cp,
|
||||
k.cx, k.cy, k.w, k.h
|
||||
));
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Path generators — create gesture paths for known words
|
||||
// ============================================================
|
||||
|
||||
/// Generate a straight-line path between key centers for a word.
|
||||
/// Returns a vector of GesturePoint with timestamps at 10ms intervals.
|
||||
inline std::vector<GesturePoint> makePathForWord(
|
||||
const KeyboardLayout& layout, const std::string& word, int pointsPerSegment = 8)
|
||||
{
|
||||
std::vector<GesturePoint> points;
|
||||
if (word.empty()) return points;
|
||||
|
||||
// Collect key centers
|
||||
std::vector<std::pair<float, float>> centers;
|
||||
for (char c : word) {
|
||||
int32_t cp = static_cast<int32_t>(c);
|
||||
int32_t idx = layout.findKeyByCodePoint(cp);
|
||||
if (idx >= 0) {
|
||||
centers.push_back({layout.keys[static_cast<size_t>(idx)].centerX,
|
||||
layout.keys[static_cast<size_t>(idx)].centerY});
|
||||
}
|
||||
}
|
||||
if (centers.empty()) return points;
|
||||
|
||||
int64_t ts = 0;
|
||||
for (size_t i = 0; i < centers.size() - 1; ++i) {
|
||||
float x0 = centers[i].first, y0 = centers[i].second;
|
||||
float x1 = centers[i+1].first, y1 = centers[i+1].second;
|
||||
|
||||
for (int j = 0; j < pointsPerSegment; ++j) {
|
||||
float t = static_cast<float>(j) / pointsPerSegment;
|
||||
float x = x0 + (x1 - x0) * t;
|
||||
float y = y0 + (y1 - y0) * t;
|
||||
points.push_back(GesturePoint{x, y, ts});
|
||||
ts += 10;
|
||||
}
|
||||
}
|
||||
// Add final point
|
||||
points.push_back(GesturePoint{
|
||||
centers.back().first, centers.back().second, ts
|
||||
});
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/// Add Gaussian noise to a path to simulate imprecise gestures.
|
||||
inline void addNoise(std::vector<GesturePoint>& points, float stddevX, float stddevY, uint32_t seed = 42) {
|
||||
// Simple LCG-based noise for reproducibility
|
||||
uint32_t state = seed;
|
||||
auto nextFloat = [&]() -> float {
|
||||
state = state * 1664525u + 1013904223u;
|
||||
return (static_cast<float>(state) / static_cast<float>(0xFFFFFFFF)) * 2.0f - 1.0f;
|
||||
};
|
||||
for (auto& p : points) {
|
||||
p.x += nextFloat() * stddevX;
|
||||
p.y += nextFloat() * stddevY;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Assertion helpers
|
||||
// ============================================================
|
||||
|
||||
/// Assert that the top candidate matches the expected word.
|
||||
#define ASSERT_TOP_CANDIDATE(candidates, expected) \
|
||||
ASSERT_FALSE(candidates.empty()) << "No candidates returned"; \
|
||||
EXPECT_EQ(candidates[0].word, expected) \
|
||||
<< "Expected top candidate '" << expected << "', got '" << candidates[0].word << "'"
|
||||
|
||||
/// Assert candidate list contains a specific word.
|
||||
#define ASSERT_CONTAINS_WORD(candidates, expectedWord) \
|
||||
{ bool _found = false; \
|
||||
for (const auto& _c : candidates) { if (_c.word == expectedWord) { _found = true; break; } } \
|
||||
EXPECT_TRUE(_found) << "Expected candidates to contain '" << expectedWord << "'"; }
|
||||
|
||||
} // namespace swipetype::test
|
||||
@@ -0,0 +1,304 @@
|
||||
# English dictionary for libswipetype — top ~500 words
|
||||
# Format: word<TAB>frequency
|
||||
the 1000000
|
||||
be 950000
|
||||
to 900000
|
||||
of 880000
|
||||
and 860000
|
||||
a 840000
|
||||
in 820000
|
||||
that 800000
|
||||
have 780000
|
||||
it 760000
|
||||
for 740000
|
||||
not 720000
|
||||
on 700000
|
||||
with 680000
|
||||
he 660000
|
||||
as 640000
|
||||
you 620000
|
||||
do 600000
|
||||
at 580000
|
||||
this 560000
|
||||
but 540000
|
||||
his 520000
|
||||
by 500000
|
||||
from 480000
|
||||
or 460000
|
||||
an 440000
|
||||
will 420000
|
||||
my 400000
|
||||
all 380000
|
||||
would 360000
|
||||
there 340000
|
||||
their 320000
|
||||
what 300000
|
||||
so 280000
|
||||
up 270000
|
||||
if 260000
|
||||
about 250000
|
||||
who 240000
|
||||
get 230000
|
||||
which 220000
|
||||
go 210000
|
||||
me 200000
|
||||
when 195000
|
||||
make 190000
|
||||
can 185000
|
||||
like 180000
|
||||
time 175000
|
||||
no 170000
|
||||
just 165000
|
||||
him 160000
|
||||
know 155000
|
||||
take 150000
|
||||
people 145000
|
||||
into 140000
|
||||
year 135000
|
||||
your 130000
|
||||
good 125000
|
||||
some 120000
|
||||
them 115000
|
||||
see 110000
|
||||
other 105000
|
||||
than 100000
|
||||
then 98000
|
||||
now 96000
|
||||
look 94000
|
||||
only 92000
|
||||
come 90000
|
||||
its 88000
|
||||
over 86000
|
||||
think 84000
|
||||
also 82000
|
||||
back 80000
|
||||
after 78000
|
||||
use 76000
|
||||
two 74000
|
||||
how 72000
|
||||
our 70000
|
||||
work 68000
|
||||
first 66000
|
||||
well 64000
|
||||
way 62000
|
||||
even 60000
|
||||
new 58000
|
||||
want 56000
|
||||
because 54000
|
||||
any 52000
|
||||
these 50000
|
||||
give 48000
|
||||
day 46000
|
||||
most 44000
|
||||
us 42000
|
||||
great 40000
|
||||
between 38000
|
||||
need 36000
|
||||
large 34000
|
||||
often 32000
|
||||
hand 30000
|
||||
high 28000
|
||||
place 26000
|
||||
hold 24000
|
||||
turn 22000
|
||||
here 20000
|
||||
why 19500
|
||||
help 19000
|
||||
call 18500
|
||||
world 18000
|
||||
try 17500
|
||||
ask 17000
|
||||
too 16500
|
||||
say 16000
|
||||
tell 15500
|
||||
right 15000
|
||||
still 14500
|
||||
own 14000
|
||||
mean 13500
|
||||
find 13000
|
||||
thing 12500
|
||||
much 12000
|
||||
name 11500
|
||||
before 11000
|
||||
move 10500
|
||||
off 10000
|
||||
under 9800
|
||||
last 9600
|
||||
never 9400
|
||||
next 9200
|
||||
away 9000
|
||||
long 8800
|
||||
big 8600
|
||||
down 8400
|
||||
more 8200
|
||||
out 8000
|
||||
old 7800
|
||||
same 7600
|
||||
little 7400
|
||||
very 7200
|
||||
once 7000
|
||||
every 6800
|
||||
really 6600
|
||||
around 6400
|
||||
school 6200
|
||||
might 6000
|
||||
today 5800
|
||||
let 5600
|
||||
always 5400
|
||||
show 5200
|
||||
set 5000
|
||||
start 4900
|
||||
part 4800
|
||||
night 4700
|
||||
point 4600
|
||||
play 4500
|
||||
small 4400
|
||||
number 4300
|
||||
off 4200
|
||||
open 4100
|
||||
seem 4000
|
||||
together 3900
|
||||
next 3800
|
||||
white 3700
|
||||
children 3600
|
||||
begin 3500
|
||||
got 3400
|
||||
walk 3300
|
||||
example 3200
|
||||
hear 3100
|
||||
grow 3000
|
||||
study 2900
|
||||
learn 2800
|
||||
should 2700
|
||||
plant 2600
|
||||
cover 2500
|
||||
food 2400
|
||||
sun 2300
|
||||
four 2200
|
||||
state 2100
|
||||
keep 2000
|
||||
eye 1950
|
||||
never 1900
|
||||
city 1850
|
||||
tree 1800
|
||||
cross 1750
|
||||
farm 1700
|
||||
hard 1650
|
||||
start 1600
|
||||
story 1550
|
||||
saw 1500
|
||||
far 1450
|
||||
sea 1400
|
||||
draw 1350
|
||||
left 1300
|
||||
late 1250
|
||||
run 1200
|
||||
where 1200
|
||||
talk 1150
|
||||
soon 1100
|
||||
earth 1050
|
||||
book 1000
|
||||
write 980
|
||||
carry 960
|
||||
took 940
|
||||
science 920
|
||||
eat 900
|
||||
room 880
|
||||
friend 860
|
||||
began 840
|
||||
idea 820
|
||||
fish 800
|
||||
mountain 780
|
||||
north 760
|
||||
plan 740
|
||||
notice 720
|
||||
south 700
|
||||
map 680
|
||||
music 660
|
||||
care 640
|
||||
face 620
|
||||
produce 600
|
||||
mile 580
|
||||
river 560
|
||||
car 540
|
||||
feet 520
|
||||
second 500
|
||||
enough 480
|
||||
plain 460
|
||||
girl 440
|
||||
usual 420
|
||||
young 400
|
||||
ready 380
|
||||
above 360
|
||||
ever 340
|
||||
red 320
|
||||
list 300
|
||||
though 280
|
||||
many 260
|
||||
feel 240
|
||||
talk 220
|
||||
bird 200
|
||||
soon 195
|
||||
body 190
|
||||
stop 185
|
||||
real 180
|
||||
side 170
|
||||
cut 165
|
||||
born 160
|
||||
sit 155
|
||||
five 150
|
||||
windows 145
|
||||
system 140
|
||||
computer 135
|
||||
phone 130
|
||||
data 125
|
||||
user 120
|
||||
message 115
|
||||
screen 110
|
||||
word 105
|
||||
type 100
|
||||
text 98
|
||||
key 96
|
||||
input 94
|
||||
keyboard 92
|
||||
hello 90
|
||||
okay 88
|
||||
yes 84
|
||||
please 80
|
||||
thank 76
|
||||
thanks 72
|
||||
sorry 68
|
||||
what 64
|
||||
where 60
|
||||
when 56
|
||||
why 54
|
||||
how 52
|
||||
morning 50
|
||||
evening 46
|
||||
night 44
|
||||
today 42
|
||||
tomorrow 40
|
||||
yesterday 38
|
||||
week 36
|
||||
month 34
|
||||
Monday 32
|
||||
Friday 30
|
||||
Sunday 28
|
||||
home 26
|
||||
house 24
|
||||
family 22
|
||||
love 20
|
||||
happy 18
|
||||
beautiful 16
|
||||
wonderful 14
|
||||
amazing 12
|
||||
awesome 10
|
||||
great 9
|
||||
cool 8
|
||||
nice 7
|
||||
fun 6
|
||||
game 5
|
||||
app 4
|
||||
test 3
|
||||
check 2
|
||||
done 1
|
||||
|
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
# Sample English dictionary for testing
|
||||
# Format: word<TAB>frequency
|
||||
# 50 words covering common short and long words
|
||||
the 1000000
|
||||
be 900000
|
||||
to 850000
|
||||
of 800000
|
||||
and 750000
|
||||
a 700000
|
||||
in 650000
|
||||
that 600000
|
||||
have 550000
|
||||
i 500000
|
||||
it 480000
|
||||
for 460000
|
||||
not 440000
|
||||
on 420000
|
||||
with 400000
|
||||
he 380000
|
||||
as 360000
|
||||
you 340000
|
||||
do 320000
|
||||
at 300000
|
||||
this 280000
|
||||
but 260000
|
||||
his 240000
|
||||
by 220000
|
||||
from 200000
|
||||
or 180000
|
||||
an 160000
|
||||
will 140000
|
||||
my 120000
|
||||
all 100000
|
||||
would 90000
|
||||
there 80000
|
||||
their 70000
|
||||
what 60000
|
||||
so 50000
|
||||
if 45000
|
||||
about 40000
|
||||
who 35000
|
||||
get 30000
|
||||
which 25000
|
||||
go 20000
|
||||
me 18000
|
||||
hello 15000
|
||||
world 12000
|
||||
together 10000
|
||||
keyboard 8000
|
||||
question 6000
|
||||
beautiful 4000
|
||||
extraordinary 2000
|
||||
programming 1000
|
||||
|
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "Gesture test scenarios for libswipetype",
|
||||
"layout": "qwerty-standard",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "scenario-hello-clean",
|
||||
"word": "hello",
|
||||
"description": "Clean straight-line gesture through h-e-l-l-o key centers",
|
||||
"difficulty": "easy",
|
||||
"points": [
|
||||
{"x": 192.0, "y": 80.0, "t": 0, "p": 1.0},
|
||||
{"x": 168.0, "y": 66.0, "t": 10, "p": 1.0},
|
||||
{"x": 136.0, "y": 53.0, "t": 20, "p": 1.0},
|
||||
{"x": 104.0, "y": 40.0, "t": 30, "p": 1.0},
|
||||
{"x": 80.0, "y": 26.0, "t": 40, "p": 1.0},
|
||||
{"x": 112.0, "y": 40.0, "t": 50, "p": 1.0},
|
||||
{"x": 160.0, "y": 60.0, "t": 60, "p": 1.0},
|
||||
{"x": 224.0, "y": 80.0, "t": 70, "p": 1.0},
|
||||
{"x": 256.0, "y": 80.0, "t": 80, "p": 1.0},
|
||||
{"x": 288.0, "y": 80.0, "t": 90, "p": 1.0},
|
||||
{"x": 288.0, "y": 80.0, "t": 100, "p": 1.0},
|
||||
{"x": 272.0, "y": 40.0, "t": 110, "p": 1.0},
|
||||
{"x": 272.0, "y": 26.0, "t": 120, "p": 1.0}
|
||||
],
|
||||
"expectedTopCandidates": ["hello", "help"],
|
||||
"mustContain": "hello"
|
||||
},
|
||||
{
|
||||
"id": "scenario-the-clean",
|
||||
"word": "the",
|
||||
"description": "Short gesture t-h-e, common word should benefit from frequency boost",
|
||||
"difficulty": "easy",
|
||||
"points": [
|
||||
{"x": 144.0, "y": 26.0, "t": 0, "p": 1.0},
|
||||
{"x": 152.0, "y": 32.0, "t": 10, "p": 1.0},
|
||||
{"x": 164.0, "y": 46.0, "t": 20, "p": 1.0},
|
||||
{"x": 178.0, "y": 62.0, "t": 30, "p": 1.0},
|
||||
{"x": 192.0, "y": 80.0, "t": 40, "p": 1.0},
|
||||
{"x": 168.0, "y": 66.0, "t": 50, "p": 1.0},
|
||||
{"x": 136.0, "y": 46.0, "t": 60, "p": 1.0},
|
||||
{"x": 104.0, "y": 34.0, "t": 70, "p": 1.0},
|
||||
{"x": 80.0, "y": 26.0, "t": 80, "p": 1.0}
|
||||
],
|
||||
"expectedTopCandidates": ["the"],
|
||||
"mustContain": "the"
|
||||
},
|
||||
{
|
||||
"id": "scenario-world-noisy",
|
||||
"word": "world",
|
||||
"description": "Gesture for 'world' with slight positional noise (±5dp)",
|
||||
"difficulty": "medium",
|
||||
"points": [
|
||||
{"x": 51.0, "y": 23.0, "t": 0, "p": 1.0},
|
||||
{"x": 62.0, "y": 32.0, "t": 12, "p": 1.0},
|
||||
{"x": 109.0, "y": 24.0, "t": 24, "p": 0.95},
|
||||
{"x": 162.0, "y": 34.0, "t": 36, "p": 0.9},
|
||||
{"x": 214.0, "y": 45.0, "t": 48, "p": 0.95},
|
||||
{"x": 268.0, "y": 28.0, "t": 60, "p": 1.0},
|
||||
{"x": 288.0, "y": 47.0, "t": 72, "p": 0.9},
|
||||
{"x": 310.0, "y": 62.0, "t": 84, "p": 0.85},
|
||||
{"x": 289.0, "y": 78.0, "t": 96, "p": 0.95},
|
||||
{"x": 285.0, "y": 82.0, "t": 108, "p": 1.0},
|
||||
{"x": 97.0, "y": 81.0, "t": 120, "p": 0.9},
|
||||
{"x": 94.0, "y": 79.0, "t": 132, "p": 1.0}
|
||||
],
|
||||
"expectedTopCandidates": ["world", "would"],
|
||||
"mustContain": "world"
|
||||
},
|
||||
{
|
||||
"id": "scenario-keyboard-long",
|
||||
"word": "keyboard",
|
||||
"description": "Longer word gesture testing the full pipeline with a less common word",
|
||||
"difficulty": "medium",
|
||||
"points": [
|
||||
{"x": 256.0, "y": 80.0, "t": 0, "p": 1.0},
|
||||
{"x": 240.0, "y": 66.0, "t": 10, "p": 1.0},
|
||||
{"x": 220.0, "y": 42.0, "t": 20, "p": 1.0},
|
||||
{"x": 80.0, "y": 26.0, "t": 30, "p": 0.95},
|
||||
{"x": 121.0, "y": 25.0, "t": 40, "p": 1.0},
|
||||
{"x": 176.0, "y": 26.0, "t": 50, "p": 1.0},
|
||||
{"x": 192.0, "y": 53.0, "t": 60, "p": 0.9},
|
||||
{"x": 192.0, "y": 80.0, "t": 70, "p": 0.95},
|
||||
{"x": 272.0, "y": 26.0, "t": 80, "p": 0.9},
|
||||
{"x": 280.0, "y": 50.0, "t": 90, "p": 0.95},
|
||||
{"x": 32.0, "y": 80.0, "t": 100, "p": 0.85},
|
||||
{"x": 114.0, "y": 26.0, "t": 110, "p": 0.9},
|
||||
{"x": 100.0, "y": 80.0, "t": 120, "p": 1.0}
|
||||
],
|
||||
"expectedTopCandidates": ["keyboard"],
|
||||
"mustContain": "keyboard"
|
||||
},
|
||||
{
|
||||
"id": "scenario-ambiguous-go-do",
|
||||
"word": "go",
|
||||
"description": "Short gesture that could match 'go' or 'do' — tests frequency-based disambiguation",
|
||||
"difficulty": "hard",
|
||||
"points": [
|
||||
{"x": 158.0, "y": 78.0, "t": 0, "p": 1.0},
|
||||
{"x": 168.0, "y": 72.0, "t": 15, "p": 1.0},
|
||||
{"x": 200.0, "y": 50.0, "t": 30, "p": 0.9},
|
||||
{"x": 240.0, "y": 36.0, "t": 45, "p": 0.95},
|
||||
{"x": 270.0, "y": 26.0, "t": 60, "p": 1.0}
|
||||
],
|
||||
"expectedTopCandidates": ["go", "do"],
|
||||
"mustContain": "go"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "QWERTY Standard",
|
||||
"languageTag": "en-US",
|
||||
"layoutWidth": 320.0,
|
||||
"layoutHeight": 160.0,
|
||||
"keys": [
|
||||
{"label": "q", "codePoint": 113, "centerX": 16.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "w", "codePoint": 119, "centerX": 48.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "e", "codePoint": 101, "centerX": 80.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "r", "codePoint": 114, "centerX": 112.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "t", "codePoint": 116, "centerX": 144.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "y", "codePoint": 121, "centerX": 176.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "u", "codePoint": 117, "centerX": 208.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "i", "codePoint": 105, "centerX": 240.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "o", "codePoint": 111, "centerX": 272.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "p", "codePoint": 112, "centerX": 304.0, "centerY": 26.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "a", "codePoint": 97, "centerX": 32.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "s", "codePoint": 115, "centerX": 64.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "d", "codePoint": 100, "centerX": 96.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "f", "codePoint": 102, "centerX": 128.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "g", "codePoint": 103, "centerX": 160.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "h", "codePoint": 104, "centerX": 192.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "j", "codePoint": 106, "centerX": 224.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "k", "codePoint": 107, "centerX": 256.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "l", "codePoint": 108, "centerX": 288.0, "centerY": 80.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "z", "codePoint": 122, "centerX": 64.0, "centerY": 134.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "x", "codePoint": 120, "centerX": 96.0, "centerY": 134.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "c", "codePoint": 99, "centerX": 128.0, "centerY": 134.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "v", "codePoint": 118, "centerX": 160.0, "centerY": 134.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "b", "codePoint": 98, "centerX": 192.0, "centerY": 134.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "n", "codePoint": 110, "centerX": 224.0, "centerY": 134.0, "width": 32.0, "height": 52.0},
|
||||
{"label": "m", "codePoint": 109, "centerX": 256.0, "centerY": 134.0, "width": 32.0, "height": 52.0}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user