Skip to content

Commit 32a2f16

Browse files
committed
feat: user-selectable touch calibration profiles (v1.9.46)
Expose the previously-hardcoded touch-resolution knobs as Conservative / Normal / Rescue-heavy calibration profiles (Settings -> Typing). Each profile tunes the gap-rescue dead-zone factor (TextKeyboard) and the adaptive spatial model's neighbour tolerances + minimum learned-sample threshold (AdaptiveTouchModel). NORMAL reproduces the historic constants exactly and is the default, so hit-testing behaviour is unchanged unless the user opts into another profile. getNearestKeyForPos and AdaptiveTouchModel.refine gained defaulted parameters so existing call sites and tests are unaffected. Profile invariants covered by JVM tests; full unit suite green.
1 parent 3d35cc6 commit 32a2f16

12 files changed

Lines changed: 227 additions & 14 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SwiftFloris
22

3-
![Version](https://img.shields.io/badge/version-v1.9.45-blue) ![License](https://img.shields.io/badge/license-Apache%202.0-green) ![Platform](https://img.shields.io/badge/platform-Android%208.0+-orange) ![Network](https://img.shields.io/badge/network-none-lightgrey) ![Dictionary imports](https://img.shields.io/badge/dictionary%20imports-local%20files-green)
3+
![Version](https://img.shields.io/badge/version-v1.9.46-blue) ![License](https://img.shields.io/badge/license-Apache%202.0-green) ![Platform](https://img.shields.io/badge/platform-Android%208.0+-orange) ![Network](https://img.shields.io/badge/network-none-lightgrey) ![Dictionary imports](https://img.shields.io/badge/dictionary%20imports-local%20files-green)
44

55
**SwiftFloris** is a privacy-first Android keyboard, forked from FlorisBoard and pushed toward SwiftKey-class multilingual typing without the cloud. It ships under Apache-2.0, holds no `INTERNET` permission, and binds zero accounts.
66

@@ -37,7 +37,7 @@
3737
3838
## Highlights
3939

40-
| Area | What's in v1.9.45 | Privacy posture |
40+
| Area | What's in v1.9.46 | Privacy posture |
4141
|------|-------------------|-----------------|
4242
| **Autocorrect / prediction** | SCOWL 117k English dictionary, heap-bounded SymSpell d1+d2, bigram + trigram next-word, capitalization-aware completions, contraction handling, instant-remember user-dictionary overlay | On-device |
4343
| **Multilingual typing** | Bilingual subtype presets (EN+ES / EN+FR / EN+DE), per-token Latin language identification, top-two straddle guard, sentence-local context scoring, opt-in remembered keyboard language per app, and stale-id-safe manual subtype switching | On-device |
@@ -304,6 +304,7 @@ Current SM-S938B / Android 16 baselines record `am start -W` first-render median
304304

305305
The full public release stream lives on [GitHub Releases](https://github.com/SysAdminDoc/SwiftFloris/releases).
306306

307+
- **v1.9.46** (2026-06-14) — New "Touch calibration" setting (Settings → Typing) exposes Conservative / Normal / Rescue-heavy profiles that tune gap-rescue dead zones and adaptive-touch neighbour correction. Normal reproduces the previously-hardcoded behaviour exactly, so the default is unchanged.
307308
- **v1.9.45** (2026-06-14) — New optional "CJK mixed-script spacing" setting inserts a boundary space between Han characters and adjacent Latin words or digits (安装 App, 第 3 章). Preference-gated, off by default; the Han boundary requirement keeps Latin/digit-only typing untouched and existing whitespace is respected.
308309
- **v1.9.44** (2026-06-13) — Locale script heuristics now use runtime Unicode character-property detection instead of hardcoded language lists, covering all scripts automatically. Release workflow gains SLSA Build Level 2 provenance attestation and SPDX SBOM generation.
309310
- **v1.9.43** (2026-06-13) — Translate quick-action now reads the user's preferred target locale instead of hardcoding English. Sticker bitmap LRU cache now trims on system memory pressure. Composer WithRules case conversion now preserves character count under CAPS_LOCK.
@@ -511,7 +512,7 @@ limitations under the License.
511512

512513
## Status
513514

514-
🚀 **Active development.** Current release: **v1.9.45** (2026-06-14). The SwiftKey account export window closed on **2026-05-31**; local/on-device migration paths remain documented above.
515+
🚀 **Active development.** Current release: **v1.9.46** (2026-06-14). The SwiftKey account export window closed on **2026-05-31**; local/on-device migration paths remain documented above.
515516

516517
---
517518

app/src/main/kotlin/dev/patrickgold/florisboard/app/EnumDisplayEntries.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import dev.patrickgold.florisboard.ime.text.gestures.GlideTrailTheme
4343
import dev.patrickgold.florisboard.ime.text.gestures.SwipeAction
4444
import dev.patrickgold.florisboard.ime.text.key.KeyHintMode
4545
import dev.patrickgold.florisboard.ime.text.key.UtilityKeyAction
46+
import dev.patrickgold.florisboard.ime.text.keyboard.TouchCalibrationProfile
4647
import dev.patrickgold.florisboard.ime.theme.ThemeMode
4748
import dev.patrickgold.florisboard.ime.voice.VoiceModelPreference
4849
import dev.patrickgold.florisboard.ime.voice.VoiceRecognitionEnginePreference
@@ -97,6 +98,28 @@ private val ENUM_DISPLAY_ENTRIES = mapOf<Pair<KClass<*>, String>, @Composable ()
9798
)
9899
}
99100
},
101+
TouchCalibrationProfile::class to DEFAULT to {
102+
listPrefEntries {
103+
entry(
104+
key = TouchCalibrationProfile.CONSERVATIVE,
105+
label = stringRes(R.string.enum__touch_calibration_profile__conservative),
106+
description = stringRes(R.string.enum__touch_calibration_profile__conservative__description),
107+
showDescriptionOnlyIfSelected = true,
108+
)
109+
entry(
110+
key = TouchCalibrationProfile.NORMAL,
111+
label = stringRes(R.string.enum__touch_calibration_profile__normal),
112+
description = stringRes(R.string.enum__touch_calibration_profile__normal__description),
113+
showDescriptionOnlyIfSelected = true,
114+
)
115+
entry(
116+
key = TouchCalibrationProfile.RESCUE_HEAVY,
117+
label = stringRes(R.string.enum__touch_calibration_profile__rescue_heavy),
118+
description = stringRes(R.string.enum__touch_calibration_profile__rescue_heavy__description),
119+
showDescriptionOnlyIfSelected = true,
120+
)
121+
}
122+
},
100123
CandidatesDisplayMode::class to DEFAULT to {
101124
listPrefEntries {
102125
entry(

app/src/main/kotlin/dev/patrickgold/florisboard/app/prefs/CorrectionPrefs.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import dev.patrickgold.florisboard.ime.smartbar.quickaction.QuickAction
4848
import dev.patrickgold.florisboard.ime.smartbar.quickaction.QuickActionArrangement
4949
import dev.patrickgold.florisboard.ime.smartbar.quickaction.QuickActionJsonConfig
5050
import dev.patrickgold.florisboard.ime.text.gestures.GlideTrailTheme
51+
import dev.patrickgold.florisboard.ime.text.keyboard.TouchCalibrationProfile
5152
import dev.patrickgold.florisboard.ime.text.gestures.SwipeAction
5253
import dev.patrickgold.florisboard.ime.text.key.KeyCode
5354
import dev.patrickgold.florisboard.ime.text.key.KeyHintConfiguration
@@ -104,6 +105,10 @@ open class CorrectionPrefs : PreferenceModel() {
104105
key = "correction__adaptive_touch_model",
105106
default = true,
106107
)
108+
val touchCalibrationProfile = enum(
109+
key = "correction__touch_calibration_profile",
110+
default = TouchCalibrationProfile.Default,
111+
)
107112
val multilingualSuggestions = boolean(
108113
key = "correction__multilingual_suggestions",
109114
default = true,

app/src/main/kotlin/dev/patrickgold/florisboard/app/settings/typing/TypingScreen.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import dev.patrickgold.florisboard.app.enumDisplayEntriesOf
3838
import dev.patrickgold.florisboard.ime.keyboard.IncognitoMode
3939
import dev.patrickgold.florisboard.ime.nlp.AutoCorrectCommitMode
4040
import dev.patrickgold.florisboard.ime.nlp.SpellingLanguageMode
41+
import dev.patrickgold.florisboard.ime.text.keyboard.TouchCalibrationProfile
4142
import dev.patrickgold.florisboard.lib.compose.FlorisHyperlinkText
4243
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
4344
import dev.patrickgold.jetpref.datastore.model.collectAsState
@@ -148,6 +149,12 @@ fun TypingScreen() = FlorisScreen {
148149
title = stringRes(R.string.pref__correction__adaptive_touch_model__label),
149150
summary = stringRes(R.string.pref__correction__adaptive_touch_model__summary),
150151
)
152+
ListPreference(
153+
prefs.correction.touchCalibrationProfile,
154+
title = stringRes(R.string.pref__correction__touch_calibration_profile__label),
155+
entries = enumDisplayEntriesOf(TouchCalibrationProfile::class),
156+
enabledIf = { prefs.correction.adaptiveTouchModel isEqualTo true },
157+
)
151158
SwitchPreference(
152159
prefs.suggestion.nextWordPrediction,
153160
title = stringRes(R.string.pref__suggestion__next_word_prediction__label),

app/src/main/kotlin/dev/patrickgold/florisboard/ime/text/keyboard/AdaptiveTouchModel.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,23 @@ internal object AdaptiveTouchModel {
166166
* distribution. Returns [primary] when there is not enough learned data.
167167
*/
168168
@Synchronized
169-
fun refine(keyboard: TextKeyboard, primary: TextKey, touchX: Float, touchY: Float): TextKey {
169+
fun refine(
170+
keyboard: TextKeyboard,
171+
primary: TextKey,
172+
touchX: Float,
173+
touchY: Float,
174+
minSamples: Int = MIN_SAMPLES_PER_KEY,
175+
horizontalTolerance: Float = NEIGHBOUR_HORIZONTAL_TOLERANCE,
176+
verticalTolerance: Float = NEIGHBOUR_VERTICAL_TOLERANCE,
177+
): TextKey {
170178
if (!isLearnableKey(primary)) return primary
171179
val bucketStats = statsBySubtype[activeBucket] ?: return primary
172180
// Key by touchModelCode() to match recordTap() and the candidate branch
173181
// below. computedData.code can be UNSPECIFIED for keys whose code only
174182
// lives on `data`; using it here meant the lookup missed and refinement
175183
// silently no-op'd for exactly those keys.
176184
val primaryStats = bucketStats[primary.touchModelCode()] ?: return primary
177-
if (primaryStats.count < MIN_SAMPLES_PER_KEY) return primary
185+
if (primaryStats.count < minSamples) return primary
178186

179187
val pBounds = primary.visibleBounds
180188
if (pBounds.isEmpty()) return primary
@@ -202,10 +210,10 @@ internal object AdaptiveTouchModel {
202210
// Restrict the candidate set to actual neighbours of the primary
203211
// hit (same row + immediately adjacent column, give or take key
204212
// size). Keys far away can never legitimately win.
205-
if (kotlin.math.abs(rawDx) > pHalfW + cHalfW + NEIGHBOUR_HORIZONTAL_TOLERANCE * pHalfW) continue
206-
if (kotlin.math.abs(rawDy) > pHalfH + cHalfH + NEIGHBOUR_VERTICAL_TOLERANCE * pHalfH) continue
213+
if (kotlin.math.abs(rawDx) > pHalfW + cHalfW + horizontalTolerance * pHalfW) continue
214+
if (kotlin.math.abs(rawDy) > pHalfH + cHalfH + verticalTolerance * pHalfH) continue
207215
val candStats = bucketStats[candidate.touchModelCode()] ?: continue
208-
if (candStats.count < MIN_SAMPLES_PER_KEY) continue
216+
if (candStats.count < minSamples) continue
209217
val cNx = (rawDx / cHalfW).coerceIn(-2f, 2f)
210218
val cNy = (rawDy / cHalfH).coerceIn(-2f, 2f)
211219
val score = candStats.logLikelihood(cNx, cNy)

app/src/main/kotlin/dev/patrickgold/florisboard/ime/text/keyboard/TextKeyboard.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ class TextKeyboard(
5757
return null
5858
}
5959

60-
fun getNearestKeyForPos(pointerX: Float, pointerY: Float): TextKey? {
60+
fun getNearestKeyForPos(
61+
pointerX: Float,
62+
pointerY: Float,
63+
gapRescueDistanceFactor: Float = GapRescueDistanceFactor,
64+
): TextKey? {
6165
if (layoutStyle == TextKeyboardLayoutStyle.Honeycomb) {
6266
return honeycombKeyForPos(pointerX, pointerY)
6367
}
@@ -69,7 +73,7 @@ class TextKeyboard(
6973
continue
7074
}
7175
val bounds = key.visibleBounds
72-
val maxRescueDistance = min(bounds.width, bounds.height) * GapRescueDistanceFactor
76+
val maxRescueDistance = min(bounds.width, bounds.height) * gapRescueDistanceFactor
7377
val dx = when {
7478
pointerX < bounds.left -> bounds.left - pointerX
7579
pointerX >= bounds.right -> pointerX - bounds.right

app/src/main/kotlin/dev/patrickgold/florisboard/ime/text/keyboard/TextKeyboardLayout.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -916,16 +916,25 @@ private class TextKeyboardLayoutController(
916916
val canCaptureTextTap = keyboard.mode == KeyboardMode.CHARACTERS &&
917917
keyboardManager.activeState.keyVariation != KeyVariation.PASSWORD
918918
val adaptiveTouchEnabled = prefs.correction.adaptiveTouchModel.get() && canCaptureTextTap
919+
val calibration = prefs.correction.touchCalibrationProfile.get()
919920
val touchDecoderEnabled = prefs.correction.autoCorrect.get() &&
920921
canCaptureTextTap &&
921922
!keyboardManager.activeState.isIncognitoMode
922923
val initialKey = keyboard.getKeyForPos(touchX, touchY) ?: if (adaptiveTouchEnabled) {
923-
keyboard.getNearestKeyForPos(touchX, touchY)
924+
keyboard.getNearestKeyForPos(touchX, touchY, calibration.gapRescueDistanceFactor)
924925
} else {
925926
null
926927
}
927928
val key = if (initialKey != null && adaptiveTouchEnabled) {
928-
AdaptiveTouchModel.refine(keyboard, initialKey, touchX, touchY)
929+
AdaptiveTouchModel.refine(
930+
keyboard = keyboard,
931+
primary = initialKey,
932+
touchX = touchX,
933+
touchY = touchY,
934+
minSamples = calibration.minSamplesPerKey,
935+
horizontalTolerance = calibration.neighbourHorizontalTolerance,
936+
verticalTolerance = calibration.neighbourVerticalTolerance,
937+
)
929938
} else {
930939
initialKey
931940
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright (C) 2026 SwiftFloris Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package dev.patrickgold.florisboard.ime.text.keyboard
18+
19+
/**
20+
* User-selectable touch-resolution calibration profile.
21+
*
22+
* Adjacent-key false hits are a recurring FOSS-keyboard complaint (HeliBoard
23+
* #2549). SwiftFloris resolves taps through two cooperating mechanisms:
24+
*
25+
* - **Gap rescue** ([TextKeyboard.getNearestKeyForPos]) snaps a tap that
26+
* lands in the dead zone *between* keys onto the closest key within
27+
* [gapRescueDistanceFactor] × min(key width, key height).
28+
* - **Spatial model** ([AdaptiveTouchModel.refine]) re-attributes a tap to a
29+
* learned neighbour when the user's accumulated offset distribution makes
30+
* the neighbour the better fit, bounded by the neighbour tolerances and a
31+
* minimum learned-sample count.
32+
*
33+
* A calibration profile exposes those previously-hardcoded knobs so users can
34+
* trade off between "never steal my tap" (conservative — narrow dead zones,
35+
* reluctant neighbour correction, lots of evidence required) and "fix my fat
36+
* fingers" (rescue-heavy — wide dead zones, eager correction, low evidence
37+
* threshold). [NORMAL] reproduces the historically-shipped constants exactly,
38+
* so the default behaviour is unchanged.
39+
*
40+
* The profile is consulted per touch-down; values are plain immutable data so
41+
* the policy is trivially unit-testable.
42+
*/
43+
enum class TouchCalibrationProfile(
44+
/** Multiplied by min(key width, key height) to bound gap-rescue distance. */
45+
val gapRescueDistanceFactor: Float,
46+
/** Horizontal neighbour-candidate window, in primary-key half-widths. */
47+
val neighbourHorizontalTolerance: Float,
48+
/** Vertical neighbour-candidate window, in primary-key half-heights. */
49+
val neighbourVerticalTolerance: Float,
50+
/** Minimum learned taps on a key before the spatial model may act on it. */
51+
val minSamplesPerKey: Int,
52+
) {
53+
/** Tight: prefers the geometric hit, rarely rescues or re-attributes. */
54+
CONSERVATIVE(
55+
gapRescueDistanceFactor = 0.18f,
56+
neighbourHorizontalTolerance = 0.28f,
57+
neighbourVerticalTolerance = 0.45f,
58+
minSamplesPerKey = 45,
59+
),
60+
61+
/** Shipped default — identical to the pre-calibration hardcoded values. */
62+
NORMAL(
63+
gapRescueDistanceFactor = 0.32f,
64+
neighbourHorizontalTolerance = 0.40f,
65+
neighbourVerticalTolerance = 0.60f,
66+
minSamplesPerKey = 30,
67+
),
68+
69+
/** Loose: wide dead zones and eager neighbour correction for heavy thumbs. */
70+
RESCUE_HEAVY(
71+
gapRescueDistanceFactor = 0.48f,
72+
neighbourHorizontalTolerance = 0.55f,
73+
neighbourVerticalTolerance = 0.75f,
74+
minSamplesPerKey = 20,
75+
);
76+
77+
companion object {
78+
val Default = NORMAL
79+
}
80+
}

app/src/main/res/values/strings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,7 @@
920920
<string name="pref__correction__double_space_period__summary" comment="Preference summary">Tapping twice on spacebar inserts a period followed by a space</string>
921921
<string name="pref__correction__adaptive_touch_model__label" comment="Preference title">Adaptive touch model</string>
922922
<string name="pref__correction__adaptive_touch_model__summary" comment="Preference summary">Learn how you actually tap each key and bias hit-tests toward your tap pattern. All on-device.</string>
923+
<string name="pref__correction__touch_calibration_profile__label" comment="Preference title">Touch calibration</string>
923924
<string name="pref__suggestion__next_word_prediction__label" comment="Preference title">Predict the next word</string>
924925
<string name="pref__suggestion__next_word_prediction__summary" comment="Preference summary">Show predicted next words in the suggestion strip after a space. Learns from your typing history, all on-device.</string>
925926
<string name="pref__correction__multilingual_suggestions__label" comment="Preference title">Multilingual suggestions</string>
@@ -2076,6 +2077,13 @@
20762077
<string name="enum__auto_correct_commit_mode__high_confidence" comment="Enum value label">Cautious</string>
20772078
<string name="enum__auto_correct_commit_mode__high_confidence__description" comment="Enum value description">Keep lower-confidence corrections as suggestions; auto-replace only when confidence is high.</string>
20782079

2080+
<string name="enum__touch_calibration_profile__conservative" comment="Enum value label">Conservative</string>
2081+
<string name="enum__touch_calibration_profile__conservative__description" comment="Enum value description">Prefer the key you actually touched. Narrow dead zones and rarely re-attribute a tap to a neighbour.</string>
2082+
<string name="enum__touch_calibration_profile__normal" comment="Enum value label">Normal</string>
2083+
<string name="enum__touch_calibration_profile__normal__description" comment="Enum value description">Balanced gap rescue and neighbour correction. The default.</string>
2084+
<string name="enum__touch_calibration_profile__rescue_heavy" comment="Enum value label">Rescue-heavy</string>
2085+
<string name="enum__touch_calibration_profile__rescue_heavy__description" comment="Enum value description">Wide dead zones and eager neighbour correction for heavier thumbs and more frequent adjacent-key misses.</string>
2086+
20792087
<string name="enum__spelling_language_mode__use_system_languages" comment="Enum value label">Use system languages</string>
20802088
<string name="enum__spelling_language_mode__use_keyboard_subtypes" comment="Enum value label">Use keyboard subtypes</string>
20812089

0 commit comments

Comments
 (0)