diff --git a/mac/CLAUDE.md b/mac/CLAUDE.md index b624ded4..8930d526 100644 --- a/mac/CLAUDE.md +++ b/mac/CLAUDE.md @@ -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). diff --git a/mac/Sources/Wind/AppDelegate.swift b/mac/Sources/Wind/AppDelegate.swift index b8473f13..b4d7f849 100644 --- a/mac/Sources/Wind/AppDelegate.swift +++ b/mac/Sources/Wind/AppDelegate.swift @@ -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] } ?? [], diff --git a/mac/Sources/Wind/ClearCursor.swift b/mac/Sources/Wind/ClearCursor.swift index b19227d5..ff38e64c 100644 --- a/mac/Sources/Wind/ClearCursor.swift +++ b/mac/Sources/Wind/ClearCursor.swift @@ -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 } @@ -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). diff --git a/mac/Sources/Wind/Magnifier.swift b/mac/Sources/Wind/Magnifier.swift index 4a3c93a6..c0a5254e 100644 --- a/mac/Sources/Wind/Magnifier.swift +++ b/mac/Sources/Wind/Magnifier.swift @@ -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 diff --git a/mac/Sources/Wind/TooltipNudge.swift b/mac/Sources/Wind/TooltipNudge.swift deleted file mode 100644 index 94a98305..00000000 --- a/mac/Sources/Wind/TooltipNudge.swift +++ /dev/null @@ -1,105 +0,0 @@ -// 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) } - } -} diff --git a/mac/VERSION b/mac/VERSION index be14282b..7d856835 100644 --- a/mac/VERSION +++ b/mac/VERSION @@ -1 +1 @@ -0.5.3 +0.5.4 diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index 4e8563f0..3080d2b8 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -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. diff --git a/mac/scripts/tooltip_truth.py b/mac/scripts/tooltip_truth.py index 42249a06..1710f1e9 100755 --- a/mac/scripts/tooltip_truth.py +++ b/mac/scripts/tooltip_truth.py @@ -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__))) @@ -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()