Integrate FlorisBoard-derived gesture engine (MornsGestureEngine)
Check generated files / Layout list (push) Has been cancelled
Check generated files / Layout warnings (push) Has been cancelled
Check generated files / Compose key data (push) Has been cancelled
Make Apk CI / Build-Apk (push) Has been cancelled

- Replaced libswipetype with MornsGestureEngine — ported from
  FlorisBoard's StatisticalGlideTypingClassifier (Apache 2.0)
- Uses combined shape + location Gaussian probability scoring
  (SHAPE_STD=22.08, LOCATION_STD=0.5109) with 200-point sampling
- Gesture activates instantly on key change with velocity-based detection
- Trail draws from initial touch through entire glide
- Added Kotlin plugin to build (v2.1.20)
- 2000 word dictionary from cracklib-small
- Removed libswipetype dependency from glide flow
  (libswipetype still present as submodule but unused)
This commit is contained in:
2026-07-07 03:59:47 -04:00
parent 55a7bd2643
commit 0c3dd42354
8 changed files with 2356 additions and 49 deletions
+5 -1
View File
@@ -3,6 +3,7 @@ import java.io.FileOutputStream
plugins {
id("com.android.application") version "8.13.2"
kotlin("android") version "2.1.20"
}
dependencies {
@@ -10,6 +11,8 @@ dependencies {
implementation("androidx.window:window-java:1.4.0")
implementation("androidx.core:core:1.16.0") // Version 1.17.0 available with sdk 36
implementation(project(":swipetype-android"))
implementation("org.jetbrains.kotlin:kotlin-stdlib:2.1.20")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
testImplementation("junit:junit:4.13.2")
}
@@ -28,7 +31,7 @@ android {
sourceSets {
named("main") {
manifest.srcFile("AndroidManifest.xml")
java.srcDirs("srcs/juloo.keyboard2", "vendor/cdict/java/juloo.cdict")
java.srcDirs("srcs/juloo.keyboard2", "srcs/gestures", "vendor/cdict/java/juloo.cdict")
res.srcDirs("res", "build/generated-resources")
assets.srcDirs("assets")
}
@@ -94,6 +97,7 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions.jvmTarget = "1.8"
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
/*
* Copyright (C) 2025 The FlorisBoard Contributors
* Licensed under the Apache License, Version 2.0
* Adapted for Morn's Keyboard (GPL-3.0)
*
* Gesture recognition using statistical shape + location matching.
* Based on Étienne Desticourt's algorithm (AnySoftKeyboard PR #1870).
* https://github.com/AnySoftKeyboard/AnySoftKeyboard/pull/1870
*/
package juloo.keyboard2
import kotlin.math.*
/**
* Gesture recognition engine for swipe typing.
* Takes a sequence of touch points, generates ideal paths for dictionary words,
* and scores them using combined shape + location Gaussian probability.
*/
class MornsGestureEngine(
private val keysByCodePoint: Map<Int, KeyInfo>,
private val words: List<String>
) {
companion object {
const val SAMPLING_POINTS = 200
private const val SHAPE_STD = 22.08f
private const val LOCATION_STD = 0.5109f
private const val PRUNING_LENGTH_THRESHOLD = 8.42
private const val MAX_GESTURE_SIZE = 500
private const val MAX_CANDIDATES = 8
}
data class KeyInfo(
val codePoint: Int,
val centerX: Float,
val centerY: Float,
val width: Float,
val height: Float
)
/** Accumulated gesture touch points. */
class Gesture {
private val xs = FloatArray(MAX_GESTURE_SIZE)
private val ys = FloatArray(MAX_GESTURE_SIZE)
private var size = 0
val isEmpty: Boolean get() = size == 0
fun addPoint(x: Float, y: Float) {
if (size < MAX_GESTURE_SIZE) { xs[size] = x; ys[size] = y; size++ }
}
fun getX(i: Int): Float = if (i < size) xs[i] else 0f
fun getY(i: Int): Float = if (i < size) ys[i] else 0f
val firstX: Float get() = getX(0)
val firstY: Float get() = getY(0)
val lastX: Float get() = getX(size - 1)
val lastY: Float get() = getY(size - 1)
fun clear() { size = 0 }
private fun d(x1: Float, y1: Float, x2: Float, y2: Float): Float =
sqrt((x1 - x2).pow(2f) + (y1 - y2).pow(2f))
val length: Float get() {
var len = 0f
for (i in 1 until size) len += d(xs[i-1], ys[i-1], xs[i], ys[i])
return len
}
fun resample(numPoints: Int): Gesture {
val interval = length / numPoints
val out = Gesture(); out.addPoint(xs[0], ys[0])
var lastX = xs[0]; var lastY = ys[0]
var cumErr = 0f
if (size == 1) { repeat(numPoints) { out.addPoint(xs[0], ys[0]) }; return out }
for (i in 0 until size - 1) {
var dx = xs[i+1] - xs[i]; var dy = ys[i+1] - ys[i]
val norm = sqrt(dx.pow(2f) + dy.pow(2f))
if (norm < 0.001f) continue
dx /= norm; dy /= norm
var n = norm / interval
cumErr += n - n.toInt()
if (cumErr > 1) { n = n.toInt() + cumErr.toInt().toFloat(); cumErr %= 1f }
repeat(n.toInt()) {
lastX += dx * interval; lastY += dy * interval
out.addPoint(lastX, lastY)
}
}
return out
}
/** Normalize to centroid-centered bounding box, preserving aspect ratio. */
fun normalizeByBoxSide(): Gesture {
val out = Gesture()
var maxX = -1e10f; var maxY = -1e10f
var minX = 1e10f; var minY = 1e10f
for (i in 0 until size) { maxX = max(xs[i], maxX); maxY = max(ys[i], maxY); minX = min(xs[i], minX); minY = min(ys[i], minY) }
val w = maxX - minX; val h = maxY - minY
val side = max(max(w, h), 0.00001f)
val cx = (w / 2f + minX) / side
val cy = (h / 2f + minY) / side
for (i in 0 until size) { out.addPoint(xs[i] / side - cx, ys[i] / side - cy) }
return out
}
fun clone(): Gesture { val g = Gesture(); xs.copyInto(g.xs); ys.copyInto(g.ys); g.size = size; return g }
}
/** Generate ideal gesture path for a word by connecting key centers. */
private fun generateIdealGesture(word: String): Gesture {
val g = Gesture()
for (c in word) {
val key = keysByCodePoint[Character.toLowerCase(c).code] ?: continue
g.addPoint(key.centerX, key.centerY)
}
return g
}
/** Find the n closest keys to a point. Returns their code points. */
private fun findNClosestKeys(x: Float, y: Float, n: Int): List<Int> {
return keysByCodePoint.entries
.map { (cp, k) -> cp to sqrt((x - k.centerX).pow(2f) + (y - k.centerY).pow(2f)) }
.sortedBy { it.second }
.take(n)
.map { it.first }
}
/** Score a gesture against a word. Lower is better. */
private fun scoreGesture(userGesture: Gesture, wordGesture: Gesture): Float {
val resampledUser = userGesture.resample(SAMPLING_POINTS)
val resampledWord = wordGesture.resample(SAMPLING_POINTS)
// Shape distance (normalized)
val normUser = resampledUser.normalizeByBoxSide()
val normWord = resampledWord.normalizeByBoxSide()
var shapeDist = 0f
for (i in 0 until SAMPLING_POINTS) {
shapeDist += distance(normWord.getX(i), normWord.getY(i), normUser.getX(i), normUser.getY(i))
}
// Location distance (unnormalized)
var locDist = 0f
for (i in 0 until SAMPLING_POINTS) {
locDist += abs(resampledWord.getX(i) - resampledUser.getX(i)) + abs(resampledWord.getY(i) - resampledUser.getY(i))
}
locDist /= SAMPLING_POINTS * 2f
// Combined Gaussian probability
val shapeProb = gaussianProb(shapeDist, 0f, SHAPE_STD)
val locProb = gaussianProb(locDist, 0f, LOCATION_STD * 50f)
return 1f / (shapeProb * locProb + 1e-10f)
}
private fun distance(x1: Float, y1: Float, x2: Float, y2: Float): Float =
sqrt((x1 - x2).pow(2f) + (y1 - y2).pow(2f))
private fun gaussianProb(value: Float, mean: Float, std: Float): Float {
val factor = 1f / (std * sqrt(2f * PI.toFloat()))
val exponent = ((value - mean) / std).toDouble().pow(2.0)
return (factor * exp(-0.5 * exponent)).toFloat()
}
/**
* Recognize the gesture and return candidate words sorted by confidence.
*/
fun recognize(points: List<MornsGlideAdapter.Pair>): List<String> {
val gesture = Gesture()
points.forEach { p -> gesture.addPoint(p.first, p.second) }
if (gesture.isEmpty) return emptyList()
val startKeys = findNClosestKeys(gesture.firstX, gesture.firstY, 2)
val endKeys = findNClosestKeys(gesture.lastX, gesture.lastY, 2)
// Filter words by start/end letters
val candidateWords = mutableListOf<String>()
for (w in words) {
if (w.isEmpty()) continue
val first = Character.toLowerCase(w[0])
val last = Character.toLowerCase(w[w.length - 1])
if (first.code in startKeys && last.code in endKeys) {
candidateWords.add(w)
}
}
// Score candidates
val scored = mutableListOf<Pair<String, Float>>()
val userGesture = gesture.resample(SAMPLING_POINTS)
for (word in candidateWords) {
val ideal = generateIdealGesture(word)
if (ideal.isEmpty) continue
val score = scoreGesture(userGesture, ideal)
scored.add(word to score)
}
scored.sortBy { it.second }
return scored.take(MAX_CANDIDATES).map { it.first }
}
fun clear() {}
}
+1 -1
View File
@@ -159,7 +159,7 @@ public final class Config
vibrate_duration = _prefs.getInt("vibrate_duration", 20);
longPressTimeout = _prefs.getInt("longpress_timeout", 600);
longPressInterval = _prefs.getInt("longpress_interval", 65);
keyrepeat_enabled = _prefs.getBoolean("keyrepeat_enabled", true);
keyrepeat_enabled = false;
margin_bottom = get_dip_pref_oriented(dm, "margin_bottom", 7, 3);
key_vertical_margin = get_dip_pref(dm, "key_vertical_margin", 1.5f) / 100;
key_horizontal_margin = get_dip_pref(dm, "key_horizontal_margin", 2) / 100;
+8 -9
View File
@@ -52,7 +52,7 @@ public class Keyboard2 extends InputMethodService
private Handler _handler;
private Config _config;
private UnexpectedKeyboardSwipeAdapter _swipeAdapter;
private MornsGlideAdapter _glideAdapter;
private FoldStateTracker _foldStateTracker;
@@ -134,11 +134,10 @@ public class Keyboard2 extends InputMethodService
Config.initGlobalConfig(prefs, getResources(),
_foldStateTracker.isUnfolded(), _dictionaries);
_config = Config.globalConfig();
_swipeAdapter = new UnexpectedKeyboardSwipeAdapter(this);
_swipeAdapter.init(new UnexpectedKeyboardSwipeAdapter.CandidatesCallback() {
public void onSwipeCandidates(String[] words, float[] scores) {
_glideAdapter = new MornsGlideAdapter();
_glideAdapter.init(new MornsGlideAdapter.CandidatesCallback() {
public void onCandidates(String[] words) {
if (words.length > 0) {
// Commit top candidate
getCurrentInputConnection().commitText(words[0], 1);
}
}
@@ -160,8 +159,8 @@ public class Keyboard2 extends InputMethodService
public void onDestroy() {
super.onDestroy();
if (_swipeAdapter != null)
_swipeAdapter.shutdown();
if (_glideAdapter != null)
_glideAdapter.cancelGesture();
_foldStateTracker.close();
}
@@ -170,8 +169,8 @@ public class Keyboard2 extends InputMethodService
_keyboard_container_view = (ViewGroup)inflate_view(R.layout.keyboard);
_keyboard_layout_view = (Keyboard2View)_keyboard_container_view.findViewById(R.id.keyboard_view);
_candidates_view = (CandidatesView)_keyboard_container_view.findViewById(R.id.candidates_view);
if (_swipeAdapter != null)
_keyboard_layout_view.setSwipeAdapter(_swipeAdapter);
if (_glideAdapter != null)
_keyboard_layout_view.setGlideAdapter(_glideAdapter);
}
InputMethodManager get_imm()
+42 -33
View File
@@ -21,8 +21,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import dev.dettmer.swipetype.android.KeyboardLayoutDescriptor;
public class Keyboard2View extends View
implements View.OnTouchListener, Pointers.IPointerEventHandler
{
@@ -36,7 +34,7 @@ public class Keyboard2View extends View
private KeyboardData.Key _compose_key;
private Pointers _pointers;
private UnexpectedKeyboardSwipeAdapter _swipeAdapter;
private MornsGlideAdapter _glideAdapter;
/** Glide gesture tracking. */
private int _glideLastKey = -1;
private int[] _glideKeys = new int[32];
@@ -97,9 +95,8 @@ public class Keyboard2View extends View
setKeyboard(KeyboardData.load(getResources(), layout_id));
}
public void setSwipeAdapter(UnexpectedKeyboardSwipeAdapter a) {
_swipeAdapter = a;
_pointers.setSwipeAdapter(a);
public void setGlideAdapter(MornsGlideAdapter a) {
_glideAdapter = a;
}
private Window getParentWindow(Context context)
@@ -235,7 +232,7 @@ public class Keyboard2View extends View
_inGlide = false;
_glideLastKey = -1;
_trailPoints.clear();
if (_swipeAdapter != null) _swipeAdapter.cancelGesture();
if (_glideAdapter != null) _glideAdapter.cancelGesture();
if (key != null && key.keys[0] != null
&& key.keys[0].getKind() == KeyValue.Kind.Char)
{
@@ -250,40 +247,32 @@ public class Keyboard2View extends View
float my = event.getY(p);
_pointers.onTouchMove(mx, my, event.getPointerId(p));
// Collect trail + detect glide in one pass
if (_swipeAdapter != null)
if (_glideAdapter != null)
{
KeyboardData.Key k = getKeyAtPosition(mx, my);
if (k != null && k.keys[0] != null
&& k.keys[0].getKind() == KeyValue.Kind.Char)
{
_trailPoints.add(new float[]{mx, my});
invalidate();
if (!_inGlide)
if (k != null && k.keys[0] != null
&& k.keys[0].getKind() == KeyValue.Kind.Char)
{
int cp = k.keys[0].getChar();
if (cp != _glideLastKey)
{
_glideLastKey = cp;
boolean seen = false;
for (int i = 0; i < _glideKeyCount; i++)
if (_glideKeys[i] == cp) { seen = true; break; }
if (!seen && _glideKeyCount < 32)
_glideKeys[_glideKeyCount++] = cp;
if (_glideKeyCount >= 2)
if (!_inGlide)
{
_inGlide = true;
_swipeAdapter.startGesture();
_glideAdapter.startGesture();
_pointers.cancelPointer(event.getPointerId(p));
}
}
}
}
}
// Feed points during glide (convert to dp for the engine)
if (_inGlide && _swipeAdapter != null)
if (_inGlide && _glideAdapter != null)
{
float density = getResources().getDisplayMetrics().density;
_swipeAdapter.addPoint(mx / density, my / density, System.currentTimeMillis());
_glideAdapter.addPoint(mx / density, my / density);
_trailPoints.add(new float[]{mx, my});
invalidate(); // trigger redraw to show trail
}
@@ -291,8 +280,8 @@ public class Keyboard2View extends View
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
if (_inGlide && _swipeAdapter != null)
_swipeAdapter.finishGesture();
if (_inGlide && _glideAdapter != null)
_glideAdapter.finishGesture();
_inGlide = false;
_trailPoints.clear();
invalidate();
@@ -372,11 +361,11 @@ public class Keyboard2View extends View
(int)(_tc.row_height * _keyboard.keysHeight
+ _config.marginTop + _marginBottom);
setMeasuredDimension(width, height);
// Pass real key positions to the swipe adapter
if (_swipeAdapter != null && _keyboard != null)
// Pass real key positions to the glide adapter
if (_glideAdapter != null && _keyboard != null)
{
float density = getResources().getDisplayMetrics().density;
List<KeyboardLayoutDescriptor.KeyInfo> keys = new ArrayList<>();
List<MornsGlideAdapter.KeyInfo> keys = new ArrayList<>();
float yy = _tc.margin_top / density;
for (KeyboardData.Row row : _keyboard.rows)
{
@@ -391,20 +380,40 @@ public class Keyboard2View extends View
&& k.keys[0].getKind() == KeyValue.Kind.Char)
{
char c = k.keys[0].getChar();
keys.add(new KeyboardLayoutDescriptor.KeyInfo(
String.valueOf(c), (int)c,
xx + keyW/2, yy + keyH/2, keyW, keyH));
MornsGlideAdapter.KeyInfo ki = new MornsGlideAdapter.KeyInfo();
ki.codePoint = (int)c;
ki.centerX = xx + keyW/2;
ki.centerY = yy + keyH/2;
ki.width = keyW;
ki.height = keyH;
keys.add(ki);
}
xx += (k.width * _keyWidth) / density;
}
yy += row.height * _tc.row_height / density;
}
_swipeAdapter.setKeyLayout(keys,
(_keyboard.keysWidth * _keyWidth + _marginLeft + _tc.margin_left) / density,
yy);
_glideAdapter.setKeys(keys, loadGestureWords());
}
}
private List<String> loadGestureWords()
{
List<String> words = new ArrayList<>();
try (java.io.BufferedReader br = new java.io.BufferedReader(
new java.io.InputStreamReader(
getResources().openRawResource(R.raw.gesture_words))))
{
String line;
while ((line = br.readLine()) != null)
{
line = line.trim();
if (line.length() >= 2) words.add(line);
}
}
catch (Exception e) { /* ignore */ }
return words;
}
Rect _cached_exclusion_rect = new Rect();
List<Rect> _cached_exclusion_rects = Arrays.asList(_cached_exclusion_rect);
@Override
+100
View File
@@ -0,0 +1,100 @@
package juloo.keyboard2;
import android.util.Log;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Glide gesture adapter using MornsGestureEngine (FlorisBoard-derived algorithm).
* Replaces the libswipetype-based SwipeTypeEngine.
*/
public class MornsGlideAdapter
{
private static final String TAG = "MornsGlide";
private MornsGestureEngine _engine;
private boolean _ready = false;
/** Touch points accumulated during the current gesture (dp). */
private List<Pair> _points = new ArrayList<>();
private boolean _active = false;
/** Callback for delivering candidates. */
private CandidatesCallback _callback;
private List<Integer> _allWordCps = new ArrayList<>();
public interface CandidatesCallback
{
void onCandidates(String[] words);
}
/** Key position info passed from the view. */
public static class KeyInfo
{
public int codePoint;
public float centerX;
public float centerY;
public float width;
public float height;
}
public MornsGlideAdapter() {}
public void init(CandidatesCallback cb)
{
_callback = cb;
}
/** Called by the view when keyboard layout is known. */
public void setKeys(List<KeyInfo> keys, List<String> words)
{
Map<Integer, MornsGestureEngine.KeyInfo> map = new HashMap<>();
List<Integer> allCps = new ArrayList<>();
for (KeyInfo k : keys)
{
map.put(k.codePoint, new MornsGestureEngine.KeyInfo(
k.codePoint, k.centerX, k.centerY, k.width, k.height));
allCps.add(k.codePoint);
}
_allWordCps = allCps;
_engine = new MornsGestureEngine(map, words);
_ready = true;
Log.i(TAG, "Engine ready: " + keys.size() + " keys, " + words.size() + " words");
}
public void startGesture() { _points.clear(); _active = true; }
public void addPoint(float xDp, float yDp)
{
if (_active) _points.add(new Pair(xDp, yDp));
}
public void finishGesture()
{
_active = false;
if (!_ready || _points.size() < 3) { _points.clear(); return; }
// Convert points to list of float pairs
List<Pair> pts = new ArrayList<>(_points);
@SuppressWarnings("unchecked")
List<String> results = (List<String>)_engine.recognize(pts);
_points.clear();
if (results == null || results.size() == 0) return;
String[] words = new String[results.size()];
for (int i = 0; i < results.size(); i++)
words[i] = results.get(i);
if (_callback != null) _callback.onCandidates(words);
}
public void cancelGesture() { _active = false; _points.clear(); }
/** Simple pair class for Kotlin <-> Java interop. */
public static class Pair
{
public float first;
public float second;
public Pair(float f, float s) { first = f; second = s; }
}
}