Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mac/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ How the macOS zoom behaves and why: `docs/architecture.md`. Keep it in step with
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/tooltip_truth.py`: AppKit tooltips while zoomed (`TooltipNudge`, takes the screen with an active probe window); run it after touching `ClearCursor` or `TooltipNudge`.
- `python3 scripts/tooltip_truth.py`: AppKit tooltips while zoomed (takes the screen with an active probe window); run it after touching how Wind hides the real cursor.
- `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).
Expand Down
2 changes: 1 addition & 1 deletion mac/Sources/Wind/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
// transparent one (read back, so a set refused over the Dock shows up as visible).
// Shown alone = what SwiftUI hover checks (it must stay true while Wind draws the cursor).
"realCursorVisible": SkyLight.realCursorVisible && !ClearCursor.shared.systemIsClear,
"realCursorShown": SkyLight.realCursorVisible, "tooltipFlashes": TooltipNudge.shared.flashes, "realCursorClear": ClearCursor.shared.isActive,
"realCursorShown": SkyLight.realCursorVisible, "realCursorClear": ClearCursor.shared.isActive,
"ownsCursor": SkyLight.ownsCursor,
"clearCursorCounts": ClearCursor.shared.counts,
"windCursor": mag.windCursorAt.map { [$0.x, $0.y] } ?? [],
Expand Down
61 changes: 4 additions & 57 deletions mac/Sources/Wind/ClearCursor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ 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
}

Expand Down Expand Up @@ -225,65 +223,14 @@ 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.setIntegerValueField(.eventSourceUserData, value: ClearCursor.recheckMark) // Wind's own
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.
// Hiding the real cursor while the pointer moved (0.5.2-0.5.3) kept every app's set off screen, but SwiftUI
// hover came late, some clicks were lost and AppKit tooltips needed a flash to start: removed (owner 2026-11-07:
// instant hover and clicks over the occasional tiny cursor). See docs/architecture.md.
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
}()

static let motionHide: Bool = {
#if WIND_TEST_CHANNEL
if ProcessInfo.processInfo.environment["WIND_MOTION_HIDE"] == "0" { return false }
#endif
return true
}()
/// Main thread (the event tap), on every mouse move or drag: cheap when already moving.
func pointerMoved() {
guard isActive, ClearCursor.motionHide 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(); TooltipNudge.shared.rested(isMoving: { [weak self] in self?.isMoving ?? true }) }
}
var isMoving: Bool { lock.lock(); defer { lock.unlock() }; return moving }

