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
1 change: 1 addition & 0 deletions mac/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ tools/upscaleref/build-ported.inc
tools/scalecard/scalecard
tools/cursorchurn/dispcap
tools/cursorchurn/curseen
tools/hoverprobe/TipProbe.app/
1 change: 1 addition & 0 deletions mac/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +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/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, "realCursorClear": ClearCursor.shared.isActive,
"realCursorShown": SkyLight.realCursorVisible, "tooltipFlashes": TooltipNudge.shared.flashes, "realCursorClear": ClearCursor.shared.isActive,
"ownsCursor": SkyLight.ownsCursor,
"clearCursorCounts": ClearCursor.shared.counts,
"windCursor": mag.windCursorAt.map { [$0.x, $0.y] } ?? [],
Expand Down
11 changes: 9 additions & 2 deletions mac/Sources/Wind/ClearCursor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,15 @@ final class ClearCursor: @unchecked Sendable {
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 else { return }
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 }
Expand All @@ -275,8 +281,9 @@ final class ClearCursor: @unchecked Sendable {
let was = motionHidden
if motionHidden { CGDisplayShowCursor(CGMainDisplayID()); motionHidden = false }
wsLock.unlock()
if was && isActive { recheckHover() }
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
9 changes: 8 additions & 1 deletion mac/Sources/Wind/DrawZoom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,13 @@ final class DrawZoom: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sen
return nil
}

/// "owner, layer, size" of a window for the log.
static func windowDescription(_ id: Int?) -> String {
guard let id, let l = CGWindowListCopyWindowInfo([.optionIncludingWindow], CGWindowID(id)) as? [[String: Any]], let w = l.first else { return "window \(id ?? 0)" }
let b = w[kCGWindowBounds as String] as? [String: Double] ?? [:]
return "\(w[kCGWindowOwnerName as String] as? String ?? "?") layer \(w[kCGWindowLayer as String] as? Int ?? 0) \(Int(b["Width"] ?? 0))x\(Int(b["Height"] ?? 0))"
}

/// Is this window still on screen?
static func windowOnScreen(_ id: Int) -> Bool {
guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return false }
Expand Down Expand Up @@ -487,7 +494,7 @@ final class DrawZoom: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sen
}
if prompt && !DrawZoom.handedOver {
Log.info(takeover ? "draw engine: screenshot or colour-sampler mode; macOS zoom shows it"
: "draw engine: a window the capture cannot show is up (lock screen, capture-protected or above the overlay\(blocking?.above == true ? ", over Wind's cursor too" : "")); macOS zoom shows it")
: "draw engine: a window the capture cannot show is up (lock screen, capture-protected or above the overlay\(blocking?.above == true ? ", over Wind's cursor too" : "")): \(DrawZoom.windowDescription(blocking?.id)); macOS zoom shows it")
DrawZoom.handedOver = true
handoverWindow = blocking?.id
handoverAbove = blocking?.above == true ? blocking?.id : nil
Expand Down
2 changes: 1 addition & 1 deletion mac/Sources/Wind/Magnifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ final class Magnifier {
return pass

case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
if e.getIntegerValueField(.eventSourceUserData) != ClearCursor.recheckMark { ClearCursor.shared.pointerMoved() }
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: 105 additions & 0 deletions mac/Sources/Wind/TooltipNudge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) 2026 Max Aubert. All rights reserved.
// Proprietary source-available software; see LICENSE. SPDX-License-Identifier: LicenseRef-Wind-Proprietary
import AppKit

/// AppKit tooltips while the real cursor is hidden for motion (ClearCursor, owner 2026-11-06: no tooltips in
/// Xcode while zoomed). `-[NSToolTipManager mouseEnteredToolTip:inWindow:pointInWindow:]` asks
/// `+[NSCursor _isHidden]` once, when the pointer enters the tooltip's rect, and starts no timer if hidden; later
/// moves never start one. Every entry happens mid-motion, so no tooltip ever came. At rest, if the element under
/// the pointer has a tooltip (AXHelp) in the frontmost app, a tiny near-transparent window sits under the pointer
/// for `flashMs`: the app sees the pointer leave and come back with the cursor shown, and the tooltip starts.
/// Once per visit: no flash again for the same element until the pointer leaves its frame (a slow hand stops
/// every few points; each flash restarts the tooltip's timer and blinks the hover).
/// Cost: that element's hover blinks off for one or two frames. One AX look per stop, no polling.
final class TooltipNudge: @unchecked Sendable {
static let shared = TooltipNudge()
/// 20 ms brought the tooltip in 2 of 2 stops, 12 in 3 of 4, 8 in 1 of 4 (WindowServer re-tests the window
/// under the pointer on its own schedule); 25 for margin.
static let flashMs = 25.0
static let enabled: Bool = {
#if WIND_TEST_CHANNEL
if ProcessInfo.processInfo.environment["WIND_TIP_NUDGE"] == "0" { return false }
#endif
return true
}()
private let queue = DispatchQueue(label: "wind.tooltipnudge", qos: .userInitiated)
private let system: AXUIElement = {
let s = AXUIElementCreateSystemWide(); AXUIElementSetMessagingTimeout(s, 0.25); return s
}()
private var busy = false // on `queue`: one look at a time
private var panel: NSPanel? // main thread
private(set) var flashes = 0 // main thread (test state)
private let lock = NSLock()
private var visited: (el: AXUIElement, frame: CGRect)? // under lock: flashed, the pointer still inside

/// Main thread (the event tap), every hand move: leaving the flashed element's frame re-arms it.
func moved(_ p: CGPoint) {
lock.lock(); if let v = visited, !v.frame.contains(p) { visited = nil }; lock.unlock()
}

/// Any thread, once per stop after a hidden motion.
func rested(isMoving: @escaping @Sendable () -> Bool) {
guard TooltipNudge.enabled else { return }
queue.async { [self] in
if busy { return }
guard let at = CGEvent(source: nil)?.location, let front = NSWorkspace.shared.frontmostApplication?.processIdentifier,
front != getpid() else { return }
busy = true; defer { busy = false }
guard let el = tooltipElement(at: at, front: front), !isMoving(), CGEvent(source: nil)?.location == at else { return }
lock.lock(); let same = visited.map { CFEqual($0.el, el) } ?? false; lock.unlock()
if same { return }
let frame = frameOf(el) ?? CGRect(x: at.x - 1, y: at.y - 1, width: 2, height: 2)
DispatchQueue.main.async { [self] in
guard !isMoving(), CGEvent(source: nil)?.location == at else { return }
lock.lock(); visited = (el, frame); lock.unlock()
flash()
}
}
}

/// The element under the pointer (or one of its first ancestors) with tooltip text, in the frontmost app:
/// AppKit shows tooltips only for the active app.
private func tooltipElement(at p: CGPoint, front: pid_t) -> AXUIElement? {
var hit: AXUIElement?
guard AXUIElementCopyElementAtPosition(system, Float(p.x), Float(p.y), &hit) == .success, var el = hit else { return nil }
var pid: pid_t = 0
guard AXUIElementGetPid(el, &pid) == .success, pid == front else { return nil }
for _ in 0..<3 {
var v: CFTypeRef?
if AXUIElementCopyAttributeValue(el, kAXHelpAttribute as CFString, &v) == .success,
let s = v as? String, !s.isEmpty { return el }
var up: CFTypeRef?
guard AXUIElementCopyAttributeValue(el, kAXParentAttribute as CFString, &up) == .success, let u = up,
CFGetTypeID(u) == AXUIElementGetTypeID() else { return nil }
el = u as! AXUIElement
}
return nil
}

/// The element's frame in desktop points, top left (the event tap's coordinates while ClearCursor runs).
private func frameOf(_ el: AXUIElement) -> CGRect? {
var pv: CFTypeRef?, sv: CFTypeRef?
guard AXUIElementCopyAttributeValue(el, kAXPositionAttribute as CFString, &pv) == .success,
AXUIElementCopyAttributeValue(el, kAXSizeAttribute as CFString, &sv) == .success,
let pv, let sv, CFGetTypeID(pv) == AXValueGetTypeID(), CFGetTypeID(sv) == AXValueGetTypeID() else { return nil }
var o = CGPoint.zero, z = CGSize.zero
guard AXValueGetValue(pv as! AXValue, .cgPoint, &o), AXValueGetValue(sv as! AXValue, .cgSize, &z) else { return nil }
return CGRect(origin: o, size: z)
}

private func flash() {
let p = NSEvent.mouseLocation
let w = panel ?? {
let w = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
w.backgroundColor = NSColor(white: 0, alpha: 0.02); w.isOpaque = false; w.hasShadow = false
w.level = .screenSaver; w.ignoresMouseEvents = false; w.sharingType = .none
w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
w.isReleasedWhenClosed = false; w.hidesOnDeactivate = false
panel = w; return w
}()
w.setFrame(NSRect(x: p.x - 2, y: p.y - 2, width: 4, height: 4), display: false)
w.orderFrontRegardless()
flashes += 1
DispatchQueue.main.asyncAfter(deadline: .now() + TooltipNudge.flashMs / 1000) { w.orderOut(nil) }
}
}
2 changes: 1 addition & 1 deletion mac/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.2
0.5.3
14 changes: 14 additions & 0 deletions mac/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,20 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02
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:]`
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.
`TooltipNudge`: at rest, one AX look (own queue, 0.25 s timeout) for AXHelp on the element under the pointer or its
first ancestors, frontmost app only (AppKit shows tooltips only for the active app). If there is one, a 4x4
near-transparent nonactivating panel (`.screenSaver`, `sharingType = .none`) sits under the pointer for 25 ms: the
app sees the pointer leave and come back with the cursor shown, and the tooltip starts (1.8 s after the stop against
1.4 s at 1x). Flashes shorter than ~20 ms miss (12 ms: 3 of 4, 8 ms: 1 of 4). Once per visit: the same element is not
flashed again until a hand move leaves its AX frame (`moved`, from the tap). Cost: that element's hover is off for
one or two frames. Flashing at once on every stop without the AX look was tried and was worse for the owner
(2026-11-07). Transform with Wind's cursor keeps the real cursor hidden throughout, so it has no tooltips (not
changed). `scripts/tooltip_truth.py` (TipProbe.app: `open`ed so it is the active app; its log lives outside ~/Documents,
where a LaunchServices-started app cannot write, `open` fails with -10810).
- 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
Expand Down
74 changes: 74 additions & 0 deletions mac/scripts/tooltip_truth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/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).
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).
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__)))
import windtest as W
from windtest import ROOT, ctl

ap = argparse.ArgumentParser()
ap.add_argument("--engines", default="backdrop,draw")
ap.add_argument("--n", type=int, default=3)
a = ap.parse_args()
H = os.path.join(ROOT, "tools/hoverprobe"); VM = os.path.join(ROOT, "tools/vmouse/vmouse")
subprocess.run([H + "/build.sh"], check=True, stdout=subprocess.DEVNULL)
engs = a.engines.split(",")
W.relaunch_with_channel(direct=True, extra={"engine": engs[0], "cursor": "wind", "fullPointerSpeed": True, "keepPointerSpeed": False})
vm = subprocess.Popen(["sudo", VM], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
if vm.stdout.readline().strip() != "ready": sys.exit("vmouse not ready")
def mv(dx, dy, n=1, ms=8): vm.stdin.write(f"move {dx} {dy} {n} {ms}\n"); vm.stdin.flush(); vm.stdout.readline()
# Outside ~/Documents: an app LaunchServices starts has no access there (open fails with -10810).
LOG = os.path.join(tempfile.gettempdir(), "wind_tooltip_probe.log"); open(LOG, "w").close()
subprocess.run(["open", "-n", H + "/TipProbe.app", "--stdout", LOG, "--args", "panel", "active"], check=True)
tail = subprocess.Popen(["tail", "-f", LOG], stdout=subprocess.PIPE, text=True)
px, py, pw, ph = map(int, tail.stdout.readline().split()[1:5])
ev = []
threading.Thread(target=lambda: [ev.append((time.time(), l.strip())) for l in tail.stdout], daemon=True).start()
time.sleep(1.0)
def pointer(): return ctl("state")["pointer"]
def go(x, y, tol=5):
for _ in range(400):
p = pointer(); dx, dy = x - p[0], y - p[1]
if abs(dx) <= tol and abs(dy) <= tol: return True
st = lambda d: max(-6, min(6, int(d / 4) or (1 if d > 0 else -1))) if abs(d) > tol else 0
mv(st(dx), st(dy), 1, 8); time.sleep(0.012)
return False
def stroke_to(x):
p0 = pointer()[0]; mv(4, 0, max(1, (x - p0) // 4), 8); return time.time()
y = py + ph // 2
try:
for eng in engs:
W.set_config(engine=eng, cursor="wind"); time.sleep(1.0)
for lv in (1, 3):
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)
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()
W.finish()
Loading
Loading