Skip to content

Fluid-Zoom - #5028

Open
gauravrocks009 wants to merge 5 commits into
ramensoftware:mainfrom
gauravrocks009:main
Open

Fluid-Zoom#5028
gauravrocks009 wants to merge 5 commits into
ramensoftware:mainfrom
gauravrocks009:main

Conversation

@gauravrocks009

Copy link
Copy Markdown

Summary

Adds a new Windhawk mod called Fluid Zoom.

Features

  • Smooth fullscreen zoom
  • Alt + Mouse Wheel support
  • Native touchpad pinch support
  • Cursor-centered zoom
  • Fully interactive while zoomed
  • Configurable zoom speed and responsiveness
  • Optional bitmap smoothing
  • Integer zoom mode

GitHub repository:
https://github.com/gauravrocks009/Windhawk-Fluid-Zoom

Changelog

N/A (new mod)

Mod authorship

  • The submitter, with AI assistance

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 6, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

@gauravrocks009

Copy link
Copy Markdown
Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 6, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Nice idea, and the README/GIF are good. The mod doesn't hook anything though, so it shouldn't be living inside Explorer, and the unload path can crash the shell. Details below.

1. This should be a tool mod, not an explorer.exe injection.

The mod installs no function hooks at all — it only uses SetWindowsHookEx (WH_KEYBOARD_LL/WH_MOUSE_LL), its own overlay window, and the Magnification API, all of which work system-wide from any process. That's the textbook case for the Mods as tools pattern, and the wiki page names exactly the two problems this submission has:

  • There can be more than one explorer.exe (e.g. "launch folder windows in a separate process", or a crashed/relaunched shell). Each instance would call MagInitialize, create its own fullscreen overlay, and install its own pair of low-level hooks — they'd fight over one global magnification transform.
  • Any instability here takes the whole shell down with it, and this mod installs global input hooks and a fullscreen input-capturing window.

Switch to @include windhawk.exe, rename Wh_ModInit/Wh_ModSettingsChanged/Wh_ModUninit to WhTool_*, and paste the launcher snippet from the wiki verbatim. mods/mac-magnifying-cursor.wh.cpp is a very close structural match (tool mod + worker thread + message loop + layered overlay + low-level input) and is worth reading end to end. One thing to copy from it: in windhawk.exe you don't get Explorer's per-monitor DPI awareness for free, so set it explicitly on your thread like it does at L409-L414, otherwise GetCursorPos/GetSystemMetrics will be virtualized and the zoom offsets will be wrong on high-DPI setups.

2. TerminateThread in Wh_ModUninit can crash Explorer.

if (WaitForSingleObject(g_hThread, 3000) == WAIT_TIMEOUT)
    TerminateThread(g_hThread, 0);

If that path is taken, none of the thread's cleanup runs: the two low-level hooks stay installed with hook procedures pointing into the mod image, the overlay window stays alive with a WndProc in the mod image, the window class stays registered, and MagUninitialize is never called. Windhawk FreeLibrarys the mod right after Wh_ModUninit returns, so the very next keystroke or mouse movement calls into unmapped memory — an Explorer crash, system-wide. The screen also stays magnified with no way to reset it. On top of that, TerminateThread can kill the thread while it holds the loader lock (it calls LoadLibrary/FreeLibrary) or a user32/heap lock, deadlocking the process.

TerminateThread is never an acceptable fallback here — make the quit signal reliable and then wait unconditionally. See mac-magnifying-cursor.wh.cpp#L486-L508: a ready-event the uninit waits on first, PostMessage(hwnd, WM_CLOSE, ...), then WaitForSingleObject(hThread, INFINITE). Also note Wh_ModUninit can run on Explorer's UI thread, so the current 3-second wait hangs the shell UI for up to 3 seconds.

3. PostThreadMessage can send WM_QUIT to an unrelated Explorer thread.

ZoomThread returns early (return 1) when LoadMagnificationAPI() or MagInitialize fails, but g_dwThreadId is never cleared. Thread IDs are recycled, and Explorer creates threads constantly — so a later Wh_ModUninit/Wh_ModSettingsChanged posts WM_QUIT to whatever thread now owns that ID, silently killing an unrelated message loop in the shell. There's a second, related race even on the success path: PostThreadMessage fails with ERROR_INVALID_THREAD_ID if the thread hasn't created its message queue yet (a quick enable→disable, or a settings change right after load), which then falls straight into the TerminateThread branch above.

Both go away with the ready-event + PostMessage to the window pattern from item 2. Also, Wh_ModInit currently returns TRUE whenever CreateThread succeeds, so if the Magnification API fails to load the mod silently does nothing — return FALSE (or at least Wh_Log it) instead.

4. The registry write is a persistent system change.

RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\ScreenMagnifier", ...);
RegSetValueExW(hk, L"UseBitmapSmoothing", ...);

That's the Windows Magnifier's own user setting. The mod overwrites it on every load and on every settings change, and never restores it — so disabling the mod leaves the user's Magnifier configuration permanently changed. Mods must be fully reversible and must not write to arbitrary registry locations; use Wh_SetIntValue/Wh_SetStringValue for the mod's own state, and hook reads when you need to influence what the system reads.

It's also redundant: MagSetFullscreenUseBitmapSmoothing is already called with the same value in ZoomThread. Just delete SetBitmapSmoothing and both of its call sites.

5. Window class: unchecked registration + cleanup only on the happy path.

wc.hInstance = GetModuleHandle(NULL);
wc.lpszClassName = L"ScreenZoom_Overlay_v21";
RegisterClassW(&wc);          // return value ignored

A class registered by the mod is not unregistered when the mod DLL unloads — and here UnregisterClassW only runs at the end of ZoomThread, which is skipped entirely on the TerminateThread path. On the next load RegisterClassW fails with ERROR_CLASS_ALREADY_EXISTS, the failure is ignored, and CreateWindowExW happily binds to the stale class whose lpfnWndProc still points into the previous, now-unmapped mod image → crash or worse on the first message. The _v21 suffix in the class name suggests you may already have run into this.

Fix: check the RegisterClassW result and bail out on failure (see mac-magnifying-cursor.wh.cpp#L424-L427), and make sure UnregisterClass always runs before the mod can be unloaded. Also, GetModuleHandle(NULL) is explorer.exe, not this mod — register/create/unregister with the mod's own module handle instead; audio-scroll-switcher.wh.cpp#L125-L134 has the small helper for it. Same for CreateWindowExW's hInstance and for the SetWindowsHookExW module argument.

6. Magnification calls inside the low-level mouse hook.

LLMouseProcApplyZoomDeltaAnimTickMagSetFullscreenTransform. Low-level hook callbacks run synchronously and block all system input until they return; a round trip into the magnification/compositor stack on every wheel event is exactly the kind of work that makes Windows exceed LowLevelHooksTimeout and silently drop your hook (at which point the mod stops responding to input entirely, with no error).

The hook already runs on the same thread that owns the overlay, so the fix is cheap: in the hook, only update g_zTgt and kick the timer (or PostMessage to the overlay), and let the WM_TIMER handler do the MagSetFullscreenTransform. Drop the AnimTick() call at the end of ApplyZoomDelta.

7. The fullscreen overlay swallows a lot of normal input while the modifier is held.

Covering the whole virtual desktop with a topmost input-capturing window whenever Alt (or Shift) is down has broad fallout:

  • Double-clicks are downgraded: WM_LBUTTONDBLCLK injects a single MOUSEEVENTF_LEFTDOWN, so Alt+double-click never reaches the app as a double-click.
  • WM_XBUTTONDOWN/WM_XBUTTONUP (mouse back/forward) and WM_MBUTTONDBLCLK aren't forwarded at all — they're silently eaten.
  • Hover state, tooltips and the app's cursor are lost while the modifier is held; wc.hCursor = IDC_ARROW forces an arrow over everything, e.g. over text in an editor.
  • Alt+drag / Alt+click gestures (window managers, Blender, Photoshop, etc.) get mangled by the hide→SendInput→re-show-after-200ms dance, and the 200 ms re-show timer can re-cover the screen mid-interaction.
  • With shift selected as the modifier this is much worse — Shift is held while typing any capital letter, and Shift+click is range-selection in Explorer and every list control.

Given that precision-touchpad two-finger scroll doesn't reach WH_MOUSE_LL, I understand why the overlay exists, but it shouldn't be the default cost for mouse users. Consider gating it behind a setting (e.g. "touchpad scroll support"), and at minimum forward the missing button messages and re-synthesize double-clicks.

Separately, the overlay is created full-screen, topmost and WS_EX_LAYERED, and ShowWindown for the entire lifetime of the mod even when idle. A permanently present fullscreen topmost layered window is known to interfere with exclusive-fullscreen games and fullscreen optimizations. Creating it (or sizing it to the screen) only while the modifier is held would avoid that.

8. The overlay can get stuck covering the screen.

g_bModHeld is only cleared by seeing the key-up in LLKbdProc. That key-up is missed if another low-level keyboard hook earlier in the chain consumes it, if the key is released on the secure desktop (UAC prompt), if Windows times out and removes your hook, or if the mod is loaded while the key is already down. The result is an invisible, screen-covering window that eats every click with no way for the user to recover except disabling the mod. Add a safety net — e.g. re-check GetAsyncKeyState(g_iModVK) in the animation timer / on mouse events and hide the overlay when the key isn't actually down.

9. g_bInjecting never does anything.

g_bInjecting = true;
SendInput(1, &in, sizeof(INPUT));
g_bInjecting = false;

SendInput queues the event; the low-level hook procedure is only invoked when this thread next pumps messages, which is long after g_bInjecting has been reset — so LLMouseProc's guard never fires. Check the real flag instead: m->flags & LLMHF_INJECTED in LLMouseProc (and LLKHF_INJECTED, which you already use for Ctrl). Note the re-entrancy the flag was meant to prevent lives in OverlayWndProc, not in the hook, so the guard wouldn't have helped there anyway — the injected click can come straight back to the overlay if the alpha change hasn't taken effect yet or if the 200 ms re-show timer fires in between.

10. Pinch-to-zoom is hijacked system-wide with no way to opt out.

Once g_bPinchCtrl is set, LLMouseProc consumes the wheel event, so touchpad pinch-to-zoom stops working in every app that implements it — browsers, Photos, Maps, PDF viewers. That's a significant behavior change that isn't mentioned in the README and can't be turned off. Please add a setting for it (defaulting either way is fine) and document it. Also note g_bPinchCtrl is set by any injected Ctrl key-down, not just the touchpad's — remote desktop, the on-screen keyboard, AutoHotkey scripts and other automation will all turn ordinary wheel scrolling into zooming.

11. smoothEdges and integerSnap should be boolean settings.

Both are on/off flags declared as numbers, with $descriptions that have to tell the user "Must be exactly 0 or 1". Declaring them as false makes Windhawk render a checkbox (still read with Wh_GetIntSetting), removes the invalid-input problem, and lets the descriptions just describe the feature:

- smoothEdges: false
  $name: Smooth edges
  $description: Sharp/pixelated (best for coding) when off, soft/blurry (best for media) when on.
- integerSnap: false
  $name: Integer zoom only
  $description: Snap the zoom level to whole multipliers (2x, 3x, 4x...).

That also removes the current inconsistency in LoadSettings, where an out-of-range smoothEdges falls back to 1 while the declared default is 0.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • LoadLibraryW(L"Magnification.dll") uses the default search order, which includes the executable's directory. Magnification.dll isn't a KnownDLL, so this is a DLL-hijacking pattern. The exposure is negligible while the mod only runs in explorer.exe (or windhawk.exe), but it costs nothing to use LoadLibraryExW(L"Magnification.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32). Plenty of examples in the repo, e.g. app-theme-crash-fixer.wh.cpp#L271.
  • LoadSettings uses a raw Wh_GetStringSetting + Wh_FreeStringSetting; WindhawkUtils::StringSetting::make(L"modifierKey") is RAII and shorter. The if (k) check can go too — Wh_GetStringSetting never returns NULL, it returns L"".
  • Out-of-range numeric settings silently revert to the hardcoded default (maxZoom: 100 → 20, not 50). Clamping to the valid range would be less surprising.
  • volatile bool doesn't provide atomicity or ordering; use std::atomic<bool> for the flags shared between the hook procs and the window proc (<atomic>).
  • Wh_ModSettingsChanged calls LoadSettings() and SetBitmapSmoothing() before stopping the old thread, so the old thread reads the globals while they're being rewritten. Stop the thread first, then load settings, then start the new one. It also ignores the CreateThread result.
  • #include <math.h><cmath>, and abs() on the POINT deltas relies on a transitive include — <cstdlib> with std::abs (or just std::abs from <cmath>) is more predictable.
  • wc.hbrBackground is NULL and there's no WM_ERASEBKGND/WM_PAINT handler, so the layered surface content is undefined. It's imperceptible at alpha 1, but setting a brush (or handling the message) removes the ambiguity.
  • There's no Wh_Log call anywhere in the mod. A few at the failure points (Magnification API load, RegisterClassW, CreateWindowExW, SetWindowsHookExW) would make user bug reports far easier to act on, and cost nothing when logging is off.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The pan clamp is wrong on multi-monitor setups. In AnimTick, the valid offset range for a viewport of width vw at zoom z is [vx, vx + vw*f] (with f = 1 - 1/z), but the code computes [vx*f, (vx+vw)*f]. Those coincide only when vx == 0, i.e. a single monitor or the primary at the virtual origin. With a monitor to the left of the primary (vx = -1920, z = 2) the minimum becomes -960 instead of -1920, so you can't pan to the left third of the desktop, while the maximum becomes 960 instead of 0, so you can pan past the right edge. Same for Y. Also worth verifying what MagSetFullscreenTransform actually magnifies on a multi-monitor setup — its offsets are documented as relative to the primary monitor's top-left corner.
  • The redraw dead-zone grows with zoom, which is backwards. int th = (int)(g_zCur * 2.5f) means at 20x the view only follows the cursor in 50-physical-pixel steps — which is 1000 magnified pixels, i.e. very jumpy exactly where smoothness matters most. The threshold should shrink as zoom increases (e.g. max(1, 2.5f / g_zCur)), or just drop it and rely on the 16 ms timer.
  • Small wheel deltas are multiplied rather than accumulated. if (ad > 0 && ad < 120) n *= 2.5f; is a magic fudge that also hits high-resolution mice (Logitech MX and friends split one notch into many small deltas), making them zoom ~2.5x faster than a standard wheel. Accumulating the remainder until a full WHEEL_DELTA is crossed is the usual fix — see the comment and state at audio-scroll-switcher.wh.cpp#L118-L124.
  • The lerp is frame-count based, not time based. g_zCur += diff * g_fLerp per WM_TIMER tick means the animation duration depends on how reliably the 16 ms timer is delivered; under load the zoom visibly slows down. Scaling by the actual elapsed time (GetTickCount64 delta) makes it consistent.
  • Horizontal wheel zooms too. Both LLMouseProc and OverlayWndProc treat 0x020E (WM_MOUSEHWHEEL) the same as WM_MOUSEWHEEL, so modifier + horizontal scroll zooms. With Shift as the modifier this is especially confusing, since Shift+wheel is horizontal scrolling in most apps.
  • The animation timer stays alive at 16 ms for as long as the zoom is above 1x (mouse moves keep restarting it). That's inherent to cursor-following and fine, just worth knowing that the mod is doing 60 wakeups/second the whole time the screen is zoomed.
  • Escape resets the zoom but isn't consumed, so it also reaches the focused app. That's probably what you want — worth a line in the README either way.
  • The fullscreen magnification transform is global system state shared with the built-in Windows Magnifier; running both at once will produce conflicting results. Not much to do about it, just something to mention in the README.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-author The author's turn: request an AI review, or respond to one that was posted.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant