Skip to content

Add wheel scroll settings#94

Open
klovinad wants to merge 8 commits into
AprilNEA:masterfrom
klovinad:proposal/wheel-scroll-settings
Open

Add wheel scroll settings#94
klovinad wants to merge 8 commits into
AprilNEA:masterfrom
klovinad:proposal/wheel-scroll-settings

Conversation

@klovinad

@klovinad klovinad commented Jun 2, 2026

Copy link
Copy Markdown

Summary

This proposes two related mouse-control improvements:

  • Change the default horizontal gesture-button swipes to switch macOS desktops/spaces:
    • Left → Previous Desktop
    • Right → Next Desktop
  • Add app-wide scroll wheel preferences:
    • invert wheel direction
    • scroll strength multiplier
    • tactility/chunking

Implementation notes

  • Scroll preferences are persisted in AppSettings and mirrored into the hook runtime through a shared ScrollSettings value.
  • The hook transforms captured scroll events only when non-default scroll settings are active; default settings pass through natively.
  • Synthetic transformed scroll events are posted at the session tap to avoid being re-captured by OpenLogi's own HID event tap.
  • Added unit coverage for scroll axis ordering, inversion, and chunking.

Verification

Ran locally on macOS:

cargo fmt --all
cargo check -p openlogi-gui
cargo test -p openlogi-gui -p openlogi-core

Results:

  • openlogi-core: 34 passed
  • openlogi-gui: 14 passed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ Minor suggestions inline — one observation about the scroll-settings default check.

Reviewed changes — Adds three scroll-preference controls (invert, strength, tactility) to the Settings window and changes default horizontal gesture swipes to desktop/Space switching.

  • Default gesture direction updatePrevTab/NextTab replaced with PreviousDesktop/NextDesktop.
  • ScrollSettings shared stateArc<RwLock<ScrollSettings>> mirrors the persisted config to the hook runtime.
  • Scroll event transformationtransform_scroll/quantize_scroll apply inversion, strength, and chunking to captured wheel events.
  • Session-tap re-injection — Transformed scroll events are posted at CGEventTapLocation::Session to avoid re-capture by OpenLogi's HID tap.
  • Settings UI — Invert switch, strength slider (1–10), and tactility slider (0–10) in a new "Scroll" group box.
  • Generalized setting_row — Signature changed from control: Switch to control: impl IntoElement to accept sliders alongside switches.
  • Expanded native-click passthroughBackBrowserBack and ForwardBrowserForward added.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Big Pickle (free) (credentials for Anthropic not configured) | 𝕏

Comment thread crates/openlogi-gui/src/hook_runtime.rs Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Prior feedback addressed — no new issues found.

Reviewed changes — Fixed the ScrollSettings::default() mismatch with AppSettings defaults by replacing the derived Default with a manual impl whose strength: 1 matches the app config.

  • Manual Default impl for ScrollSettings — Replaced #[derive(Default)] with a manual impl Default where strength: 1 aligns with AppSettings::wheel_strength. This restores the identity fast-path in hook_runtime.rs.

Pullfrog  | View workflow run | Using Big Pickle (free) (credentials for Anthropic not configured) | 𝕏

@averypelle

Copy link
Copy Markdown

This should close #126

@phuocleoceo

Copy link
Copy Markdown

I'm really interested in the "invert wheel direction" feature. Thanks for bringing it up in this Pull Request, I hope it gets merged into the software soon!

@shawnflanagan

Copy link
Copy Markdown

Let's get these conflicts resolved so we can merge this. Invert wheel direction is sorely needed in the app

@averypelle

Copy link
Copy Markdown

for anyone looking for an interim fix, https://pilotmoon.com/scrollreverser/ is compatible with this app

@klovinad klovinad force-pushed the proposal/wheel-scroll-settings branch from 1813205 to 86fdb0d Compare June 14, 2026 09:09
@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds user-configurable wheel scroll settings (inversion, strength multiplier, tactility/chunking) across all three platforms, routing discrete mouse wheel events through a ScrollSettings transform while bypassing the transform for continuous trackpad events via the new is_continuous field.

  • hook_runtime.rs / binding.rs: ScrollSettings and SharedScrollSettings thread-safely share config with the hook callback; macOS mutates the CGEvent in-place (TransformScroll) while Windows/Linux suppress the original and re-inject a synthetic event — injection loops are correctly prevented by LLMHF_INJECTED on Windows and separate uinput device nodes on Linux.
  • macos.rs: transform_scroll_event handles all three CGEvent scroll field families (double, point, fixed-point); transform_integer_scroll_field always receives tactility=0 from its call sites, leaving the quantize branch dead (P2).
  • config.rs / state.rs / settings.rs: New fields use #[serde(default)] for backwards compatibility; GUI sliders clamp strength to 1–10 and tactility to 0–10, though the bounds are duplicated between settings.rs constants and state.rs magic numbers rather than sourced from a single definition (P2).

Confidence Score: 4/5

Safe to merge; all findings are P2 quality/polish items with no correctness or data-loss risk.

No P0 or P1 issues found. The three P2 comments cover dead code in transform_integer_scroll_field, duplicated bound constants, and a theoretical async race in post_symbolic_hotkey. Injection-loop guards on Windows (LLMHF_INJECTED) and macOS (SYNTHETIC_EVENT_USER_DATA) are both confirmed in place. Score is 4 rather than 5 to acknowledge the minor dead-code and duplication issues worth tidying before release.

crates/openlogi-core/src/binding.rs (symbolic hotkey async race), crates/openlogi-hook/src/macos.rs (dead tactility branch), crates/openlogi-gui/src/windows/settings.rs + crates/openlogi-gui/src/state.rs (duplicated wheel bound constants)

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/hook_runtime.rs Core scroll transform logic added: ScrollSettings struct, SharedScrollSettings Arc, transform_scroll/quantize_scroll helpers, and per-platform dispatch (TransformScroll on macOS, Suppress+post on others).
crates/openlogi-agent-core/src/orchestrator.rs Wires SharedScrollSettings into SharedRuntime; initialises from config and updates via apply_config. Straightforward plumbing with no logic issues.
crates/openlogi-agent/src/main.rs One-line change to forward scroll_settings clone to hook_runtime::start(). No issues.
crates/openlogi-core/src/binding.rs Large addition moving Action::execute() and platform scroll-injection helpers into openlogi-core. post_symbolic_hotkey has a potential async race (P2). Injection-loop risk on macOS is correctly prevented by the SYNTHETIC_EVENT_USER_DATA tag check.
crates/openlogi-core/src/config.rs Adds wheel_inverted, wheel_strength (default 1), and wheel_tactility (default 0) to AppSettings with proper serde defaults and Default impl. No issues.
crates/openlogi-gui/src/state.rs Adds set_wheel_inverted/strength/tactility mutators with in-state clamping before persist_and_reload. Clamp bounds are magic numbers duplicated from settings.rs (P2).
crates/openlogi-gui/src/windows/settings.rs New wheel inversion switch and two sliders added to the settings UI. Wheel bound constants redeclared locally rather than sourced from config.rs (P2 duplication).
crates/openlogi-hook/src/lib.rs MouseEvent::Scroll gains is_continuous: bool; new EventDisposition::TransformScroll(ScrollTransform) variant added. Additive and non-breaking.
crates/openlogi-hook/src/linux.rs scroll() sets is_continuous: false; device_thread disposition broadened to !matches!(Suppress) so TransformScroll passes through correctly on Linux. Tests updated.
crates/openlogi-hook/src/macos.rs transform_scroll_event modifies CGEvent fields in-place. transform_integer_scroll_field always receives tactility=0, leaving the quantize branch dead code (P2).
crates/openlogi-hook/src/tests.rs Scroll event literal updated to include is_continuous: false. Trivial maintenance change.
crates/openlogi-hook/src/windows.rs Both scroll constructors gain is_continuous: false. LLMHF_INJECTED guard confirmed, preventing synthetic scroll re-capture.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant HW as Physical Mouse
    participant OS as OS Hook (WH_MOUSE_LL / CGEventTap / evdev)
    participant CB as hook callback (mouse_proc / tap_callback / device_thread)
    participant HR as hook_runtime (EventDisposition)
    participant macOS as macos::transform_scroll_event
    participant Post as binding::post_scroll_delta

    HW->>OS: Raw scroll event
    OS->>CB: Invoke hook callback
    CB->>CB: "Translate to MouseEvent::Scroll{delta_x, delta_y, is_continuous}"
    CB->>HR: invoke user callback(event)
    
    alt is_continuous (trackpad) OR default settings
        HR-->>CB: EventDisposition::PassThrough
        CB->>OS: CallNextHook / Keep / forward
    else macOS discrete wheel
        HR-->>CB: "EventDisposition::TransformScroll(ScrollTransform{inverted, strength, tactility})"
        CB->>macOS: transform_scroll_event(cg_event, transform)
        macOS->>macOS: modify DELTA_AXIS fields in-place
        macOS-->>CB: modified CGEvent
        CB->>OS: CallbackResult::Keep (OS delivers modified event)
    else non-macOS (Windows / Linux)
        HR->>HR: transform_scroll(delta_x, delta_y, settings) → (i32, i32)
        HR-->>CB: EventDisposition::Suppress
        CB->>OS: Block original event
        HR->>Post: post_scroll_delta(v, h)
        Post->>OS: Inject new synthetic scroll (LLMHF_INJECTED / uinput write)
        OS->>CB: Re-deliver synthetic event
        CB->>CB: LLMHF_INJECTED flag / different device node → return None
        CB->>OS: PassThrough (synthetic event flows to apps)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant HW as Physical Mouse
    participant OS as OS Hook (WH_MOUSE_LL / CGEventTap / evdev)
    participant CB as hook callback (mouse_proc / tap_callback / device_thread)
    participant HR as hook_runtime (EventDisposition)
    participant macOS as macos::transform_scroll_event
    participant Post as binding::post_scroll_delta

    HW->>OS: Raw scroll event
    OS->>CB: Invoke hook callback
    CB->>CB: "Translate to MouseEvent::Scroll{delta_x, delta_y, is_continuous}"
    CB->>HR: invoke user callback(event)
    
    alt is_continuous (trackpad) OR default settings
        HR-->>CB: EventDisposition::PassThrough
        CB->>OS: CallNextHook / Keep / forward
    else macOS discrete wheel
        HR-->>CB: "EventDisposition::TransformScroll(ScrollTransform{inverted, strength, tactility})"
        CB->>macOS: transform_scroll_event(cg_event, transform)
        macOS->>macOS: modify DELTA_AXIS fields in-place
        macOS-->>CB: modified CGEvent
        CB->>OS: CallbackResult::Keep (OS delivers modified event)
    else non-macOS (Windows / Linux)
        HR->>HR: transform_scroll(delta_x, delta_y, settings) → (i32, i32)
        HR-->>CB: EventDisposition::Suppress
        CB->>OS: Block original event
        HR->>Post: post_scroll_delta(v, h)
        Post->>OS: Inject new synthetic scroll (LLMHF_INJECTED / uinput write)
        OS->>CB: Re-deliver synthetic event
        CB->>CB: LLMHF_INJECTED flag / different device node → return None
        CB->>OS: PassThrough (synthetic event flows to apps)
    end
Loading

Reviews (9): Last reviewed commit: "Merge branch 'master' into proposal/whee..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/hook_runtime.rs Outdated
Comment thread crates/openlogi-core/src/binding.rs Outdated
@klovinad klovinad force-pushed the proposal/wheel-scroll-settings branch from 86fdb0d to 183eb59 Compare June 14, 2026 12:57

@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 this — invert-scroll is clearly in demand (#126 plus the comments here). Before merging I want to flag some concerns with the current approach, because the suppress-and-reinject design has a few real problems on macOS, and one of them is that it doesn't actually solve #126 as written.

1. It doesn't solve #126 (per-device inversion)

#126 specifically asks to keep the trackpad on natural scrolling and invert only the mouse. The hook taps at CGEventTapLocation::HID (crates/openlogi-hook/src/macos.rs:257) and captures every ScrollWheel event regardless of source, and transform_scroll never looks at kCGScrollWheelEventIsContinuous. So enabling invert flips the trackpad too — exactly the native-setting behavior #126 wants to avoid. We shouldn't close #126 with this as-is.

2. Modifier flags are dropped

post_scroll_delta builds a fresh CGEvent::new_scroll_event(...) and never copies the original event's flags. Modifier+scroll gestures that apps read off the event flags (zoom, etc.) will stop working while non-default settings are active. (System-level Ctrl+scroll screen zoom reads live key state and may be unaffected — worth verifying on-device.)

3. Momentum / pixel precision are lost

We suppress the original and re-emit ScrollEventUnit::LINE from the rounded AXIS_1/AXIS_2 line deltas. That drops scroll-phase/momentum and ignores the pixel POINT_DELTA_* fields, so continuous input (trackpad, MX free-spin high-res wheel) becomes coarse. Micro-deltas round to 0 → PassThrough, so on a free-spinning wheel small movements aren't inverted while fast ones are — inconsistent. Given our primary devices are MX-class, this matters.

4. Per-event cost on the hottest path

The macOS tap must stay lock-light or it stalls the whole input stream (see the comments in macos.rs). This adds an RwLock read per scroll event and, when active, a fresh CGEventSource::new + CGEvent allocation per event. On a high-rate free-spin stream that risks TapDisabledByTimeout / scroll stutter.

Suggested approach

Instead of suppress + reinject, mutate the event in place and return Keep. We already do in-place field writes elsewhere (crates/openlogi-core/src/binding.rs:1312,1327), and the same set_*_value_field works on the callback's &CGEvent. Negate/scale AXIS_1/AXIS_2 plus the POINT_DELTA_* and fixed-point fields and keep the event — that preserves momentum, pixel precision, flags, and continuity, with zero allocation and no re-entry concern. For #126, branch on kCGScrollWheelEventIsContinuous to apply inversion to discrete (mouse) scroll only. This is essentially how Scroll Reverser does it.

Unrelated change

The default gesture remap (PrevTab/NextTabPreviousDesktop/NextDesktop) is a separate UX decision — please split it into its own PR so each can be reviewed/reverted independently.

The config plumbing, clamping, and default fast-path are clean; the concern is purely the macOS transform path. Happy to help iterate on the in-place version.

@klovinad klovinad requested a review from AprilNEA June 16, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants