Skip to content

feat(config): make the gesture-button hold gate configurable - #2022

Open
jmastorga wants to merge 2 commits into
AprilNEA:masterfrom
jmastorga:feat/configurable-gesture-hold
Open

jmastorga wants to merge 2 commits into
AprilNEA:masterfrom
jmastorga:feat/configurable-gesture-hold

Conversation

@jmastorga

@jmastorga jmastorga commented Oct 24, 2026 •

Copy link
Copy Markdown

What and why

Two gesture behaviours were compile-time constants, and both are the reason a gesture button misbehaves for someone who swipes fast:

  1. GESTURE_HOLD_FOR_SWIPE (160 ms). Travel may only commit to a direction once the button has been held past it. Flicking faster than that reaches the release with no committed direction, so SwipeAccumulator::end reports a plain click and the button's Click binding fires instead of the gesture — reproducibly, on every swipe.
  2. GESTURE_REPEAT_DISTANCE (600 raw units, 12× the commit threshold). Carrying the stroke on fires the same direction again, the way Options+ steps through several desktops. A long hold-and-drag therefore fires two or three actions from one press, with no way to stop it.

Both become per-device settings, alongside smooth_wheel and scroll_resolution, which already tune per-device wheel feel.

Changes

  • GestureHold (crates/openlogi-core/src/config/settings.rs): validated nutype, 40–1000 ms, DEFAULT = 160.
  • DeviceConfig::gesture_hold: Option<GestureHold> — omitted from config.toml when unset.
  • DeviceConfig::gesture_repeat: Option<bool> — None and true keep the current repeat behaviour; false limits one press to one swipe. Option (not a bare bool) keeps unset values out of the file and DeviceConfig under clippy's bool-count lint.
  • SwipeAccumulator::begin_with(hold) sets the gate for that hold; set_repeat_enabled(bool) gates the repeat branch at all (no distance test when off). begin() keeps the shared defaults, so existing callers and tests are unchanged.
  • Plumbed to both capture paths:
    • HID++: CaptureSpec::gesture_hold / gesture_repeat → GestureCapture → CaptureAccum (both preserved across reset_input_state).
    • OS hook: HookMaps::gesture_hold / gesture_repeat, published per selected device, applied in HoldState::begin.
[devices."serial:xxxx"]
gesture_hold = 80      # 40-1000 ms; omit for the 160 ms default
gesture_repeat = false # one swipe per press; omit to keep Options+ stepping

Tests

  • a_hold_gate_of_zero_commits_on_the_first_report
  • the_default_hold_gate_still_withholds_a_quick_swipe
  • an_abandoned_repeat_keeps_one_swipe_per_press
  • gesture_hold_round_trips_and_rejects_out_of_range
  • gesture_repeat_defaults_on_and_can_be_disabled

cargo test -p openlogi-core -p openlogi-device -p openlogi-agent-core: 1600 passed, 0 failed.
cargo clippy --all-targets on the same three crates: no warnings. cargo fmt --check: clean.

Schema note

DeviceConfig is deny_unknown_fields, so a config written with either field makes a pre-change build refuse the whole file. I did not bump schema_version, since the versioning policy is yours — say the word and I'll add the v10 bump (plus the load-side note) to this PR.

Scope

Backend + TOML only; no GUI control is added here. Happy to wire both into the device settings pane (next to the SmartShift/scroll rows) if you want it in the same PR.

The 160 ms hold required before gesture travel may commit to a swipe is a
compile-time constant. Users who flick the gesture button faster than that
reach a release with no committed direction, so the press is read as a plain
click — the binding fires instead of the gesture, on every swipe.

Add a per-device `gesture_hold` (40-1000 ms, default 160) and plumb it to both
capture paths: the HID++ gesture sources and the OS-hook gesture buttons.

- `GestureHold` is a validated newtype, omitted from config.toml when unset.
- `SwipeAccumulator::begin_with(hold)` carries the gate per hold; `begin()`
  keeps the shared default so existing call sites and tests are unchanged.
- `CaptureSpec` and `HookMaps` carry the configured value to the accumulators.

Config schema: `DeviceConfig` denies unknown fields, so a file written with
`gesture_hold` is rejected by a build that predates this field. The schema
bump is left to the maintainer per docs/DEVELOPMENT.md.
@greptile-apps

greptile-apps Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change affects gesture-button behavior across both supported capture paths.

Fix the repeat switch before merging; users can still trigger multiple actions with repeats disabled.

Fix All in CodexFindings

  1. P1 Disabled repeats still fire ▶
  2. P2 Chosen delay disappears on reconnect ▶
  3. P2 Custom delay lacks capture tests ▶

Summary

Adds a per-device gesture hold delay and a new gesture_repeat switch.

  • Each device can set when its gesture swipes start and whether they repeat.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Config["Per-device settings"] --> Plan["CaptureSpec"]
  Config --> Maps["HookMaps"]
  Plan --> Device["HID++ capture"]
  Maps --> Hook["OS hook"]
  Device --> Swipe["SwipeAccumulator"]
  Hook --> Swipe
  Swipe --> Action["Gesture action or click"]
Loading

Reviews (2) · Last reviewed commit: "feat(config): let a device switch gestur..." · Reviewed by Greptile

/// 160 ms) and is omitted from `config.toml`. Lowering it lets a quick
/// hold-and-flick register as a swipe instead of a plain click on release.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gesture_hold: Option<GestureHold>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Chosen delay disappears on reconnect

config::identity::fold does not carry over gesture_hold. If a user adds this setting to an existing route-keyed entry while the device is offline, the next online adoption removes that entry and drops the value. The device then uses 160 ms again.

Add fold_option_field!(gesture_hold) alongside the other optional settings and cover adoption with a test.

Knowledge Base Used: Core domain and configuration

Fix in Codex Fix in Claude Code

