feat(scroll): per-device inverted scrolling#294
Conversation
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 SummaryThis 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.
Confidence Score: 5/5Safe 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
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
%%{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
Reviews (2): Last reviewed commit: "feat(scroll): implement Windows wheel in..." | Re-trigger Greptile |
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.
|
Addressed the P1 in fdd916e — Windows now actually inverts instead of silently no-op'ing.
|
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
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
invert_scrollflag onDeviceConfigwith accessors; omitted from
config.tomlwhen false.MouseEvent::Scrollgainsis_continuous(a trackpad/Magic-Mouse gesture vs. a discrete wheel detent), anda new
EventDisposition::ReplaceScrollthat re-emits the wheel with transformeddeltas. Each platform's hook owns its own re-emission:
CGEventin place and scales its smooth-scroll companionfields (fixed-point + pixel deltas), so pixel-precise apps invert too — not just
line-based ones.
SendInput; the hook already dropsLLMHF_INJECTEDevents, so the synthetic tick can't loop back.
flag into a shared
AtomicBool; the hook runtime inverts only a discrete wheeland passes continuous trackpad scrolling through untouched.
(pure config — no hardware read). New strings translated across all 20 locales.
Known limitation (documented in code)
The macOS
CGEventTapreads the merged HID stream and can't attribute a scroll to aphysical device, so the flag applies to the selected device — the same
active-device compromise as
thumbwheel_sensitivity. The trackpad-vs-wheel split isdone 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— cleanwire_format— no IPC protocol bump, the flag travels via config +
ReloadConfig), andopenlogi-gui (incl. the i18n ordered-key parity test)