/// 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).
Expand Down
1 change: 0 additions & 1 deletion mac/Sources/Wind/Magnifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,6 @@ final class Magnifier {
return pass

case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
if e.getIntegerValueField(.eventSourceUserData) != ClearCursor.recheckMark { ClearCursor.shared.pointerMoved(); TooltipNudge.shared.moved(e.location) }
let dx = Double(e.getIntegerValueField(.mouseEventDeltaX))
let dy = Double(e.getIntegerValueField(.mouseEventDeltaY))
let loc = e.location
Expand Down
105 changes: 0 additions & 105 deletions mac/Sources/Wind/TooltipNudge.swift

This file was deleted.

2 changes: 1 addition & 1 deletion mac/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.3
0.5.4
8 changes: 6 additions & 2 deletions mac/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -769,12 +769,16 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02
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):
6.9 ms refresh). So 0.5.2-0.5.3 HID the real cursor while the pointer moved and kept it transparent only at rest (owner 2026-11-06; REMOVED in 0.5.4, see below):
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%.
- AppKit tooltips (owner 2026-11-06: none in Xcode while zoomed). `-[NSToolTipManager mouseEnteredToolTip:inWindow:pointInWindow:]`
- Removed in 0.5.4 (owner 2026-11-07): the motion hide made SwiftUI too unresponsive (hover only after the rest, and
some clicks did not register), so it and `TooltipNudge` went; the owner takes the occasional tiny cursor (hide-first
poll at 0.25 ms: about 0.3 frames per 35 hover changes in `glimpse_truth.py`) over that. Any future hide of the
real cursor has to keep these in mind:
- AppKit tooltips (owner 2026-11-06: none in Xcode while zoomed). `-[NSToolTipManager mouseEnteredToolTip:inWindow:pointInWindow:]`
asks `+[NSCursor _isHidden]` (= `!SLCursorIsVisible()`, scoreboard) once, when the pointer ENTERS the tooltip's rect,
and starts no timer if hidden; later moves (Wind's recheck, a moved or delta-carrying recheck, a second recheck) never
start one (each tried, `ipsw dyld disass`, 2026-11-06). Every entry happens mid-motion, hidden, so none came.
Expand Down
22 changes: 5 additions & 17 deletions mac/scripts/tooltip_truth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Max Aubert. All rights reserved.
# Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary
"""AppKit tooltips while zoomed (owner 2026-11-06: none in Xcode). The real cursor is hidden while the pointer moves
(ClearCursor), and AppKit starts a tooltip only if the cursor is shown when the pointer ENTERS its rect, so Wind
flashes a tiny window under the pointer at rest when the element there has a tooltip (TooltipNudge).
"""AppKit tooltips while zoomed (owner 2026-11-06: none in Xcode). AppKit starts a tooltip only if the cursor is shown
when the pointer ENTERS its rect: any hide of the real cursor during motion kills them (0.5.2-0.5.3, docs/architecture.md).
tools/hoverprobe's window, opened as TipProbe.app (the active app: tooltips need that): a teal AppKit view with a
tooltip (and AXHelp) and a SwiftUI half without one. Real HID strokes into each half, then a stop.
PASS: per engine at 1x and 3x a tooltip within 3 s of every stop on the teal half, the probe stays the active app,
and no flash over the SwiftUI half. Takes the screen (an active window at the top left).
tooltip. Real HID strokes into it, then a stop.
PASS: per engine at 1x and 3x a tooltip within 3 s of every stop on the teal half. Takes the screen (an active window at the top left).
usage: tooltip_truth.py [--engines backdrop,draw] [--n 3]"""
import argparse, os, subprocess, sys, tempfile, threading, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
Expand Down Expand Up @@ -51,23 +49,13 @@ def stroke_to(x):
tag = f"{eng} {lv}x"
ctl("level 1"); time.sleep(0.4); ctl(f"warp {px - 60} {y}"); time.sleep(0.2)
if lv > 1: ctl(f"level {lv}"); time.sleep(1.0)
got = []; fa = ctl("state").get("tooltipFlashes", 0)
got = []
for _ in range(a.n):
go(px - 60, y, tol=8); time.sleep(0.6); del ev[:]
t0 = stroke_to(px + pw // 8); time.sleep(3.0)
on = [t - t0 for t, l in ev if l == "TIP on"]
got.append(round(on[0], 2) if on else None)
W.check(f"{tag}: AppKit tooltip after every stop", all(g is not None for g in got), f"seconds after the stop {got}")
fb = ctl("state").get("tooltipFlashes", 0)
W.check(f"{tag}: one flash per visit", fb - fa <= a.n, f"{fb - fa} flashes over {a.n} visits")
go(px + pw // 4, y, tol=8); time.sleep(0.6)
f0 = ctl("state").get("tooltipFlashes", 0)
for _ in range(a.n):
stroke_to(px + 3 * pw // 4); time.sleep(0.6); go(px + pw // 2 + 20, y, tol=8); time.sleep(0.6)
f1 = ctl("state").get("tooltipFlashes", 0)
W.check(f"{tag}: no flash over an element without a tooltip", f1 == f0, f"flashes {f0} -> {f1}")
lines = open(LOG).read().split("\n")
W.check("the probe stayed the active app (the flash never takes focus)", "ACTIVE false" not in lines, "")
finally:
ctl("level 1"); tail.terminate(); subprocess.run(["pkill", "-f", "TipProbe.app/Contents/MacOS"])
vm.stdin.write("quit\n"); vm.stdin.flush()
Expand Down
Loading