Comment on lines +382 to +385
#[test]
fn a_hold_gate_of_zero_commits_on_the_first_report() {
let mut acc = SwipeAccumulator::default();
acc.begin_with(Duration::ZERO);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Custom delay lacks capture tests

The only custom-delay test calls SwipeAccumulator::begin_with directly with zero, which the config cannot accept. The hook and device tests still pass GestureHold::DEFAULT, so they can stay green if either capture path drops the user's setting.

Add tests using an allowed non-default value through both paths, including reset_input_state.

Fix in Codex Fix in Claude Code

@AprilNEA AprilNEA left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR.

Whether OpenLogi adds this setting is a maintainer decision. This PR stays open until then. Three open PRs target the symptom in #1016:

  • #1048 (2026-08-26): per-control hold and travel presets, GUI and TOML.
  • #1996 (2026-10-23): a long, straight flick commits before the gate. No new setting.
  • This PR: a per-device hold gate, TOML only. The default stays 160 ms, so #1016 still happens for users who do not edit config.toml.

Findings that apply to any version of this change:

  • crates/openlogi-core/src/config.rs: master is at SCHEMA_VERSION = 10 since #993. After a rebase, a new DeviceConfig field needs v11 and a doc paragraph, as v8 (smooth_wheel) and v10 (disabled_keys) did.
  • crates/openlogi-core/src/config/identity.rs:247 fold(): add fold_option_field!(gesture_hold); and an adoption test. This is the Greptile thread on device.rs:286.
  • crates/openlogi-agent-core/src/capture_plan.rs:326 and orchestrator.rs:668 repeat one lookup. Add Config::gesture_hold(&self, device_key) -> GestureHold in config/per_device.rs, next to smooth_wheel() (.agents/rules/rust.md: "Policy is an API").
  • crates/openlogi-device/src/session/gesture.rs:196: CaptureTarget equality is the firmware rearm contract (capture_plan.rs:25-28). With the gate in CaptureSpec, each edit restores and re-diverts the controls. thumbwheel_sensitivity, also a software value, lives in the hot-replaceable DispatchPlan. Hot-update the gate in GestureCapture, or state the trade-off on the field.
  • config/settings.rs:385 GESTURE_HOLD_DEFAULT_MS and binding/swipe.rs:35 GESTURE_HOLD_FOR_SWIPE are two copies of 160 ms (AGENTS.md: "Single source of truth"). Keep one and derive the other from it.
  • binding/swipe.rs:155 begin_with(Duration) accepts any Duration and skips the validated type. Use one begin(hold: GestureHold) and store the GestureHold. Its Default then keeps #[derive(Default)] on SwipeAccumulator.
  • binding/swipe.rs:295 backdate_hold_for_test backdates by GESTURE_HOLD_FOR_SWIPE * 2 (320 ms). A configured gate above 320 ms stays closed. Use self.hold_for_swipe.
  • Tests: drive an allowed non-default value through both capture paths, including reset_input_state. This is the Greptile thread on swipe.rs:385.
  • docs/CONFIGURATION.md, "Common device fields": add gesture_hold with its range and default.

Carrying a gesture on past its first commit fires that direction again every
600 raw units (12x the commit threshold), which is Options+' behaviour for
stepping through several desktops. A user who holds a long stroke instead gets
two or three actions from one press, with no setting to stop it.

Add a per-device `gesture_repeat` (default on, so nothing changes for anyone
who does not set it). Off limits one press to one swipe: the accumulator stops
testing for the repeat distance at all.

`Option<bool>` rather than a bare bool keeps the config serialization honest
(unset is omitted, not written as `true`) and DeviceConfig under clippy's
bool-count lint.
let straight =
detect_swipe(self.repeat, self.repeat_cross) == Some(GestureDirection::Right);
if self.repeat >= GESTURE_REPEAT_DISTANCE && straight {
if self.repeat_enabled && self.repeat >= GESTURE_REPEAT_DISTANCE && straight {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Disabled repeats still fire

With gesture_repeat = false, this check only stops repeats during continuous travel. A pause of at least 250 ms clears last, so continuing in the same direction fires another action through the unchecked commit path. Reversing direction can also fire another swipe. One press can therefore run multiple actions despite the setting's promise of a single swipe.

Stop accepting movement after fired becomes true when repeats are disabled, and add tests for pauses and reversals.

Fix in Codex Fix in Claude Code

@jmastorga

Copy link
Copy Markdown
Author

One practical question about validating this on real hardware.

The gesture path can't be exercised by a locally built agent: without the Developer ID signature, com.apple.developer.hid.virtual.device is unavailable, the agent falls back to CGEventPost, and synthesized shortcuts never reach the focused app — gestures get detected but fire nothing. So the only way to test either field end-to-end is a signed build.

Would you be open to publishing a throwaway artifact of this branch (even unsigned-for-release, just notarised/Developer ID signed, arm64)? I have a macOS arm64 machine with an MX Master 3S where the current 160 ms gate and the 600-unit repeat both reproduce on every gesture, and I can report back with RUST_LOG=debug traces of gesture committed lines for whatever values you'd like tested.

No pressure if that's not part of your release flow — happy to wait for a release build instead.

@AprilNEA AprilNEA left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the signed build: a Developer ID build of an external branch runs that branch's code where the signing secrets live. Whether to do that is the maintainer's call, so I have left it to them. Two facts for testing in the meantime:

  • This PR changes swipe detection, not dispatch. The HID++ path logs gesture committed at debug level (accum.rs:257), so a local build shows whether the gate and the repeat setting behave.
  • A local build loses only the virtual HID keyboard; shortcut keys fall back to CGEventPost (docs/DECISIONS.md, "macOS key synthesis types through a virtual HID keyboard when entitled"). Only some shortcuts ignore posted keys (#1653, #744). #1996 reports a hardware test on an MX Master 3S with cargo run -p openlogi-desktop.

Changes needed on bdb0cb8e:

  • Scope: gesture_repeat is a second new setting with its own product decision (#1652 added repeats on purpose). Move it to a separate PR.
  • binding/swipe.rs:193-203 and :276: with repeats off, a rest of GESTURE_REARM_PAUSE clears last, and the normal commit path fires the same direction again. A reversal also commits. The doc on DeviceConfig::gesture_repeat ("one press to a single swipe") does not hold. This is the Greptile P1 on swipe.rs:230. Add tests for a pause and for a reversal.
  • bool parameters: accum.rs:49 begin_hold now takes three, and HoldState::begin, GestureCapture::new and with_gesture_settings take one each (.agents/rules/rust.md: "A bool parameter is boolean-blind"). Pass one named type, for example GestureTuning { hold: GestureHold, repeat: GestureRepeat } with enum GestureRepeat { Chain, Once }, into a single SwipeAccumulator::begin. That also replaces set_repeat_enabled.
  • capture_plan.rs:310 and orchestrator.rs:675 both write the default as unwrap_or(true). Use one Config accessor, as for gesture_hold.
  • config/identity.rs fold() needs gesture_repeat as well.

The 9 findings from my review of e375538c are still open, and so are the Greptile threads on device.rs:286 and swipe.rs:395.

@davidbudnick davidbudnick added area: config Configuration files, persistence, and settings area: hid HID device discovery, permissions, reads, or writes platform: all Cross-platform issue type: feature New feature request labels Oct 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: config Configuration files, persistence, and settings area: hid HID device discovery, permissions, reads, or writes platform: all Cross-platform issue type: feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants