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

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:
inventory69
2026-02-26 23:02:03 +01:00
co-authored by inventory69 Hyphonical
commit b4d2f26319
85 changed files with 10470 additions and 0 deletions
+38
View File
@@ -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". -->
+139
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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
+99
View File
@@ -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.
+177
View File
@@ -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.
+180
View File
@@ -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>
+17
View File
@@ -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.
+64
View File
@@ -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.
+49
View File
@@ -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"
@@ -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;
}
}
}
@@ -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;
}
}
}
+9
View File
@@ -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
View File
@@ -0,0 +1,456 @@
# API Reference — libswipetype
> **Version:** 0.1.0 &nbsp;|&nbsp; **Language:** C++17 (core) &amp; Java (Android wrapper) &nbsp;|&nbsp; **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;
}
```
+357
View File
@@ -0,0 +1,357 @@
# Architecture — libswipetype
> **Version:** 0.1.0 &nbsp;|&nbsp; **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) |
+358
View File
@@ -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
+241
View File
@@ -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.
+17
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
+32
View File
@@ -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'
}
+41
View File
@@ -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 &amp; 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 &quot;SwipeType Sample IME&quot; 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.
+23
View File
@@ -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>
+14
View File
@@ -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>
+220
View File
@@ -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()
+42
View File
@@ -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 ==="
+21
View File
@@ -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'
+37
View File
@@ -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()
+66
View File
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
}
+92
View File
@@ -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
+79
View File
@@ -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
+59
View File
@@ -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
+251
View File
@@ -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
+341
View File
@@ -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
+235
View File
@@ -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
+214
View File
@@ -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
+119
View File
@@ -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
+25
View File
@@ -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);
}
+273
View File
@@ -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";
}
+176
View File
@@ -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);
}
}
+134
View File
@@ -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);
}
+129
View File
@@ -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
+304
View File
@@ -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
1 # English dictionary for libswipetype — top ~500 words
2 # Format: word<TAB>frequency
3 the 1000000
4 be 950000
5 to 900000
6 of 880000
7 and 860000
8 a 840000
9 in 820000
10 that 800000
11 have 780000
12 it 760000
13 for 740000
14 not 720000
15 on 700000
16 with 680000
17 he 660000
18 as 640000
19 you 620000
20 do 600000
21 at 580000
22 this 560000
23 but 540000
24 his 520000
25 by 500000
26 from 480000
27 or 460000
28 an 440000
29 will 420000
30 my 400000
31 all 380000
32 would 360000
33 there 340000
34 their 320000
35 what 300000
36 so 280000
37 up 270000
38 if 260000
39 about 250000
40 who 240000
41 get 230000
42 which 220000
43 go 210000
44 me 200000
45 when 195000
46 make 190000
47 can 185000
48 like 180000
49 time 175000
50 no 170000
51 just 165000
52 him 160000
53 know 155000
54 take 150000
55 people 145000
56 into 140000
57 year 135000
58 your 130000
59 good 125000
60 some 120000
61 them 115000
62 see 110000
63 other 105000
64 than 100000
65 then 98000
66 now 96000
67 look 94000
68 only 92000
69 come 90000
70 its 88000
71 over 86000
72 think 84000
73 also 82000
74 back 80000
75 after 78000
76 use 76000
77 two 74000
78 how 72000
79 our 70000
80 work 68000
81 first 66000
82 well 64000
83 way 62000
84 even 60000
85 new 58000
86 want 56000
87 because 54000
88 any 52000
89 these 50000
90 give 48000
91 day 46000
92 most 44000
93 us 42000
94 great 40000
95 between 38000
96 need 36000
97 large 34000
98 often 32000
99 hand 30000
100 high 28000
101 place 26000
102 hold 24000
103 turn 22000
104 here 20000
105 why 19500
106 help 19000
107 call 18500
108 world 18000
109 try 17500
110 ask 17000
111 too 16500
112 say 16000
113 tell 15500
114 right 15000
115 still 14500
116 own 14000
117 mean 13500
118 find 13000
119 thing 12500
120 much 12000
121 name 11500
122 before 11000
123 move 10500
124 off 10000
125 under 9800
126 last 9600
127 never 9400
128 next 9200
129 away 9000
130 long 8800
131 big 8600
132 down 8400
133 more 8200
134 out 8000
135 old 7800
136 same 7600
137 little 7400
138 very 7200
139 once 7000
140 every 6800
141 really 6600
142 around 6400
143 school 6200
144 might 6000
145 today 5800
146 let 5600
147 always 5400
148 show 5200
149 set 5000
150 start 4900
151 part 4800
152 night 4700
153 point 4600
154 play 4500
155 small 4400
156 number 4300
157 off 4200
158 open 4100
159 seem 4000
160 together 3900
161 next 3800
162 white 3700
163 children 3600
164 begin 3500
165 got 3400
166 walk 3300
167 example 3200
168 hear 3100
169 grow 3000
170 study 2900
171 learn 2800
172 should 2700
173 plant 2600
174 cover 2500
175 food 2400
176 sun 2300
177 four 2200
178 state 2100
179 keep 2000
180 eye 1950
181 never 1900
182 city 1850
183 tree 1800
184 cross 1750
185 farm 1700
186 hard 1650
187 start 1600
188 story 1550
189 saw 1500
190 far 1450
191 sea 1400
192 draw 1350
193 left 1300
194 late 1250
195 run 1200
196 where 1200
197 talk 1150
198 soon 1100
199 earth 1050
200 book 1000
201 write 980
202 carry 960
203 took 940
204 science 920
205 eat 900
206 room 880
207 friend 860
208 began 840
209 idea 820
210 fish 800
211 mountain 780
212 north 760
213 plan 740
214 notice 720
215 south 700
216 map 680
217 music 660
218 care 640
219 face 620
220 produce 600
221 mile 580
222 river 560
223 car 540
224 feet 520
225 second 500
226 enough 480
227 plain 460
228 girl 440
229 usual 420
230 young 400
231 ready 380
232 above 360
233 ever 340
234 red 320
235 list 300
236 though 280
237 many 260
238 feel 240
239 talk 220
240 bird 200
241 soon 195
242 body 190
243 stop 185
244 real 180
245 side 170
246 cut 165
247 born 160
248 sit 155
249 five 150
250 windows 145
251 system 140
252 computer 135
253 phone 130
254 data 125
255 user 120
256 message 115
257 screen 110
258 word 105
259 type 100
260 text 98
261 key 96
262 input 94
263 keyboard 92
264 hello 90
265 okay 88
266 yes 84
267 please 80
268 thank 76
269 thanks 72
270 sorry 68
271 what 64
272 where 60
273 when 56
274 why 54
275 how 52
276 morning 50
277 evening 46
278 night 44
279 today 42
280 tomorrow 40
281 yesterday 38
282 week 36
283 month 34
284 Monday 32
285 Friday 30
286 Sunday 28
287 home 26
288 house 24
289 family 22
290 love 20
291 happy 18
292 beautiful 16
293 wonderful 14
294 amazing 12
295 awesome 10
296 great 9
297 cool 8
298 nice 7
299 fun 6
300 game 5
301 app 4
302 test 3
303 check 2
304 done 1
Binary file not shown.
+53
View File
@@ -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
1 # Sample English dictionary for testing
2 # Format: word<TAB>frequency
3 # 50 words covering common short and long words
4 the 1000000
5 be 900000
6 to 850000
7 of 800000
8 and 750000
9 a 700000
10 in 650000
11 that 600000
12 have 550000
13 i 500000
14 it 480000
15 for 460000
16 not 440000
17 on 420000
18 with 400000
19 he 380000
20 as 360000
21 you 340000
22 do 320000
23 at 300000
24 this 280000
25 but 260000
26 his 240000
27 by 220000
28 from 200000
29 or 180000
30 an 160000
31 will 140000
32 my 120000
33 all 100000
34 would 90000
35 there 80000
36 their 70000
37 what 60000
38 so 50000
39 if 45000
40 about 40000
41 who 35000
42 get 30000
43 which 25000
44 go 20000
45 me 18000
46 hello 15000
47 world 12000
48 together 10000
49 keyboard 8000
50 question 6000
51 beautiful 4000
52 extraordinary 2000
53 programming 1000
+109
View File
@@ -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"
}
]
}
+34
View File
@@ -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}
]
}