From c549ee4c9d6c8da1e35c614cce009a27790ee76b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:35:56 +0100 Subject: [PATCH 1/4] fix(mac): AppKit tooltips while zoomed (0.5.3) Hiding the real cursor during motion (0.5.2) killed every AppKit tooltip: NSToolTipManager checks NSCursor._isHidden once, when the pointer enters the tooltip rect. TooltipNudge: at rest, if the element under the pointer has AXHelp in the frontmost app, a 4x4 near-transparent panel sits under the pointer for 25 ms so the app sees a re-entry with the cursor shown. Once per visit. scripts/tooltip_truth.py; the hand-over log names the window. 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/AppDelegate.swift | 2 +- mac/Sources/Wind/ClearCursor.swift | 11 ++- mac/Sources/Wind/DrawZoom.swift | 9 ++- mac/Sources/Wind/Magnifier.swift | 2 +- mac/Sources/Wind/TooltipNudge.swift | 105 ++++++++++++++++++++++++++ mac/VERSION | 2 +- mac/docs/architecture.md | 13 ++++ mac/scripts/tooltip_truth.py | 74 ++++++++++++++++++ mac/tools/hoverprobe/build.sh | 7 ++ mac/tools/hoverprobe/hoverprobe.swift | 30 +++++++- 12 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 mac/Sources/Wind/TooltipNudge.swift create mode 100755 mac/scripts/tooltip_truth.py diff --git a/mac/.gitignore b/mac/.gitignore index 7c93ab5c..deac1456 100644 --- a/mac/.gitignore +++ b/mac/.gitignore @@ -20,3 +20,4 @@ tools/upscaleref/build-ported.inc tools/scalecard/scalecard tools/cursorchurn/dispcap tools/cursorchurn/curseen +tools/hoverprobe/TipProbe.app/ diff --git a/mac/CLAUDE.md b/mac/CLAUDE.md index 24f39ba2..b624ded4 100644 --- a/mac/CLAUDE.md +++ b/mac/CLAUDE.md @@ -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). diff --git a/mac/Sources/Wind/AppDelegate.swift b/mac/Sources/Wind/AppDelegate.swift index b4d7f849..b8473f13 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, "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] } ?? [], diff --git a/mac/Sources/Wind/ClearCursor.swift b/mac/Sources/Wind/ClearCursor.swift index 830f52a2..b19227d5 100644 --- a/mac/Sources/Wind/ClearCursor.swift +++ b/mac/Sources/Wind/ClearCursor.swift @@ -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 } @@ -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). diff --git a/mac/Sources/Wind/DrawZoom.swift b/mac/Sources/Wind/DrawZoom.swift index c734cbe4..44fb20dc 100644 --- a/mac/Sources/Wind/DrawZoom.swift +++ b/mac/Sources/Wind/DrawZoom.swift @@ -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 } @@ -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 diff --git a/mac/Sources/Wind/Magnifier.swift b/mac/Sources/Wind/Magnifier.swift index e8a37ba0..4a3c93a6 100644 --- a/mac/Sources/Wind/Magnifier.swift +++ b/mac/Sources/Wind/Magnifier.swift @@ -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 diff --git a/mac/Sources/Wind/TooltipNudge.swift b/mac/Sources/Wind/TooltipNudge.swift new file mode 100644 index 00000000..94a98305 --- /dev/null +++ b/mac/Sources/Wind/TooltipNudge.swift @@ -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) } + } +} diff --git a/mac/VERSION b/mac/VERSION index cb0c939a..be14282b 100644 --- a/mac/VERSION +++ b/mac/VERSION @@ -1 +1 @@ -0.5.2 +0.5.3 diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index 6fc449fe..5e731048 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -774,6 +774,19 @@ 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. 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 diff --git a/mac/scripts/tooltip_truth.py b/mac/scripts/tooltip_truth.py new file mode 100755 index 00000000..42249a06 --- /dev/null +++ b/mac/scripts/tooltip_truth.py @@ -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() diff --git a/mac/tools/hoverprobe/build.sh b/mac/tools/hoverprobe/build.sh index 7e7b68f9..fbcb6d52 100755 --- a/mac/tools/hoverprobe/build.sh +++ b/mac/tools/hoverprobe/build.sh @@ -6,3 +6,10 @@ set -euo pipefail cd "${0:A:h}" swiftc -O hoverprobe.swift -o hoverprobe echo "built tools/hoverprobe/hoverprobe" +# TipProbe.app: the same binary as an app bundle, so `open` makes it the active app (AppKit tooltips need that). +A=TipProbe.app; mkdir -p $A/Contents/MacOS && cp hoverprobe $A/Contents/MacOS/TipProbe +cat > $A/Contents/Info.plist <<'PL' + +CFBundleExecutableTipProbeCFBundleIdentifiercom.windtest.tipprobeCFBundlePackageTypeAPPL +PL +codesign -fs - $A 2>/dev/null diff --git a/mac/tools/hoverprobe/hoverprobe.swift b/mac/tools/hoverprobe/hoverprobe.swift index 71482f21..af287f02 100644 --- a/mac/tools/hoverprobe/hoverprobe.swift +++ b/mac/tools/hoverprobe/hoverprobe.swift @@ -41,9 +41,11 @@ func banner() { } final class TV: NSView { + var mine: NSTrackingArea? override func updateTrackingAreas() { - trackingAreas.forEach(removeTrackingArea) - addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self)) + if let t = mine { removeTrackingArea(t) } // only ours: the tooltip's tracking area stays + mine = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self) + addTrackingArea(mine!) } override func mouseEntered(with e: NSEvent) { print("AK entered") } override func mouseExited(with e: NSEvent) { print("AK exited") } @@ -61,14 +63,36 @@ func panel() { app.setActivationPolicy(.accessory) let at = CommandLine.arguments.dropFirst(2).compactMap { Double($0) } // panel [x y]: bottom left let r = NSRect(x: at.first ?? 60, y: at.dropFirst().first ?? 60, width: 400, height: 120) - let p = NSPanel(contentRect: r, styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) + let act = CommandLine.arguments.contains("active") + let p = act ? NSWindow(contentRect: r, styleMask: [.titled], backing: .buffered, defer: false) + : NSPanel(contentRect: r, styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) p.level = .statusBar; p.collectionBehavior = [.canJoinAllSpaces] p.title = "WINDTEST hover" let tv = TV(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) let host = NSHostingView(rootView: HV()); host.frame = NSRect(x: 200, y: 0, width: 200, height: 120) p.contentView = NSView(frame: NSRect(origin: .zero, size: r.size)) p.contentView!.addSubview(tv); p.contentView!.addSubview(host) + tv.toolTip = "WINDTEST tip"; tv.setAccessibilityElement(true); tv.setAccessibilityRole(.button); tv.setAccessibilityHelp("WINDTEST tip") // AppKit tooltip: "TIP on"/"TIP off" + var tip = false, wasActive = false, lastDump = "", lastUnder = "" + Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in + let pid = Int(getpid()) + let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] ?? [] + let on = list.contains { ($0[kCGWindowOwnerPID as String] as? Int) == pid && ($0[kCGWindowNumber as String] as? Int) != p.windowNumber } + if on != tip { tip = on; print(on ? "TIP on" : "TIP off"); fflush(stdout) } + if act { + let all = (CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] ?? []).filter { ($0[kCGWindowOwnerPID as String] as? Int) == pid } + let d = all.map { "\($0[kCGWindowNumber as String] ?? 0)/L\($0[kCGWindowLayer as String] ?? 0)/on\($0[kCGWindowIsOnscreen as String] ?? 0)" }.joined(separator: " ") + if d != lastDump { lastDump = d; print("WINS", d, NSApp.windows.map { "\(type(of: $0)):\($0.isVisible)" }) } + } + if act { + let n = NSWindow.windowNumber(at: NSEvent.mouseLocation, belowWindowWithWindowNumber: 0) + let u = n == p.windowNumber ? "own" : "other \(n)" + if u != lastUnder { lastUnder = u; print("UNDER", u) } + } + if act, NSApp.isActive != wasActive { wasActive = NSApp.isActive; print("ACTIVE", wasActive) } + } p.orderFrontRegardless() + if CommandLine.arguments.contains("active") { app.setActivationPolicy(.regular); app.activate(ignoringOtherApps: true); p.level = .normal; p.makeKeyAndOrderFront(nil) } let h = NSScreen.screens[0].frame.height print("panel", Int(r.minX), Int(h - r.maxY), Int(r.width), Int(r.height)) app.run() From 29ea29aee2c6616fcb61bda4204951c30b87c576 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:44:31 +0100 Subject: [PATCH 2/4] fix(mac): tooltip flash at once on every stop, no AX look (owner: no flicker seen) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkyGaTJWN7aoebcu1GASvL --- mac/Sources/Wind/Magnifier.swift | 2 +- mac/Sources/Wind/TooltipNudge.swift | 69 +++-------------------------- mac/docs/architecture.md | 11 +++-- mac/scripts/tooltip_truth.py | 16 ++----- 4 files changed, 17 insertions(+), 81 deletions(-) diff --git a/mac/Sources/Wind/Magnifier.swift b/mac/Sources/Wind/Magnifier.swift index 4a3c93a6..e8a37ba0 100644 --- a/mac/Sources/Wind/Magnifier.swift +++ b/mac/Sources/Wind/Magnifier.swift @@ -373,7 +373,7 @@ final class Magnifier { return pass case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - if e.getIntegerValueField(.eventSourceUserData) != ClearCursor.recheckMark { ClearCursor.shared.pointerMoved(); TooltipNudge.shared.moved(e.location) } + 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/Sources/Wind/TooltipNudge.swift b/mac/Sources/Wind/TooltipNudge.swift index 94a98305..977a0553 100644 --- a/mac/Sources/Wind/TooltipNudge.swift +++ b/mac/Sources/Wind/TooltipNudge.swift @@ -5,12 +5,10 @@ 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. +/// moves never start one. Every entry happens mid-motion, so no tooltip ever came. At every rest 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 a tooltip there starts. At once, with no Accessibility look first (owner +/// 2026-11-07: no flicker seen, so faster beats skipping elements without a tooltip). 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 @@ -22,69 +20,16 @@ final class TooltipNudge: @unchecked Sendable { #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 + DispatchQueue.main.async { [self] in + guard !isMoving(), NSWorkspace.shared.frontmostApplication?.processIdentifier != getpid() else { return } + flash() } - 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() { diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index 5e731048..6443f6f2 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -778,12 +778,11 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02 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 + `TooltipNudge`: at every rest 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 with an AX look first). Flashes shorter than ~20 ms miss (12 ms: 3 of + 4, 8 ms: 1 of 4). A first version looked for AXHelp under the pointer first and flashed once per element; the owner saw + no flicker and asked for it at once on every stop (2026-11-07). Cost: the hover under the pointer is off for one or two frames. 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). diff --git a/mac/scripts/tooltip_truth.py b/mac/scripts/tooltip_truth.py index 42249a06..3e597eb8 100755 --- a/mac/scripts/tooltip_truth.py +++ b/mac/scripts/tooltip_truth.py @@ -3,11 +3,11 @@ # 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). +flashes a tiny window under the pointer at every rest (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. +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, the probe stays the active app, -and no flash over the SwiftUI half. Takes the screen (an active window at the top left). +and the probe stays the active app. 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,21 +51,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: From 5fec879d9393ac4ba4f9aad08a66290fce1b80c0 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:46:25 +0100 Subject: [PATCH 3/4] Revert the every-stop tooltip flash: back to the AX look first, once per element (owner 2026-11-07: worse) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkyGaTJWN7aoebcu1GASvL --- mac/Sources/Wind/Magnifier.swift | 2 +- mac/Sources/Wind/TooltipNudge.swift | 69 ++++++++++++++++++++++++++--- mac/docs/architecture.md | 11 ++--- mac/scripts/tooltip_truth.py | 16 +++++-- 4 files changed, 81 insertions(+), 17 deletions(-) diff --git a/mac/Sources/Wind/Magnifier.swift b/mac/Sources/Wind/Magnifier.swift index e8a37ba0..4a3c93a6 100644 --- a/mac/Sources/Wind/Magnifier.swift +++ b/mac/Sources/Wind/Magnifier.swift @@ -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 diff --git a/mac/Sources/Wind/TooltipNudge.swift b/mac/Sources/Wind/TooltipNudge.swift index 977a0553..94a98305 100644 --- a/mac/Sources/Wind/TooltipNudge.swift +++ b/mac/Sources/Wind/TooltipNudge.swift @@ -5,10 +5,12 @@ 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 every rest 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 a tooltip there starts. At once, with no Accessibility look first (owner -/// 2026-11-07: no flicker seen, so faster beats skipping elements without a tooltip). No polling. +/// 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 @@ -20,16 +22,69 @@ final class TooltipNudge: @unchecked Sendable { #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 } - DispatchQueue.main.async { [self] in - guard !isMoving(), NSWorkspace.shared.frontmostApplication?.processIdentifier != getpid() else { return } - flash() + 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() { diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index 6443f6f2..5e731048 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -778,11 +778,12 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02 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 every rest 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 with an AX look first). Flashes shorter than ~20 ms miss (12 ms: 3 of - 4, 8 ms: 1 of 4). A first version looked for AXHelp under the pointer first and flashed once per element; the owner saw - no flicker and asked for it at once on every stop (2026-11-07). Cost: the hover under the pointer is off for + `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. 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). diff --git a/mac/scripts/tooltip_truth.py b/mac/scripts/tooltip_truth.py index 3e597eb8..42249a06 100755 --- a/mac/scripts/tooltip_truth.py +++ b/mac/scripts/tooltip_truth.py @@ -3,11 +3,11 @@ # 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 every rest (TooltipNudge). +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. Real HID strokes into it, then a stop. +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 the probe stays the active app. Takes the screen (an active window at the top left). +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__))) @@ -51,13 +51,21 @@ 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 = [] + 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: From 5c5a1d265517c84379d2df7c8576523b805959dd Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:46:25 +0100 Subject: [PATCH 4/4] docs(mac): note the every-stop flash was worse Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkyGaTJWN7aoebcu1GASvL --- mac/docs/architecture.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mac/docs/architecture.md b/mac/docs/architecture.md index 5e731048..4e8563f0 100644 --- a/mac/docs/architecture.md +++ b/mac/docs/architecture.md @@ -784,7 +784,8 @@ through) and macOS zoom itself are not involved (research/wind-mac/re/2026-11-02 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. Transform with Wind's cursor keeps the real cursor hidden throughout, so it has no tooltips (not + 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