diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 834cc1561..202d5e462 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -100,6 +100,10 @@ public final class InputLogic { // signal-driven grace + two-gate Assisted-tier logic. private boolean mSpacingComplete; // typed word is a real dictionary word private float mSpacingPrefixRichScore; // fraction of candidates that are completions [0..1] + // #24 last gate-decision snapshot — null when the Assisted tier is off (default). + // Read by A11y / TraceRecorder / replay harness; never drives commit behaviour yet. + @Nullable + private SpacingGateDecision mLastSpacingGateDecision; private final Suggest mSuggest; private final DictionaryFacilitator mDictionaryFacilitator; private SingleDictionaryFacilitator mEmojiDictionaryFacilitator; @@ -990,7 +994,14 @@ private void enterCombiningMode(final SettingsValues settingsValues, final boole // separators / cursor-front recompositions there's nothing to auto-commit, and arming // the timer would draw a spurious progress bar. if (!mWordComposer.isComposingWord()) return; - final int graceMs = baseGraceMs + Math.max(0, settingsValues.mCombiningTapExtraMs); + final int graceMs; + if (settingsValues.mSpacingSignalDrivenGrace) { + // #24: vary the grace duration by the per-keystroke word-state signals. + graceMs = signalDrivenGraceMs(baseGraceMs, settingsValues.mSpacingCompleteBonusMs, + settingsValues.mSpacingPrefixPenaltyMs, mSpacingComplete, mSpacingPrefixRichScore); + } else { + graceMs = baseGraceMs + Math.max(0, settingsValues.mCombiningTapExtraMs); + } cancelCombiningTimerOnly(); mInCombiningMode = true; // #14 "only auto-finish swiped words": still ENTER combining mode (so a following swipe @@ -1407,6 +1418,15 @@ public void setSuggestedWords(final SuggestedWords suggestedWords) { final SpacingSignals spacingSignals = computeSpacingSignals(suggestedWords); mSpacingComplete = spacingSignals.complete; mSpacingPrefixRichScore = spacingSignals.prefixRichScore; + // #24 gate-decision: evaluate and snapshot whenever the Assisted tier is on. + // Stored for trace/A11y consumption; does NOT drive any commit behaviour yet. + final SettingsValues sv = Settings.getInstance().getCurrent(); + if (sv != null && sv.mSpacingAssistedTier) { + mLastSpacingGateDecision = SpacingGateDecision.evaluate( + true, mSpacingComplete, mSpacingPrefixRichScore, sv.mSpacingLowThreshold); + } else { + mLastSpacingGateDecision = null; + } final boolean newAutoCorrectionIndicator = suggestedWords.mWillAutoCorrect; // Put a blue underline to a word in TextView which will be auto-corrected. @@ -1461,6 +1481,31 @@ static SpacingSignals computeSpacingSignals(final SuggestedWords suggestedWords) return new SpacingSignals(complete, (float) completions / n); } + private static final int SIGNAL_GRACE_MIN_MS = 100; + private static final int SIGNAL_GRACE_MAX_MS = 3000; + + /** + * #24 signal-driven grace duration: a confident complete word commits sooner (subtract + * {@code completeBonus}), while an extendable prefix-rich stem waits longer (add + * {@code prefixPenalty} scaled by the score), clamped to a sane range. Pure for testability. + */ + static int signalDrivenGraceMs(final int baseMs, final int completeBonusMs, + final int prefixPenaltyMs, final boolean complete, final float prefixRichScore) { + final int ms = baseMs - (complete ? completeBonusMs : 0) + + Math.round(prefixPenaltyMs * prefixRichScore); + return Math.max(SIGNAL_GRACE_MIN_MS, Math.min(SIGNAL_GRACE_MAX_MS, ms)); + } + + /** + * Returns the last gate-decision snapshot (for A11y / TraceRecorder / replay-harness + * consumption), or {@code null} when the Assisted tier is disabled (the default). + * Never drives commit behaviour on its own. + */ + @Nullable + public SpacingGateDecision getLastSpacingGateDecision() { + return mLastSpacingGateDecision; + } + /** * Handle a consumed event. *
diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/SpacingGateModel.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/SpacingGateModel.kt
new file mode 100644
index 000000000..a08e72c6e
--- /dev/null
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/SpacingGateModel.kt
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: GPL-3.0-only
+package helium314.keyboard.latin.inputlogic
+
+/**
+ * Pure two-gate decision model for #24 "Assisted" spacing-policy tier.
+ *
+ * Stateless and Android-free: every input is explicit, so the whole thing is unit-testable
+ * on the JVM without a running InputLogic. Production wiring into [InputLogic.enterCombiningMode]
+ * is intentionally deferred — this file only provides the model + trace carrier.
+ *
+ * Gate table (see docs/SPACING_POLICY.md §5):
+ *
+ * | Gate | Condition | Behaviour |
+ * |------------|-------------------------------------------------|------------------------|
+ * | A — INSTANT | complete && prefixRichScore ≤ lowThreshold | commit immediately |
+ * | B — PAUSE | complete && prefixRichScore > lowThreshold | wait for inter-word pause |
+ * | NONE | !complete (or policy disabled) | fall back to grace timer |
+ */
+object SpacingGateModel {
+
+ /**
+ * Which gate (if any) the current word falls into.
+ *
+ * - [NONE] — policy disabled, or word not complete; fall back to signal-driven grace timer.
+ * - [INSTANT] — Gate A: word is finished and prefix-richness is low → commit immediately.
+ * - [PAUSE] — Gate B: word is finished but prefix-richness is high → wait for the inter-word
+ * pause threshold before committing.
+ */
+ enum class Gate { NONE, INSTANT, PAUSE }
+
+ /**
+ * Decide which gate applies.
+ *
+ * @param policyEnabled master "Assisted tier" switch (default off — see
+ * [helium314.keyboard.latin.settings.Settings.PREF_SPACING_ASSISTED_TIER])
+ * @param complete the typed word is a real dictionary word (not user-typed-only)
+ * @param prefixRichScore fraction of suggestion candidates that are completions [0..1]
+ * @param lowThreshold Gate-A ceiling: score ≤ this triggers [Gate.INSTANT]
+ * (see [helium314.keyboard.latin.settings.Settings.PREF_SPACING_LOW_THRESHOLD])
+ * @return the applicable gate, or [Gate.NONE] when conditions are not met
+ */
+ @JvmStatic
+ fun decide(
+ policyEnabled: Boolean,
+ complete: Boolean,
+ prefixRichScore: Float,
+ lowThreshold: Float,
+ ): Gate {
+ if (!policyEnabled || !complete) return Gate.NONE
+ return if (prefixRichScore <= lowThreshold) Gate.INSTANT else Gate.PAUSE
+ }
+}
+
+/**
+ * Immutable snapshot of the most recent gate evaluation, held by [InputLogic] as
+ * `mLastSpacingGateDecision` for A11y / TraceRecorder / replay-harness consumption.
+ *
+ * The field is `@Nullable` so callers that have the Assisted tier disabled pay nothing —
+ * no allocation until the policy is actually on. Use [SpacingGateDecision.update] to
+ * produce a fresh snapshot cheaply.
+ */
+data class SpacingGateDecision(
+ /** The gate that fired (or [SpacingGateModel.Gate.NONE]). */
+ val gate: SpacingGateModel.Gate,
+ /** The `complete` signal at decision time. */
+ val complete: Boolean,
+ /** The `prefixRichScore` signal at decision time [0..1]. */
+ val prefixRichScore: Float,
+ /** The `lowThreshold` used at decision time. */
+ val lowThreshold: Float,
+) {
+ companion object {
+ /**
+ * Convenience factory: evaluate [SpacingGateModel.decide] and wrap the result.
+ * Called from [InputLogic.setSuggestedWords] when the Assisted tier is enabled.
+ */
+ @JvmStatic
+ fun evaluate(
+ policyEnabled: Boolean,
+ complete: Boolean,
+ prefixRichScore: Float,
+ lowThreshold: Float,
+ ): SpacingGateDecision = SpacingGateDecision(
+ gate = SpacingGateModel.decide(policyEnabled, complete, prefixRichScore, lowThreshold),
+ complete = complete,
+ prefixRichScore = prefixRichScore,
+ lowThreshold = lowThreshold,
+ )
+ }
+}
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
index c0e875464..206f7f75b 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
@@ -151,6 +151,13 @@ object Defaults {
const val PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = false
const val PREF_SPACING_DEFER_GRACE_SPACE = false
const val PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = true // default on: tapped words shouldn't auto-finish
+ const val PREF_SPACING_SIGNAL_DRIVEN_GRACE = false
+ const val PREF_SPACING_COMPLETE_BONUS_MS = 200 // complete word commits this much sooner
+ const val PREF_SPACING_PREFIX_PENALTY_MS = 400 // max extra wait when fully prefix-rich
+ // #24 two-gate Assisted-tier: master switch (default off) + Gate-A low-threshold.
+ // 0.1f is conservative: only clearly non-extendable words (very few completions) get Gate A.
+ const val PREF_SPACING_ASSISTED_TIER = false
+ const val PREF_SPACING_LOW_THRESHOLD = 0.1f
const val PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "alternatives_then_next_word"
const val PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = true
const val PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = true
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
index cf89a5379..6255fbda4 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
@@ -163,6 +163,16 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
// #14: when on, the combining grace timer only auto-commits words that include a swipe —
// pure tap-typed words are never auto-finished by the timer. Experimental, default off.
public static final String PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = "combining_grace_only_after_gesture";
+ // #14/#24 signal-driven grace: vary the grace-timer duration by the per-keystroke word-state
+ // signals (complete / prefix-richness) instead of a fixed value. Experimental, default off.
+ public static final String PREF_SPACING_SIGNAL_DRIVEN_GRACE = "spacing_signal_driven_grace";
+ public static final String PREF_SPACING_COMPLETE_BONUS_MS = "spacing_complete_bonus_ms";
+ public static final String PREF_SPACING_PREFIX_PENALTY_MS = "spacing_prefix_penalty_ms";
+ // #24 two-gate Assisted-tier: master on/off + Gate-A low-threshold. Both experimental,
+ // default off / conservative. The gate decision model is in SpacingGateModel.kt; production
+ // wiring into enterCombiningMode is deferred to the next phase.
+ public static final String PREF_SPACING_ASSISTED_TIER = "spacing_assisted_tier";
+ public static final String PREF_SPACING_LOW_THRESHOLD = "spacing_low_threshold";
// What the suggestion strip shows after the combining grace timer auto-commits a word.
// Values: "keep_alternatives" (1) | "next_word" (2, default) | "alternatives_then_next_word" (3).
public static final String PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "combining_autospace_suggestions";
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
index 95efd7756..ed7b32ce2 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
@@ -139,6 +139,12 @@ public class SettingsValues {
public final boolean mCombiningAutospaceOnlyAfterGesture;
public final boolean mSpacingDeferGraceSpace;
public final boolean mCombiningGraceOnlyAfterGesture;
+ public final boolean mSpacingSignalDrivenGrace;
+ public final int mSpacingCompleteBonusMs;
+ public final int mSpacingPrefixPenaltyMs;
+ // #24 two-gate Assisted-tier (experimental, default off).
+ public final boolean mSpacingAssistedTier;
+ public final float mSpacingLowThreshold;
// Raw string value: "keep_alternatives" | "next_word" | "alternatives_then_next_word"
public final String mCombiningAutospaceSuggestions;
public final boolean mCombiningBackspaceDeletesGestureWord;
@@ -390,6 +396,17 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mCombiningGraceOnlyAfterGesture = prefs.getBoolean(
Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE);
+ mSpacingSignalDrivenGrace = prefs.getBoolean(
+ Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE,
+ Defaults.PREF_SPACING_SIGNAL_DRIVEN_GRACE);
+ mSpacingCompleteBonusMs = prefs.getInt(Settings.PREF_SPACING_COMPLETE_BONUS_MS,
+ Defaults.PREF_SPACING_COMPLETE_BONUS_MS);
+ mSpacingPrefixPenaltyMs = prefs.getInt(Settings.PREF_SPACING_PREFIX_PENALTY_MS,
+ Defaults.PREF_SPACING_PREFIX_PENALTY_MS);
+ mSpacingAssistedTier = prefs.getBoolean(Settings.PREF_SPACING_ASSISTED_TIER,
+ Defaults.PREF_SPACING_ASSISTED_TIER);
+ mSpacingLowThreshold = prefs.getFloat(Settings.PREF_SPACING_LOW_THRESHOLD,
+ Defaults.PREF_SPACING_LOW_THRESHOLD);
mCombiningAutospaceSuggestions = prefs.getString(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS);
final boolean nonNormalTwoThumbSpacing = mGestureManualSpacing || mCombiningGraceMs > 0;
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
index e73389788..de9f98db6 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
@@ -71,6 +71,9 @@ fun TwoThumbTypingScreen(
add(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS)
add(Settings.PREF_SPACING_DEFER_GRACE_SPACE)
add(Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE)
+ add(Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE)
+ add(Settings.PREF_SPACING_COMPLETE_BONUS_MS)
+ add(Settings.PREF_SPACING_PREFIX_PENALTY_MS)
}
if (nonNormalSpacing) {
add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS)
@@ -158,6 +161,44 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
R.string.combining_grace_only_after_gesture_summary) {
SwitchPreference(it, Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE)
},
+ Setting(context, Settings.PREF_SPACING_SIGNAL_DRIVEN_GRACE,
+ R.string.spacing_signal_driven_grace, R.string.spacing_signal_driven_grace_summary) {
+ SwitchPreference(it, Defaults.PREF_SPACING_SIGNAL_DRIVEN_GRACE)
+ },
+ Setting(context, Settings.PREF_SPACING_COMPLETE_BONUS_MS,
+ R.string.spacing_complete_bonus, R.string.spacing_complete_bonus_summary) { def ->
+ SliderPreference(
+ name = def.title,
+ key = def.key,
+ default = Defaults.PREF_SPACING_COMPLETE_BONUS_MS,
+ range = 0f..1000f,
+ description = { stringResource(R.string.abbreviation_unit_milliseconds, it.toString()) }
+ )
+ },
+ Setting(context, Settings.PREF_SPACING_PREFIX_PENALTY_MS,
+ R.string.spacing_prefix_penalty, R.string.spacing_prefix_penalty_summary) { def ->
+ SliderPreference(
+ name = def.title,
+ key = def.key,
+ default = Defaults.PREF_SPACING_PREFIX_PENALTY_MS,
+ range = 0f..1500f,
+ description = { stringResource(R.string.abbreviation_unit_milliseconds, it.toString()) }
+ )
+ },
+ Setting(context, Settings.PREF_SPACING_ASSISTED_TIER,
+ R.string.spacing_assisted_tier, R.string.spacing_assisted_tier_summary) {
+ SwitchPreference(it, Defaults.PREF_SPACING_ASSISTED_TIER)
+ },
+ Setting(context, Settings.PREF_SPACING_LOW_THRESHOLD,
+ R.string.spacing_low_threshold, R.string.spacing_low_threshold_summary) { def ->
+ SliderPreference(
+ name = def.title,
+ key = def.key,
+ default = Defaults.PREF_SPACING_LOW_THRESHOLD,
+ range = 0f..1f,
+ description = { value -> "%.2f".format(value) }
+ )
+ },
Setting(context, Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
R.string.combining_autospace_suggestions, R.string.combining_autospace_suggestions_summary) { def ->
val items = listOf(
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 82e563c1c..781e638d7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -303,6 +303,18 @@