-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix(windows): capture mouse clicks so auto-zoom works #972
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const { recordCursorMouseDown, recordCursorMouseUp } = vi.hoisted(() => ({ | ||
| recordCursorMouseDown: vi.fn(), | ||
| recordCursorMouseUp: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("./interaction", () => ({ | ||
| recordCursorMouseDown, | ||
| recordCursorMouseUp, | ||
| })); | ||
|
|
||
| vi.mock("electron", () => ({ | ||
| BrowserWindow: { | ||
| getAllWindows: () => [], | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock("../paths/binaries", () => ({ | ||
| ensureNativeCursorMonitorBinary: vi.fn(), | ||
| getCursorMonitorExePath: vi.fn(() => "/tmp/cursor-monitor.exe"), | ||
| })); | ||
|
|
||
| import { setNativeCursorMonitorOutputBuffer } from "../state"; | ||
| import { handleCursorMonitorStdout } from "./monitor"; | ||
|
|
||
| /** | ||
| * The native cursor-monitor helper is the click source on Windows: the global | ||
| * uiohook hook gets silently unhooked by Windows while the recorder's main | ||
| * process is busy, so these `INTERACTION:` lines are what keeps click telemetry | ||
| * alive. They are the contract between main.cpp and the main process. | ||
| */ | ||
| describe("handleCursorMonitorStdout interaction protocol", () => { | ||
| beforeEach(() => { | ||
| recordCursorMouseDown.mockClear(); | ||
| recordCursorMouseUp.mockClear(); | ||
| setNativeCursorMonitorOutputBuffer(""); | ||
| }); | ||
|
|
||
| it("records a left click from INTERACTION:mousedown:1", () => { | ||
| handleCursorMonitorStdout(Buffer.from("INTERACTION:mousedown:1\n")); | ||
|
|
||
| expect(recordCursorMouseDown).toHaveBeenCalledTimes(1); | ||
| expect(recordCursorMouseDown).toHaveBeenCalledWith(1); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ["2", 2], | ||
| ["3", 3], | ||
| ])("maps button %s to recordCursorMouseDown(%i)", (reported, expected) => { | ||
| handleCursorMonitorStdout(Buffer.from(`INTERACTION:mousedown:${reported}\n`)); | ||
|
|
||
| expect(recordCursorMouseDown).toHaveBeenCalledWith(expected); | ||
| }); | ||
|
|
||
| it("defaults to the left button when no button is reported", () => { | ||
| handleCursorMonitorStdout(Buffer.from("INTERACTION:mousedown\n")); | ||
|
|
||
| expect(recordCursorMouseDown).toHaveBeenCalledWith(1); | ||
| }); | ||
|
|
||
| it("records mouse releases from INTERACTION:mouseup", () => { | ||
| handleCursorMonitorStdout(Buffer.from("INTERACTION:mouseup\n")); | ||
|
|
||
| expect(recordCursorMouseUp).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("reassembles an interaction line split across two stdout chunks", () => { | ||
| handleCursorMonitorStdout(Buffer.from("INTERACTION:mouse")); | ||
| expect(recordCursorMouseDown).not.toHaveBeenCalled(); | ||
|
|
||
| handleCursorMonitorStdout(Buffer.from("down:1\n")); | ||
| expect(recordCursorMouseDown).toHaveBeenCalledWith(1); | ||
| }); | ||
|
|
||
| it("keeps handling cursor STATE lines interleaved with interactions", () => { | ||
| handleCursorMonitorStdout( | ||
| Buffer.from("STATE:pointer\nINTERACTION:mousedown:1\nINTERACTION:mouseup\n"), | ||
| ); | ||
|
|
||
| expect(recordCursorMouseDown).toHaveBeenCalledWith(1); | ||
| expect(recordCursorMouseUp).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("ignores malformed interaction lines", () => { | ||
| handleCursorMonitorStdout(Buffer.from("INTERACTION:mousedown:9\nINTERACTION:wheel\n")); | ||
|
|
||
| expect(recordCursorMouseDown).not.toHaveBeenCalled(); | ||
| expect(recordCursorMouseUp).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,43 @@ static void stdinListener() { | |
| g_running.store(false); | ||
| } | ||
|
|
||
| namespace { | ||
|
|
||
| struct MouseButtonWatch { | ||
| int virtualKey; | ||
| int reportedButton; // 1 = left, 2 = right, 3 = middle | ||
| bool wasDown; | ||
| }; | ||
|
|
||
| // Polling GetAsyncKeyState instead of installing a WH_MOUSE_LL hook: a low-level | ||
| // hook lives inside the hooking process and Windows silently unhooks it whenever | ||
| // the callback misses LowLevelHooksTimeout, which is exactly what happens while | ||
| // the recorder's main process is busy encoding. This helper is a separate | ||
| // process doing a cheap state read, so it cannot be unhooked. | ||
| bool isButtonDown(int virtualKey) { | ||
| return (GetAsyncKeyState(virtualKey) & 0x8000) != 0; | ||
| } | ||
|
|
||
| void reportMouseButtonEdges(MouseButtonWatch buttons[], size_t count) { | ||
| for (size_t i = 0; i < count; ++i) { | ||
| MouseButtonWatch& button = buttons[i]; | ||
| const bool isDown = isButtonDown(button.virtualKey); | ||
|
|
||
| if (isDown == button.wasDown) { | ||
| continue; | ||
| } | ||
|
|
||
| button.wasDown = isDown; | ||
| if (isDown) { | ||
| std::cout << "INTERACTION:mousedown:" << button.reportedButton << std::endl; | ||
| } else { | ||
| std::cout << "INTERACTION:mouseup" << std::endl; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| int main() { | ||
| std::setvbuf(stdout, nullptr, _IONBF, 0); | ||
|
|
||
|
|
@@ -39,21 +76,39 @@ int main() { | |
|
|
||
| std::string lastType; | ||
|
|
||
| MouseButtonWatch buttons[] = { | ||
| {VK_LBUTTON, 1, false}, | ||
| {VK_RBUTTON, 2, false}, | ||
| {VK_MBUTTON, 3, false}, | ||
| }; | ||
| const size_t buttonCount = sizeof(buttons) / sizeof(buttons[0]); | ||
|
|
||
| // Buttons are sampled every 8ms so short clicks are not missed; the cursor | ||
| // shape only needs the original ~50ms cadence. | ||
| const int buttonPollMs = 8; | ||
| const int cursorPollEvery = 50 / buttonPollMs; | ||
| int tick = 0; | ||
|
|
||
| while (g_running.load()) { | ||
| CURSORINFO ci = {}; | ||
| ci.cbSize = sizeof(ci); | ||
| reportMouseButtonEdges(buttons, buttonCount); | ||
|
|
||
| if (tick == 0) { | ||
| CURSORINFO ci = {}; | ||
| ci.cbSize = sizeof(ci); | ||
|
|
||
| if (GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING)) { | ||
| auto it = cursorMap.find(ci.hCursor); | ||
| std::string type = (it != cursorMap.end()) ? it->second : "arrow"; | ||
| if (GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING)) { | ||
| auto it = cursorMap.find(ci.hCursor); | ||
| std::string type = (it != cursorMap.end()) ? it->second : "arrow"; | ||
|
|
||
| if (type != lastType) { | ||
| lastType = type; | ||
| std::cout << "STATE:" << type << std::endl; | ||
| if (type != lastType) { | ||
| lastType = type; | ||
| std::cout << "STATE:" << type << std::endl; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Sleep(50); | ||
| tick = (tick + 1) % cursorPollEvery; | ||
| Sleep(buttonPollMs); | ||
|
Comment on lines
+79
to
+111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Refresh the checked-in Windows cursor-monitor artifacts. If 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return 0; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Add a Windows-native integration test for click-edge polling. The existing cursor-monitor tests feed synthetic
INTERACTION:lines tohandleCursorMonitorStdout; they do not executeGetAsyncKeyStateorreportMouseButtonEdges. Add coverage for left, right, and middle press/release events. The repository has no native test harness or CMake test target, so this requires introducing one alongside the existing Windows CMake build path.🤖 Prompt for AI Agents