Skip to content

feat(scroll): per-device inverted scrolling#294

Merged
AprilNEA merged 2 commits into
masterfrom
feat/invert-scroll
Jun 21, 2026
Merged

feat(scroll): per-device inverted scrolling#294
AprilNEA merged 2 commits into
masterfrom
feat/invert-scroll

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an opt-in, per-device "Invert scroll direction" toggle. A user can keep
macOS natural scrolling on the trackpad while running a traditional (reversed)
wheel on the mouse — which the native setting can't do.

Closes #126.

Why the native option isn't enough

macOS scroll direction is a single global default (com.apple.swipescrolldirection);
the Mouse and Trackpad panes both reflect that same value. So "natural trackpad

  • reversed mouse" is impossible natively — exactly the case [Feature]: Inverted Scrolling Support #126 describes, and the
    reason tools like Scroll Reverser / LinearMouse exist. The toggle here inverts
    relative to whatever the OS produces, so it composes correctly with the global
    setting.

Design — layered so each crate keeps its own job

  • openlogi-core (data model): a per-device invert_scroll flag on DeviceConfig
    with accessors; omitted from config.toml when false.
  • openlogi-hook (pure OS mechanism, no policy): MouseEvent::Scroll gains
    is_continuous (a trackpad/Magic-Mouse gesture vs. a discrete wheel detent), and
    a new EventDisposition::ReplaceScroll that re-emits the wheel with transformed
    deltas. Each platform's hook owns its own re-emission:
    • macOS mutates the CGEvent in place and scales its smooth-scroll companion
      fields (fixed-point + pixel deltas), so pixel-precise apps invert too — not just
      line-based ones.
    • Linux re-injects the flipped tick via uinput.
    • Windows re-injects via SendInput; the hook already drops LLMHF_INJECTED
      events, so the synthetic tick can't loop back.
  • openlogi-agent-core (policy): the orchestrator publishes the active device's
    flag into a shared AtomicBool; the hook runtime inverts only a discrete wheel
    and passes continuous trackpad scrolling through untouched.
  • openlogi-gui: a "Scrolling" card in the Pointer tab with an On/Off toggle
    (pure config — no hardware read). New strings translated across all 20 locales.

Known limitation (documented in code)

The macOS CGEventTap reads the merged HID stream and can't attribute a scroll to a
physical device, so the flag applies to the selected device — the same
active-device compromise as thumbwheel_sensitivity. The trackpad-vs-wheel split is
done via is_continuous, which is what makes #126's actual scenario work. On Linux
(thread-per-device evdev) true per-device is structurally possible later.

Verification

  • cargo clippy --workspace --all-targets -- -D warnings — clean (macOS)
  • cargo clippy --target x86_64-pc-windows-gnu -p openlogi-hook -p openlogi-inject -p openlogi-agent-core --all-targets -- -D warnings — clean (Windows code cross-linted)
  • cargo fmt --all --check — clean
  • Tests pass: openlogi-core, openlogi-hook, openlogi-agent-core (incl. wire_format
    no IPC protocol bump, the flag travels via config + ReloadConfig), and
    openlogi-gui (incl. the i18n ordered-key parity test)

Add an opt-in, per-device "Invert scroll direction" toggle so a user can keep
macOS natural scrolling on the trackpad while running a traditional (reversed)
wheel on the mouse.

Layered so each crate keeps its own job:

- openlogi-core: a per-device invert_scroll config flag (+ accessors), omitted
  from config.toml when false.
- openlogi-hook (pure OS mechanism): MouseEvent::Scroll gains is_continuous
  (trackpad gesture vs. discrete wheel) and a new EventDisposition::ReplaceScroll
  that rewrites the live event's deltas. macOS scales the smooth-scroll companion
  fields too, so pixel-precise apps invert as well; Linux re-injects the flipped
  tick via uinput. Windows carries the API but defers re-injection.
- openlogi-agent-core (policy): the orchestrator publishes the active device's
  flag into a shared atomic; the hook runtime inverts only a discrete wheel and
  leaves continuous trackpad scrolling untouched.
- openlogi-gui: a Scrolling card in the Pointer tab, with the new strings
  translated across all 20 locales.

The macOS tap reads the merged HID stream and can't attribute a scroll to a
device, so the setting applies to the selected device (the same compromise as
thumbwheel_sensitivity).
@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a per-device "Invert scroll direction" toggle that lets users reverse a mouse's discrete scroll wheel while leaving trackpad natural-scrolling untouched — working around the macOS single-global-setting limitation described in issue #126. The implementation is layered cleanly across four crates: config model, OS hooks, orchestrator, and GUI.

  • openlogi-core: DeviceConfig.invert_scroll bool, skipped in TOML when false; round-trip and omission tests cover it.
  • openlogi-hook: MouseEvent::Scroll gains is_continuous; new EventDisposition::ReplaceScroll drives platform-specific re-emission — macOS mutates all three CGEvent delta fields in-place, Linux re-injects via uinput, Windows drops the original and re-injects via SendInput (LLMHF_INJECTED prevents loop-back).
  • openlogi-agent-core/gui: Arc<AtomicBool> shared between orchestrator and hook runtime carries the active-device flag; the GUI adds a "Scrolling" card with an On/Off pill, and all 20 locale files are updated.

Confidence Score: 5/5

Safe to merge — all three platform scroll-inversion paths are implemented and guarded correctly, with no loop-back or data-loss risk.

The Windows injection path (previously unimplemented) is now complete with LLMHF_INJECTED loop-back prevention. macOS mutates all three CGEvent delta fields so pixel-precise apps invert correctly. Linux reinjects through uinput with proper hi-res scaling. Config round-trips cleanly and the AtomicBool handoff from orchestrator to hook runtime follows the established thumbwheel_sensitivity pattern. No correctness issues were found across the changed paths.

No files require special attention.

Important Files Changed

Filename Overview
crates/openlogi-hook/src/lib.rs Adds is_continuous to MouseEvent::Scroll and ReplaceScroll variant to EventDisposition; Eq is no longer derived because f32 fields are not Eq — this is a breaking change to the public API surface noted in a previous review comment.
crates/openlogi-hook/src/macos.rs Adds rewrite_scroll/rewrite_scroll_axis that mutates all three CGEvent delta fields (line, fixed-point, pixel) in place; correctly scales by new_line/old_line and skips the division when old_line == 0.
crates/openlogi-hook/src/windows.rs Adds inject_scroll via SendInput for ReplaceScroll; correctly suppresses original event and lets LLMHF_INJECTED filter prevent loop-back. The i32→u32 bit-cast for mouseData is idiomatic for Win32 signed-delta-in-DWORD fields.
crates/openlogi-hook/src/linux.rs reinject_scroll correctly inverts the inverse of translate for all four axis codes (REL_WHEEL, REL_WHEEL_HI_RES, REL_HWHEEL, REL_HWHEEL_HI_RES), re-scaling hi-res values by HIRES_UNITS_PER_TICK as the forward direction does.
crates/openlogi-agent-core/src/hook_runtime.rs Adds invert_scroll AtomicBool parameter; correctly gates inversion on !is_continuous && delta_y != 0.0, matching the discrete-wheel-only, vertical-only spec.
crates/openlogi-agent-core/src/orchestrator.rs Seeds invert_scroll AtomicBool in SharedRuntime and refreshes it on rebuild; uses Relaxed ordering, consistent with the existing thumbwheel_sensitivity pattern.
crates/openlogi-core/src/config.rs Adds invert_scroll bool to DeviceConfig with skip_serializing_if = is_false; round-trip and omission tests verify correct TOML behaviour.
crates/openlogi-gui/src/app.rs Adds scrolling_card with invert_scroll_toggle to the Pointer tab; reads state via AppState::current_invert_scroll and commits via commit_invert_scroll, following the existing immediate-mode pattern.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[OS delivers scroll event] --> B{is_continuous?}
    B -- yes / trackpad --> C[PassThrough — no change]
    B -- no / mouse wheel --> D{invert_scroll AtomicBool?}
    D -- false --> C
    D -- true --> E{delta_y != 0?}
    E -- no / horizontal only --> C
    E -- yes --> F[EventDisposition::ReplaceScroll\ndelta_x unchanged, delta_y negated]
    F --> G{Platform}
    G -- macOS --> H[rewrite_scroll: mutate CGEvent\nline + fixed-point + pixel fields\nCallbackResult::Keep]
    G -- Linux --> I[reinject_scroll: replace evdev\nevent value via uinput]
    G -- Windows --> J[inject_scroll via SendInput\noriginal suppressed\nLLMHF_INJECTED prevents loop-back]
    H --> K[Inverted event reaches app]
    I --> K
    J --> K
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"}}}%%
flowchart TD
    A[OS delivers scroll event] --> B{is_continuous?}
    B -- yes / trackpad --> C[PassThrough — no change]
    B -- no / mouse wheel --> D{invert_scroll AtomicBool?}
    D -- false --> C
    D -- true --> E{delta_y != 0?}
    E -- no / horizontal only --> C
    E -- yes --> F[EventDisposition::ReplaceScroll\ndelta_x unchanged, delta_y negated]
    F --> G{Platform}
    G -- macOS --> H[rewrite_scroll: mutate CGEvent\nline + fixed-point + pixel fields\nCallbackResult::Keep]
    G -- Linux --> I[reinject_scroll: replace evdev\nevent value via uinput]
    G -- Windows --> J[inject_scroll via SendInput\noriginal suppressed\nLLMHF_INJECTED prevents loop-back]
    H --> K[Inverted event reaches app]
    I --> K
    J --> K
Loading

Reviews (2): Last reviewed commit: "feat(scroll): implement Windows wheel in..." | Re-trigger Greptile

Comment thread crates/openlogi-hook/src/windows.rs Outdated
Addresses Greptile's P1 on #294: the Windows hook accepted ReplaceScroll but
treated it like PassThrough, so enabling "Invert scroll direction" silently did
nothing (toggle flips, config persists, agent reloads, but the wheel is
unchanged).

The WH_MOUSE_LL hook already drops LLMHF_INJECTED events in translate_event, so
re-injecting the flipped tick via SendInput can't loop back through the hook.
The dispatch now suppresses the original wheel and re-injects a transformed one,
making Windows symmetric with Linux (uinput re-injection) and macOS (in-place
mutation). Negation round-trips exactly through the wheel-tick units.

Verified with an x86_64-pc-windows-gnu cross-lint (cargo clippy --target,
-D warnings) plus a unit test on the mouseData reconstruction.
@AprilNEA

Copy link
Copy Markdown
Owner Author

Addressed the P1 in fdd916e — Windows now actually inverts instead of silently no-op'ing.

translate_event already drops LLMHF_INJECTED events, so re-injecting the flipped tick via SendInput can't loop back through the WH_MOUSE_LL hook. The dispatch now suppresses the original wheel and re-injects a transformed one, making Windows symmetric with Linux (uinput) and macOS (in-place CGEvent mutation). Verified with an x86_64-pc-windows-gnu cross-lint (-D warnings) plus a unit test on the mouseData reconstruction (one notch → 120, inverted → −120, exact negation).

@AprilNEA AprilNEA merged commit c91f17f into master Jun 21, 2026
8 checks passed
@AprilNEA AprilNEA deleted the feat/invert-scroll branch June 21, 2026 06:41
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.

[Feature]: Inverted Scrolling Support

1 participant