From f19cbe1076c82abcdd795294dc5a0c405074307e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:41:19 +0100 Subject: [PATCH 1/3] fix(mac): no tiny cursor on hover changes (0.5.2) An app's single cursor change (a hover effect) showed the real cursor on screen in about 1 of 5 changes: Wind's look read the app's cursor image before clearing it, and that read took longer than the wait between looks. The look now hides the real cursor first, reads, clears, shows again, and looks every 0.25 ms (CPU unchanged). glimpse_truth.py counts real-cursor frames on screen: about 7 per 35 changes before, 0-2 now. churn_truth's stop check no longer assumes a pointer travel. Fixes #554 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkyGaTJWN7aoebcu1GASvL --- mac/.gitignore | 1 + mac/CLAUDE.md | 1 + mac/Sources/Wind/ClearCursor.swift | 36 ++++++++++++++++- mac/VERSION | 2 +- mac/docs/architecture.md | 6 +++ mac/scripts/backdrop_truth.py | 4 ++ mac/scripts/churn_truth.py | 9 +++-- mac/scripts/glimpse_truth.py | 40 +++++++++++++++++++ mac/tools/cursorchurn/build.sh | 1 + mac/tools/cursorchurn/curseen.m | 51 +++++++++++++++++++++++++ mac/tools/cursorchurn/cursorchurn.swift | 14 ++++++- 11 files changed, 158 insertions(+), 7 deletions(-) create mode 100755 mac/scripts/glimpse_truth.py create mode 100644 mac/tools/cursorchurn/curseen.m diff --git a/mac/.gitignore b/mac/.gitignore index 39dfeb67..7c93ab5c 100644 --- a/mac/.gitignore +++ b/mac/.gitignore @@ -19,3 +19,4 @@ tools/upscaleref/up tools/upscaleref/build-ported.inc tools/scalecard/scalecard tools/cursorchurn/dispcap +tools/cursorchurn/curseen diff --git a/mac/CLAUDE.md b/mac/CLAUDE.md index 29654e54..24f39ba2 100644 --- a/mac/CLAUDE.md +++ b/mac/CLAUDE.md @@ -42,6 +42,7 @@ How the macOS zoom behaves and why: `docs/architecture.md`. Keep it in step with - `python3 scripts/handover_truth.py`: panning with a system alert up on the zoomed screen (Transform, Render drawing it, `--states zoomalert,handover --tier 250` the hand-over to macOS's zoom); run it after touching `DrawZoom` prompts or the capture. - `python3 scripts/churn_truth.py [--target ChatGPT]`: the real cursor over an app that sets it on every move (Chromium), measured outside Wind; run it after touching `ClearCursor`. +- `python3 scripts/glimpse_truth.py`: the real cursor ON SCREEN at hover changes (frames counted with `tools/cursorchurn/curseen`); run it after touching `ClearCursor`. - `python3 scripts/hitch_truth.py`: view stalls as apps open, quit and alerts show, from Wind's `slow tick` lines (`Stall`); run it after touching anything on the main thread's tick path. - `python3 scripts/backdrop_truth.py`: the Backdrop engine (no capture); `WIND_ENGINE=backdrop` runs a suite on it. ScreenCaptureKit cannot see it (`tools/cursorchurn/dispcap` can). - `python3 scripts/edge_truth.py`: the pinned view at screen edges on the zoomed screen (edge shake). diff --git a/mac/Sources/Wind/ClearCursor.swift b/mac/Sources/Wind/ClearCursor.swift index a4c8ddbe..a26daf33 100644 --- a/mac/Sources/Wind/ClearCursor.swift +++ b/mac/Sources/Wind/ClearCursor.swift @@ -49,6 +49,15 @@ final class ClearCursor: @unchecked Sendable { private var repeatHidden = false // the poll thread's own hide private var lastForeign = 0.0 private static let repeatWithin = 1.0, busyHold = 0.5 + /// The poll's sleep in microseconds: normally, and for 2 s after an app set its cursor. 250 both: an app's + /// hover change showed the real cursor on screen in about 1 of 35 changes at 2000/500 with the hide first, + /// and 0.3 at 250 (scripts/glimpse_truth.py), for no measurable CPU (Wind 3.8% against 3.9% at 3x). + static let pollUs: (UInt32, UInt32) = { + #if WIND_TEST_CHANNEL + if let v = ProcessInfo.processInfo.environment["WIND_CLEAR_POLL_US"]?.split(separator: ",").compactMap({ UInt32($0) }), v.count == 2 { return (v[0], v[1]) } + #endif + return (250, 250) + }() private var shapeGen = 0 private var ours: UInt32 = 0 // the seed of Wind's own transparent set private var beat = 0.0 // main thread alive (a timer while active) @@ -217,11 +226,31 @@ final class ClearCursor: @unchecked Sendable { e.post(tap: .cghidEventTap) } + /// A hide around the read of an app's cursor. Its own count, balanced by quickShow whatever stop() did + /// meanwhile (the hide count is per process). + private func quickHide(session s: Int) -> Bool { + wsLock.lock(); defer { wsLock.unlock() } + lock.lock(); let current = active && session == s; lock.unlock() + guard current else { return false } + CGDisplayHideCursor(CGMainDisplayID()) + return true + } + private func quickShow() { + wsLock.lock(); CGDisplayShowCursor(CGMainDisplayID()); wsLock.unlock() + } + static let hideFirst: Bool = { + #if WIND_TEST_CHANNEL + if ProcessInfo.processInfo.environment["WIND_HIDE_FIRST"] == "0" { return false } + #endif + return true + }() + private func poll(session s: Int) { lock.lock(); var last = ours; lock.unlock() // 0 (start's set raced with an app's): look at once var quick = false, hidden = false + let (slow, fast) = ClearCursor.pollUs while true { - usleep(quick ? 500 : 2000) // faster while an app is setting its cursor + usleep(quick ? fast : slow) // faster while an app is setting its cursor lock.lock() guard active, session == s else { lock.unlock(); return } let now = CACurrentMediaTime() @@ -247,6 +276,11 @@ final class ClearCursor: @unchecked Sendable { if seed == mine && !wasStalled { continue } // Someone else set the cursor (or Wind's own set raced with it): keep theirs as the shape. lock.lock(); foreignCount += 1; lastForeign = now; lock.unlock() + // Hidden first, then read: reading the app's cursor (its image, a WindowServer round trip) took + // longer than the wait between looks, and the app's cursor showed meanwhile, on 1-2 frames at a + // hover change (owner 2026-11-05, a file label in a browser; faster looks changed nothing). + let flash = ClearCursor.hideFirst && !hidden && !wasStalled && quickHide(session: s) + defer { if flash { quickShow() } } if let c = NSCursor.currentSystem, !isClear(c) { guard setClear(session: s) else { return } // first: the app's cursor is on screen until then let key = c.image.tiffRepresentation // pixels: same size and hotspot can be another cursor diff --git a/mac/VERSION b/mac/VERSION index 4b9fcbec..cb0c939a 100644 --- a/mac/VERSION +++ b/mac/VERSION @@ -1 +1 @@ -0.5.1 +0.5.2 diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index c1567f58..d00719e4 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -762,6 +762,12 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02 sampler at 3x: `tools/cursorchurn` 3.92% -> 0.05-0.08%, the real ChatGPT window 0.98% -> 0.08% (the first two sets of a burst still show). SwiftUI hover is off only meanwhile; a panel above `cursorchurn` kept it (checked with the test app only, not with a real Chromium window under it). +- A single cursor change (a hover effect: arrow to pointing hand) still showed the real cursor on screen in 1 or 2 frames on + about half the hovers (owner 2026-11-05, a file label in a browser). The look's own read of the app's cursor (its image, a + WindowServer round trip) took longer than the wait between looks. The poll now hides the real cursor first, reads, sets the + transparent one and shows it again (`quickHide`), and looks every 0.25 ms. `scripts/glimpse_truth.py` counts frames with the + real cursor ON SCREEN (CGDisplayStream with the cursor, Wind's drawn one a magenta probe) over `cursorchurn grey hover`: + about 35 changes per run, 7 frames before (3-12), 3 with the hide first, 0.3 with both. Wind's CPU at 3x: 3.8% -> 3.9%. - Safe by construction: WindowServer drops a dead connection's cursor (a killed Wind leaves the app's cursor showing, measured), and if Wind's main thread stalls 1.5 s the thread puts the app's cursor back. - Not usable instead: cursor scale below 1 (draws at 1x), `SLSShieldCursor` (empty stub), cursor diff --git a/mac/scripts/backdrop_truth.py b/mac/scripts/backdrop_truth.py index b947c554..a2a81393 100644 --- a/mac/scripts/backdrop_truth.py +++ b/mac/scripts/backdrop_truth.py @@ -28,6 +28,10 @@ def ws_cpu(secs=4): v = [l.split() for l in out.splitlines() if "WindowServer" in l] return float(v[-1][1]) if v else -1 +# macOS keeps the dot up a few seconds after any recording ends (a suite run just before this one). +for _ in range(30): + if indicators() == 0: break + time.sleep(1) W.relaunch_with_channel(direct=True, extra={"engine": "backdrop", "cursor": "wind", "renderScaling": 1}) churn = subprocess.Popen([T + "/cursorchurn", "60"], stdout=subprocess.PIPE, text=True) x, y, w, h = map(int, churn.stdout.readline().split()); time.sleep(0.8) diff --git a/mac/scripts/churn_truth.py b/mac/scripts/churn_truth.py index 576c73c8..0dae13b0 100644 --- a/mac/scripts/churn_truth.py +++ b/mac/scripts/churn_truth.py @@ -17,6 +17,7 @@ ap = argparse.ArgumentParser() ap.add_argument("--target", default="churn") ap.add_argument("--engine", default="draw") +ap.add_argument("--mode", default="", help="cursorchurn mode: hover (a shape change per stripe, set once)") ap.add_argument("--secs", type=float, default=5) ap.add_argument("--report-ms", type=int, default=1) ap.add_argument("--limit", type=float, default=0.2) @@ -52,7 +53,7 @@ def vmc(cmd): vm.stdin.write(cmd + "\n"); vm.stdin.flush(); vm.stdout.readline() if a.target == "churn": tool = os.path.join(W.ROOT, "tools/cursorchurn/cursorchurn") subprocess.run([os.path.join(W.ROOT, "tools/cursorchurn/build.sh")], check=True, stdout=subprocess.DEVNULL) - churn = subprocess.Popen([tool, str(a.secs + 12)], stdout=subprocess.PIPE, text=True) + churn = subprocess.Popen([tool, str(a.secs + 12)] + ([a.mode] if a.mode else []), stdout=subprocess.PIPE, text=True) x, y, w, h = map(int, churn.stdout.readline().split()) cx, cy = x + w // 2, y + h // 2 else: @@ -113,11 +114,11 @@ def sample(label): events.clear() for k in range(30): vmc(f"move {2 if k % 2 else -2} 0 20 {a.report_ms}") vmc(f"move 0 12 10 {a.report_ms}") # one quick move onto the panel, about 82 pt, then the hand stops -stopped = time.time(); time.sleep(2.5) -print("stopped at", W.ctl("state")["pointer"], "panel top", py) +stopped = time.time(); at_stop = W.ctl("state")["pointer"]; time.sleep(2.5) +print("stopped at", at_stop, "panel top", py) got = [t - stopped for t, l in events if l == "SUI hover true"] W.check("render: hover comes after arriving and stopping, with no further move", bool(got), f"{got[0]:+.2f} s from stopping" if got else "none in 2.5 s") -s = W.ctl("state"); W.check("render: the pointer stayed put", abs(s["pointer"][1] - (py + 22.5)) < 3, f"pointer {s['pointer']}") +s = W.ctl("state"); W.check("render: the pointer stayed put", abs(s["pointer"][0] - at_stop[0]) + abs(s["pointer"][1] - at_stop[1]) < 0.5, f"pointer {at_stop} -> {s['pointer']}") pp.kill(); W.ctl("level 1") if churn: churn.kill() vm.stdin.close() diff --git a/mac/scripts/glimpse_truth.py b/mac/scripts/glimpse_truth.py new file mode 100755 index 00000000..a3c5fb7b --- /dev/null +++ b/mac/scripts/glimpse_truth.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Max Aubert. All rights reserved. +# Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary +"""The tiny real cursor ON SCREEN at hover changes (owner 2026-11-05: a file label in a browser, the real cursor +for 1-2 frames on about half the hovers). tools/cursorchurn `grey hover` is a plain grey window that sets the +pointing hand and the arrow in turn as the pointer crosses its stripes, once per change, like a hover effect. +tools/cursorchurn/curseen captures every frame WITH the cursor and finds the real one at the pointer's desktop +spot; Wind's drawn cursor is a magenta probe (WIND_CURSOR_PROBE) it skips. About 35 changes per sweep at 3x. +PASS: at most --limit frames with the real cursor per engine, and none with no changes at all. +usage: glimpse_truth.py [--engines backdrop,draw] [--limit 2]""" +import argparse, os, subprocess, sys, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import windtest as W + +ap = argparse.ArgumentParser() +ap.add_argument("--engines", default="backdrop,draw") +ap.add_argument("--limit", type=int, default=2) +a = ap.parse_args() +T = os.path.join(W.ROOT, "tools/cursorchurn") +subprocess.run([T + "/build.sh"], check=True, stdout=subprocess.DEVNULL) +os.environ["WIND_CURSOR_PROBE"] = "1" +vm = None +for eng in a.engines.split(","): + W.relaunch_with_channel(direct=True, extra={"engine": eng, "cursor": "wind", "renderScaling": 1}) + if vm is None: + vm = subprocess.Popen(["sudo", os.path.join(W.ROOT, "tools/vmouse/vmouse")], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + if vm.stdout.readline().strip() != "ready": sys.exit("vmouse not ready") + for mode in ("none", "hover"): + ch = subprocess.Popen([T + "/cursorchurn", "25", "grey"] + ([mode] if mode == "hover" else []), stdout=subprocess.PIPE, text=True) + x, y, w, h = map(int, ch.stdout.readline().split()); time.sleep(0.8) + W.ctl("level 1"); W.ctl(f"warp {x + 300} {y + h // 2}"); time.sleep(0.4); W.ctl("level 3"); time.sleep(1.5) + cs = subprocess.Popen([T + "/curseen", "5"], stdout=subprocess.PIPE, text=True); cs.stdout.readline() + t = time.time(); k = 0 + while time.time() - t < 4.8: + vm.stdin.write(f"move {3 if (k // 8) % 2 == 0 else -3} 0 40 1\n"); vm.stdin.flush(); vm.stdout.readline(); k += 1 + out = cs.stdout.read().split(); frames, seen = int(out[1]), int(out[3]) + lim = 0 if mode == "none" else a.limit + W.check(f"{eng}: real cursor frames, {'no cursor changes' if mode == 'none' else 'hover changes'}", frames > 250 and seen <= lim, f"{seen} of {frames} frames") + W.ctl("level 1"); time.sleep(0.4); ch.kill(); time.sleep(0.3) +W.finish() diff --git a/mac/tools/cursorchurn/build.sh b/mac/tools/cursorchurn/build.sh index 5ae7c2bf..3fbd296c 100755 --- a/mac/tools/cursorchurn/build.sh +++ b/mac/tools/cursorchurn/build.sh @@ -7,3 +7,4 @@ cd "${0:A:h}" swiftc -O cursorchurn.swift -o cursorchurn echo "built tools/cursorchurn/cursorchurn" clang -fobjc-arc -O1 -framework Cocoa -framework IOSurface dispcap.m -o dispcap +clang -fobjc-arc -O1 -framework Cocoa -framework IOSurface curseen.m -o curseen diff --git a/mac/tools/cursorchurn/curseen.m b/mac/tools/cursorchurn/curseen.m new file mode 100644 index 00000000..a2e57e36 --- /dev/null +++ b/mac/tools/cursorchurn/curseen.m @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Max Aubert. All rights reserved. +// Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary +// curseen : frames of the screen WITH the real cursor (CGDisplayStream, kCGDisplayStreamShowCursor), and +// in each a 44x44 pt box at the pointer's desktop spot, where the real cursor is drawn. Over cursorchurn's plain +// grey window, a frame with many pixels far from grey there has the real cursor in it. Prints frames, frames +// with it, and the longest run. Starting it shows macOS's recording indicator. +#import +#import +#import +typedef void (^H)(int, uint64_t, IOSurfaceRef, void *); +typedef void *(*CreateFn)(uint32_t, size_t, size_t, int32_t, CFDictionaryRef, dispatch_queue_t, H); +typedef int (*StartFn)(void *); +int main(int argc, char **argv) { + @autoreleasepool { + double secs = atof(argv[1]); + void *cg = dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_NOW); + CreateFn create = (CreateFn)dlsym(cg, "CGDisplayStreamCreateWithDispatchQueue"); + StartFn start = (StartFn)dlsym(cg, "CGDisplayStreamStart"); + __block long frames = 0, seen = 0, run = 0, longest = 0; + NSDictionary *props = @{ @"kCGDisplayStreamShowCursor": @YES, @"kCGDisplayStreamMinimumFrameTime": @(1.0/240) }; + void *s = create(CGMainDisplayID(), 1920, 1080, 'BGRA', (__bridge CFDictionaryRef)props, dispatch_queue_create("cap", 0), + ^(int status, uint64_t dt, IOSurfaceRef surf, void *upd) { + if (status != 0 || !surf) return; + CGEventRef e = CGEventCreate(NULL); CGPoint p = CGEventGetLocation(e); CFRelease(e); + IOSurfaceLock(surf, kIOSurfaceLockReadOnly, NULL); + uint8_t *b = IOSurfaceGetBaseAddress(surf); size_t r = IOSurfaceGetBytesPerRow(surf); + int w = (int)IOSurfaceGetWidth(surf), h = (int)IOSurfaceGetHeight(surf), far = 0; + for (int y = (int)p.y - 6; y < (int)p.y + 38; y++) for (int x = (int)p.x - 6; x < (int)p.x + 38; x++) { + if (x < 0 || y < 0 || x >= w || y >= h) continue; + uint8_t *q = b + y * r + x * 4; int v = q[1]; + if ((v < 50 || v > 215) && abs(q[0] - q[2]) < 30 && abs(q[1] - q[2]) < 30) far++; } + const char *dump = getenv("CURSEEN_DUMP"); + static int dumped = 0; + if (dump && far > 25 && dumped < 3) { dumped++; + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef c = CGBitmapContextCreate(b, w, h, 8, r, cs, kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little); + CGImageRef im = CGBitmapContextCreateImage(c); + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:im]; + NSString *f = [NSString stringWithFormat:@"%s-%d-%d-%d.png", dump, dumped, (int)p.x, (int)p.y]; + [[rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}] writeToFile:f atomically:NO]; + CGImageRelease(im); CGContextRelease(c); CGColorSpaceRelease(cs); } + IOSurfaceUnlock(surf, kIOSurfaceLockReadOnly, NULL); + frames++; + if (far > 25) { seen++; run++; if (run > longest) longest = run; } else run = 0; + }); + if (!s || start(s) != 0) return 5; + puts("ready"); fflush(stdout); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(secs * 1e9)), dispatch_get_main_queue(), ^{ printf("frames %ld seen %ld longest %ld\n", frames, seen, longest); exit(0); }); + dispatch_main(); + } +} diff --git a/mac/tools/cursorchurn/cursorchurn.swift b/mac/tools/cursorchurn/cursorchurn.swift index 37e4787b..dc4b6d9b 100644 --- a/mac/tools/cursorchurn/cursorchurn.swift +++ b/mac/tools/cursorchurn/cursorchurn.swift @@ -12,9 +12,21 @@ final class V: NSView { trackingAreas.forEach(removeTrackingArea) addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self)) } - override func mouseMoved(with e: NSEvent) { NSCursor.arrow.set(); sets += 1 } + // hover: like a page's hover effect, the pointing hand over white stripes and the arrow over grey ones, + // set only when it changes (owner 2026-11-04, a file label in a browser). + var hand = false + override func mouseMoved(with e: NSEvent) { + if CommandLine.arguments.contains("hover") { + let i = Int(convert(e.locationInWindow, from: nil).x / (bounds.width / 40)) + let want = i % 2 == 0 + if want != hand { hand = want; (want ? NSCursor.pointingHand : NSCursor.arrow).set(); sets += 1 } + return + } + NSCursor.arrow.set(); sets += 1 + } override func draw(_ r: NSRect) { if solid { (inverted ? NSColor.white : NSColor.black).setFill(); bounds.fill(); return } + if CommandLine.arguments.contains("grey") { NSColor(white: 0.5, alpha: 1).setFill(); bounds.fill(); return } for i in 0..<40 { ((i % 2 == 0) != inverted ? NSColor.white : NSColor.darkGray).setFill(); NSRect(x: CGFloat(i) * bounds.width / 40, y: 0, width: bounds.width / 40, height: bounds.height).fill() } } } From 09ce20762cc69dd9a8ef503b42f83b0bed65eef1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:57:04 +0100 Subject: [PATCH 2/3] fix(mac): real cursor hidden while the pointer moves, transparent at rest Even with the hide first and a look every 0.25 ms, about 1 in 20 hover changes still flashed (0.2 ms exposure against a 6.9 ms refresh; 5 seen in a minute in the owner's browser). Apps set the cursor on mouse moves, so the real cursor is now hidden while moving and transparent again 0.15 s after it stops, with a hover recheck. Event driven from the tap, no polling. SwiftUI hover comes about 0.1 s after stopping. glimpse_truth: 0 frames in 4 runs. e2e_settings_mouse no longer expects the transparent cursor shown while moving. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkyGaTJWN7aoebcu1GASvL --- mac/Sources/Wind/ClearCursor.swift | 46 ++++++++++++++++++++++++++++++ mac/Sources/Wind/Magnifier.swift | 1 + mac/docs/architecture.md | 6 ++++ mac/scripts/e2e_settings_mouse.py | 3 +- 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/mac/Sources/Wind/ClearCursor.swift b/mac/Sources/Wind/ClearCursor.swift index a26daf33..599de0b2 100644 --- a/mac/Sources/Wind/ClearCursor.swift +++ b/mac/Sources/Wind/ClearCursor.swift @@ -132,7 +132,9 @@ final class ClearCursor: @unchecked Sendable { wsLock.lock() // an in-flight set of the poll thread finishes first (app ?? NSCursor.arrow).set() // while Wind still owns the cursor (over the Dock) if repeatHidden { CGDisplayShowCursor(CGMainDisplayID()); repeatHidden = false } + if motionHidden { CGDisplayShowCursor(CGMainDisplayID()); motionHidden = false } wsLock.unlock() + lock.lock(); moving = false; lock.unlock() SkyLight.updateOwnsCursor() // then give it back, unless Wind still hides it } @@ -223,9 +225,53 @@ final class ClearCursor: @unchecked Sendable { lock.lock(); repeatAt = 0; lock.unlock() guard let at = CGEvent(source: nil)?.location, let e = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: at, mouseButton: .left) else { return } + e.setIntegerValueField(.eventSourceUserData, value: ClearCursor.recheckMark) // not a hand move (pointerMoved) e.post(tap: .cghidEventTap) } + // Hidden while the pointer moves, transparent once it rests (owner 2026-11-06). Apps set their cursor on + // mouse moves (hover changes), and even with the hide first and a look every 0.25 ms about 1 in 20 such sets + // reached the screen: the tiny cursor on a frame (a browser's file label: 88 sets, 5 seen in a minute). Hidden + // by the hide count no set shows. SwiftUI ignores hover while hidden (Notification Center's banners), so at + // rest the transparent cursor comes back and the hover is looked at again: SwiftUI hover comes about + // `restDelay` late, AppKit and web hover are unchanged. Event driven: the tap's mouse moves start it, a timer + // ends it; no polling. The hide is sent from the tap before the event reaches the app, on its own queue. + static let recheckMark: Int64 = 0x57494E44 // "WIND": Wind's own zero-distance move + static let restDelay = 0.15 + private var motionHidden = false // under wsLock + private var moving = false // under lock (short holds only) + private let motionQueue = DispatchQueue(label: "wind.clearcursor.motion", qos: .userInteractive) + private lazy var restTimer: DispatchSourceTimer = { + let t = DispatchSource.makeTimerSource(queue: motionQueue) + t.setEventHandler { [weak self] in self?.rested() } + t.resume() + return t + }() + + /// Main thread (the event tap), on every mouse move or drag: cheap when already moving. + func pointerMoved() { + guard isActive else { return } + restTimer.schedule(deadline: .now() + ClearCursor.restDelay, leeway: .milliseconds(5)) + lock.lock(); let starting = !moving; moving = true; let s = session; lock.unlock() + if !starting { return } + motionQueue.async { [weak self] in + guard let self else { return } + self.wsLock.lock(); defer { self.wsLock.unlock() } + self.lock.lock(); let ok = self.active && self.session == s; self.lock.unlock() + if ok && !self.motionHidden { CGDisplayHideCursor(CGMainDisplayID()); self.motionHidden = true } + } + } + + private func rested() { + // Cleared before the show: a move from now on queues its hide behind this on the same queue. + lock.lock(); moving = false; lock.unlock() + wsLock.lock() + let was = motionHidden + if motionHidden { CGDisplayShowCursor(CGMainDisplayID()); motionHidden = false } + wsLock.unlock() + if was && isActive { recheckHover() } + } + /// A hide around the read of an app's cursor. Its own count, balanced by quickShow whatever stop() did /// meanwhile (the hide count is per process). private func quickHide(session s: Int) -> Bool { diff --git a/mac/Sources/Wind/Magnifier.swift b/mac/Sources/Wind/Magnifier.swift index c0a5254e..e8a37ba0 100644 --- a/mac/Sources/Wind/Magnifier.swift +++ b/mac/Sources/Wind/Magnifier.swift @@ -373,6 +373,7 @@ final class Magnifier { return pass case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + if e.getIntegerValueField(.eventSourceUserData) != ClearCursor.recheckMark { ClearCursor.shared.pointerMoved() } let dx = Double(e.getIntegerValueField(.mouseEventDeltaX)) let dy = Double(e.getIntegerValueField(.mouseEventDeltaY)) let loc = e.location diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index d00719e4..0e9acf73 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -768,6 +768,12 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02 transparent one and shows it again (`quickHide`), and looks every 0.25 ms. `scripts/glimpse_truth.py` counts frames with the real cursor ON SCREEN (CGDisplayStream with the cursor, Wind's drawn one a magenta probe) over `cursorchurn grey hover`: about 35 changes per run, 7 frames before (3-12), 3 with the hide first, 0.3 with both. Wind's CPU at 3x: 3.8% -> 3.9%. +- Still not zero: in the owner's browser 88 sets a minute brought 5 flashes they saw (each set exposed 0.2 ms median, against a + 6.9 ms refresh). So the real cursor is now HIDDEN while the pointer moves and transparent only at rest (owner 2026-11-06): + the tap's first mouse move hides it (on `motionQueue`, sent before the app gets the event), and 0.15 s without moves shows it + transparent again and posts the zero-distance move (`recheckHover`, marked so the tap ignores it). Apps set their cursor on + moves, so no set meets a shown cursor. SwiftUI hover comes about 0.1-0.15 s after the pointer stops; AppKit and web hover are + unchanged. Event driven, no polling. `glimpse_truth.py`: 0 frames in 4 runs on both engines; `churn_truth.py` 0.00%. - Safe by construction: WindowServer drops a dead connection's cursor (a killed Wind leaves the app's cursor showing, measured), and if Wind's main thread stalls 1.5 s the thread puts the app's cursor back. - Not usable instead: cursor scale below 1 (draws at 1x), `SLSShieldCursor` (empty stub), cursor diff --git a/mac/scripts/e2e_settings_mouse.py b/mac/scripts/e2e_settings_mouse.py index 7e4cb2b4..17fbcdc1 100755 --- a/mac/scripts/e2e_settings_mouse.py +++ b/mac/scripts/e2e_settings_mouse.py @@ -94,7 +94,8 @@ def key(k): ctl(f"post key {k} down"); ctl(f"post key {k} up"); time.sleep(0.25) vmc(f"move {4 if (k // 25) % 2 == 0 else -4} 1 5 8"); k += 1 n, shown, real = map(int, sp.stdout.read().split()) s = ctl("state"); own1 = (s.get("clearCursorCounts", []) + [0, 0, 0])[:3] -check("render: no real cursor over the zoomed Settings window", n > 400 and shown > n * 0.9 and real <= 2 and s["drawn"], +# Hidden while the pointer moves, transparent at rest (ClearCursor, 2026-11-06): the shown share no longer says anything. +check("render: no real cursor over the zoomed Settings window", n > 400 and real <= 2 and s["drawn"], f"real cursor in {real}/{n} samples (shown transparent {shown - real}), drawn {s['drawn']}") check("render: the page's cursor sets are taken by Wind", own1[2] > own0[2], f"own sets {own0[2]} -> {own1[2]}") ctl("level 1"); time.sleep(0.5) From e249910cbc3625ed7603dacf44cd62686bb9154d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:58:39 +0100 Subject: [PATCH 3/3] fix(mac): SwiftUI hover 0.06 s after the pointer stops (was 0.15 s) glimpse_truth --pauses (hand-like 20-200 ms stops) showed 0 real-cursor frames at 150, 60 and 40 ms on both engines; 60 keeps a margin for slow trackpad moves. WIND_REST_MS test override. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkyGaTJWN7aoebcu1GASvL --- mac/Sources/Wind/ClearCursor.swift | 8 +++++++- mac/docs/architecture.md | 4 ++-- mac/scripts/glimpse_truth.py | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/mac/Sources/Wind/ClearCursor.swift b/mac/Sources/Wind/ClearCursor.swift index 599de0b2..830f52a2 100644 --- a/mac/Sources/Wind/ClearCursor.swift +++ b/mac/Sources/Wind/ClearCursor.swift @@ -237,7 +237,13 @@ final class ClearCursor: @unchecked Sendable { // `restDelay` late, AppKit and web hover are unchanged. Event driven: the tap's mouse moves start it, a timer // ends it; no polling. The hide is sent from the tap before the event reaches the app, on its own queue. static let recheckMark: Int64 = 0x57494E44 // "WIND": Wind's own zero-distance move - static let restDelay = 0.15 + // 60 ms (owner 2026-11-06, was 150): 60 and 40 both 0 frames with hand-like pauses (glimpse_truth --pauses). + static let restDelay: Double = { + #if WIND_TEST_CHANNEL + if let v = ProcessInfo.processInfo.environment["WIND_REST_MS"].flatMap(Double.init) { return v / 1000 } + #endif + return 0.06 + }() private var motionHidden = false // under wsLock private var moving = false // under lock (short holds only) private let motionQueue = DispatchQueue(label: "wind.clearcursor.motion", qos: .userInteractive) diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index 0e9acf73..6fc449fe 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -770,9 +770,9 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02 about 35 changes per run, 7 frames before (3-12), 3 with the hide first, 0.3 with both. Wind's CPU at 3x: 3.8% -> 3.9%. - Still not zero: in the owner's browser 88 sets a minute brought 5 flashes they saw (each set exposed 0.2 ms median, against a 6.9 ms refresh). So the real cursor is now HIDDEN while the pointer moves and transparent only at rest (owner 2026-11-06): - the tap's first mouse move hides it (on `motionQueue`, sent before the app gets the event), and 0.15 s without moves shows it + the tap's first mouse move hides it (on `motionQueue`, sent before the app gets the event), and 0.06 s without moves shows it transparent again and posts the zero-distance move (`recheckHover`, marked so the tap ignores it). Apps set their cursor on - moves, so no set meets a shown cursor. SwiftUI hover comes about 0.1-0.15 s after the pointer stops; AppKit and web hover are + moves, so no set meets a shown cursor. SwiftUI hover comes about 0.06 s after the pointer stops (0.15 s until 2026-11-06; 60 and 40 ms both showed 0 frames in `glimpse_truth.py --pauses`, 60 keeps a margin for slow trackpad moves whose events come further apart); AppKit and web hover are unchanged. Event driven, no polling. `glimpse_truth.py`: 0 frames in 4 runs on both engines; `churn_truth.py` 0.00%. - Safe by construction: WindowServer drops a dead connection's cursor (a killed Wind leaves the app's cursor showing, measured), and if Wind's main thread stalls 1.5 s the thread puts the app's cursor back. diff --git a/mac/scripts/glimpse_truth.py b/mac/scripts/glimpse_truth.py index a3c5fb7b..fe1fd51b 100755 --- a/mac/scripts/glimpse_truth.py +++ b/mac/scripts/glimpse_truth.py @@ -8,13 +8,14 @@ spot; Wind's drawn cursor is a magenta probe (WIND_CURSOR_PROBE) it skips. About 35 changes per sweep at 3x. PASS: at most --limit frames with the real cursor per engine, and none with no changes at all. usage: glimpse_truth.py [--engines backdrop,draw] [--limit 2]""" -import argparse, os, subprocess, sys, time +import argparse, os, random, subprocess, sys, time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import windtest as W ap = argparse.ArgumentParser() ap.add_argument("--engines", default="backdrop,draw") ap.add_argument("--limit", type=int, default=2) +ap.add_argument("--pauses", action="store_true", help="stop for 20-200 ms between strokes, like a hand (rest and resume)") a = ap.parse_args() T = os.path.join(W.ROOT, "tools/cursorchurn") subprocess.run([T + "/build.sh"], check=True, stdout=subprocess.DEVNULL) @@ -33,6 +34,7 @@ t = time.time(); k = 0 while time.time() - t < 4.8: vm.stdin.write(f"move {3 if (k // 8) % 2 == 0 else -3} 0 40 1\n"); vm.stdin.flush(); vm.stdout.readline(); k += 1 + if a.pauses and k % 8 == 0: time.sleep(random.uniform(0.02, 0.2)) out = cs.stdout.read().split(); frames, seen = int(out[1]), int(out[3]) lim = 0 if mode == "none" else a.limit W.check(f"{eng}: real cursor frames, {'no cursor changes' if mode == 'none' else 'hover changes'}", frames > 250 and seen <= lim, f"{seen} of {frames} frames")