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..830f52a2 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) @@ -123,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 } @@ -214,14 +225,84 @@ 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 + // 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) + 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 { + 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 +328,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/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/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..6fc449fe 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -762,6 +762,18 @@ 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%. +- 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.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.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. - 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/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) diff --git a/mac/scripts/glimpse_truth.py b/mac/scripts/glimpse_truth.py new file mode 100755 index 00000000..fe1fd51b --- /dev/null +++ b/mac/scripts/glimpse_truth.py @@ -0,0 +1,42 @@ +#!/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, 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) +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 + 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") + 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() } } }