From ed12bcdb6884bc0ac0d180ee73e49a02e27a919a Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 17:35:05 -0500 Subject: [PATCH] Activate the app under the pen and re-announce pen/eraser on app switch Clicking into a background app with the pen raised its window but never made it the active app. macOS only delivers tablet proximity events to the frontmost app, so the app never learned the pointer type: it either ignored pen input or kept a stale tool (the eraser end still drew). - On each tip/eraser press over a normal window of a non-frontmost app, activate that app via Accessibility (kAXFrontmostAttribute), falling back to NSRunningApplication.activate(). Synthesized clicks raise the window but macOS does not let them activate the app. - Re-send proximity-enter with the current pointer type when the frontmost app changes, and when the pen end changes without a reported exit. - Create all events from an HID-system-state CGEventSource (with local event suppression disabled) so they are tagged like hardware input rather than as synthetic session events. Co-Authored-By: Claude Opus 5.5 --- driver/Sources/NibCore/EventEmitter.swift | 39 ++++++++++++++-- driver/Sources/NibCore/TouchGesture.swift | 10 +++- driver/Sources/nib/main.swift | 34 ++++++++++++++ .../NibCoreTests/EventEmitterTests.swift | 46 +++++++++++++++++++ 4 files changed, 122 insertions(+), 7 deletions(-) diff --git a/driver/Sources/NibCore/EventEmitter.swift b/driver/Sources/NibCore/EventEmitter.swift index 5d5ad05..fc4b370 100644 --- a/driver/Sources/NibCore/EventEmitter.swift +++ b/driver/Sources/NibCore/EventEmitter.swift @@ -31,6 +31,10 @@ public final class EventEmitter { public var pressureCurve: PressureCurve = .linear /// Reports raw normalized pressure [0,1] each pointer event (for the prefs preview). public var pressureObserver: ((Double) -> Void)? + /// Called with the screen point just before each tip (or eraser) press is posted. The app + /// uses it to activate the app under the pen: synthesized clicks raise a background + /// window but macOS doesn't let them activate its app. + public var willPress: ((CGPoint) -> Void)? /// Multi-click detection for the tip and each side button. private var tipClicks = ClickCounter(), lowerClicks = ClickCounter(), upperClicks = ClickCounter() @@ -45,6 +49,15 @@ public final class EventEmitter { } } + /// Events are created from the HID system state (sourceStateID 1), like real hardware + /// input. With a nil source they are tagged as synthetic session events, and apps clicked + /// with the pen did not activate (macOS only lets an app activate itself on a click it + /// trusts as user input). + private let source: CGEventSource? = { + let s = CGEventSource(stateID: .hidSystemState) + s?.localEventsSuppressionInterval = 0 // never mute the keyboard/trackpad after posting + return s + }() private let sink: EventSink private let now: () -> TimeInterval @@ -70,6 +83,9 @@ public final class EventEmitter { if s.inRange && !wasInProximity { postProximity(entering: true, inverted: s.inverted) wasInProximity = true + } else if s.inRange && s.inverted != lastInverted { + // Pen end changed without a reported exit: re-announce the new pointer type. + postProximity(entering: true, inverted: s.inverted) } if s.inRange { @@ -89,7 +105,7 @@ public final class EventEmitter { /// detaches or calibration takes over the pen stream, so no button is left "stuck". public func reset() { let pt = lastPoint - if wasTipDown, let e = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, + if wasTipDown, let e = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: pt, mouseButton: .left) { e.setIntegerValueField(.mouseEventClickState, value: Int64(tipClickState)) sink.post(e) @@ -100,6 +116,16 @@ public final class EventEmitter { wasTipDown = false; wasBarrel = false; wasSecondary = false; wasInProximity = false } + /// Re-send proximity-enter for the pen currently in range. macOS delivers proximity + /// events only to the app frontmost at the time, so an app activated while the pen is + /// already in range never learns the pointer type (pen vs eraser) or even that a tablet + /// pointer is present: tablet input is then ignored or uses a stale tool. Call this when + /// the frontmost application changes. No-op when the pen is out of range. + public func reassertProximity() { + guard wasInProximity else { return } + postProximity(entering: true, inverted: lastInverted) + } + // MARK: - Side buttons private func handleButtons(_ s: PenSample) { @@ -125,7 +151,7 @@ public final class EventEmitter { case .middleClick: type = down ? .otherMouseDown : .otherMouseUp; button = .center case .leftClick: type = down ? .leftMouseDown : .leftMouseUp; button = .left } - guard let e = CGEvent(mouseEventSource: nil, mouseType: type, + guard let e = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: pt, mouseButton: button) else { return } e.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) sink.post(e) @@ -144,9 +170,12 @@ public final class EventEmitter { else if contact { type = .leftMouseDragged } else { type = .mouseMoved } wasTipDown = contact - if type == .leftMouseDown { tipClickState = tipClicks.down(at: pt, time: now()) } + if type == .leftMouseDown { + tipClickState = tipClicks.down(at: pt, time: now()) + willPress?(pt) + } - guard let e = CGEvent(mouseEventSource: nil, mouseType: type, + guard let e = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: pt, mouseButton: .left) else { return } if type != .mouseMoved { e.setIntegerValueField(.mouseEventClickState, value: Int64(tipClickState)) @@ -179,7 +208,7 @@ public final class EventEmitter { // MARK: - Proximity private func postProximity(entering: Bool, inverted: Bool) { - guard let e = CGEvent(source: nil) else { return } + guard let e = CGEvent(source: source) else { return } e.type = .tabletProximity e.setIntegerValueField(.tabletProximityEventVendorID, value: Int64(vendorID)) e.setIntegerValueField(.tabletProximityEventDeviceID, value: Int64(deviceID)) diff --git a/driver/Sources/NibCore/TouchGesture.swift b/driver/Sources/NibCore/TouchGesture.swift index e549d04..ddcc879 100644 --- a/driver/Sources/NibCore/TouchGesture.swift +++ b/driver/Sources/NibCore/TouchGesture.swift @@ -67,6 +67,12 @@ public final class TouchGestureEngine { } private let sink: EventSink + /// HID-state source, like real input (see `EventEmitter.source`). + private let source: CGEventSource? = { + let s = CGEventSource(stateID: .hidSystemState) + s?.localEventsSuppressionInterval = 0 // never mute the keyboard/trackpad after posting + return s + }() private let now: () -> TimeInterval /// Performs a three-finger swipe given the arrow key code (123 ← / 124 → / 125 ↓ / 126 ↑). /// Defaults to Control+arrow via System Events; tests substitute a recorder. @@ -116,7 +122,7 @@ public final class TouchGestureEngine { // Plain pixel scroll (horizontal + vertical). Phased/gesture scroll for Safari // swipe-navigation is a stretch goal — it felt janky, so we keep simple scrolling. guard abs(dx) >= 1 || abs(dy) >= 1 else { return } - guard let e = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, + guard let e = CGEvent(scrollWheelEvent2Source: source, units: .pixel, wheelCount: 2, wheel1: Int32(dy), wheel2: Int32(dx), wheel3: 0) else { return } sink.post(e) @@ -155,7 +161,7 @@ public final class TouchGestureEngine { let pt = m.point(x: x, y: y) let clicks = tapClicks.down(at: pt, time: now()) for type in [CGEventType.leftMouseDown, .leftMouseUp] { - guard let e = CGEvent(mouseEventSource: nil, mouseType: type, + guard let e = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: pt, mouseButton: .left) else { continue } e.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) sink.post(e) diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index 0e30391..e45ba0c 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -4,6 +4,32 @@ import ApplicationServices import AppKit import NibCore +/// Activate the app that owns the normal window under `pt`, if it isn't already frontmost. +/// A pen press raises a background window, but macOS doesn't let a synthesized click activate +/// its app, so the app never gets proximity events and ignores the pen's tool (pen vs eraser). +/// Uses Accessibility (already granted for posting events) rather than relying on the click. +func activateApp(under pt: CGPoint) { + let opts: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windows = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] else { return } + for w in windows { // front to back + guard let b = w[kCGWindowBounds as String] as? NSDictionary, + let r = CGRect(dictionaryRepresentation: b), r.contains(pt) else { continue } + if let alpha = w[kCGWindowAlpha as String] as? Double, alpha == 0 { continue } + // Topmost visible window under the pen. Only normal windows (layer 0); the menu bar, + // Dock, panels and overlays don't take focus from the frontmost app. + guard (w[kCGWindowLayer as String] as? Int) == 0, + let pid = w[kCGWindowOwnerPID as String] as? pid_t, + pid != getpid(), + pid != NSWorkspace.shared.frontmostApplication?.processIdentifier else { return } + let app = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(app, 0.25) // a hung app mustn't stall the pen + if AXUIElementSetAttributeValue(app, kAXFrontmostAttribute as CFString, kCFBooleanTrue) != .success { + NSRunningApplication(processIdentifier: pid)?.activate() + } + return + } +} + // Resolve the device Nib should drive from whatever tablet is actually plugged in; fall // back to the reference device if nothing recognized is connected. All model-specific // identity, report layouts, mode-switch and touch-enable live in the profile — see @@ -160,6 +186,7 @@ case "run": // Start with an identity mapping; DeviceManager rebuilds it once a profile resolves. let emitter = EventEmitter(mapping: DisplayMapping(targetBounds: CGDisplayBounds(CGMainDisplayID()))) let touchGesture = TouchGestureEngine() + emitter.willPress = { activateApp(under: $0) } // (Re)build the pen + touch coordinate mappings for a given tablet. Called whenever the // active device changes, and after an in-app calibration. @@ -238,6 +265,13 @@ case "run": beforeCalibration: { emitter.reset() }, calibrationBounds: { cintiqDisplayBounds(activeProfile) }) _ = statusBar // retained for the process lifetime + + // Proximity events only reach the frontmost app; re-announce the pen to whichever app + // just became active so it picks up the current tool (pen vs eraser). + let activationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main + ) { _ in emitter.reassertProximity() } + _ = activationObserver app.run() case "capture": diff --git a/driver/Tests/NibCoreTests/EventEmitterTests.swift b/driver/Tests/NibCoreTests/EventEmitterTests.swift index 7f04b38..ac3e0d2 100644 --- a/driver/Tests/NibCoreTests/EventEmitterTests.swift +++ b/driver/Tests/NibCoreTests/EventEmitterTests.swift @@ -30,6 +30,52 @@ final class EventEmitterTests: XCTestCase { XCTAssertEqual(press.getIntegerValueField(.tabletEventPointButtons) & 0x1, 1) } + func testWillPressFiresOncePerPress() { + let sink = RecordingSink(), em = makeEmitter(sink) + var presses: [CGPoint] = [] + em.willPress = { presses.append($0) } + em.handle(sample(tip: true)); em.handle(sample(tip: true)); em.handle(sample()) + var eraser = sample(); eraser.inverted = true; eraser.eraser = true + em.handle(eraser) + XCTAssertEqual(presses.count, 2) + XCTAssertEqual(presses.first, sink.clicks.first?.location) + } + + func testEventsComeFromTheHIDSystemState() { + let sink = RecordingSink(), em = makeEmitter(sink) + em.handle(sample(tip: true)) // proximity + mouse-down + XCTAssertFalse(sink.events.isEmpty) + for e in sink.events { + XCTAssertEqual(e.getIntegerValueField(.eventSourceStateID), + Int64(CGEventSourceStateID.hidSystemState.rawValue)) + } + } + + func testReassertProximityAnnouncesCurrentPointerType() { + let sink = RecordingSink(), em = makeEmitter(sink) + em.reassertProximity() // out of range: nothing + XCTAssertTrue(sink.events.isEmpty) + var s = sample(); s.inverted = true + em.handle(s) + sink.clear() + em.reassertProximity() + XCTAssertEqual(sink.types, [.tabletProximity]) + XCTAssertEqual(sink.events[0].getIntegerValueField(.tabletProximityEventEnterProximity), 1) + XCTAssertEqual(sink.events[0].getIntegerValueField(.tabletProximityEventPointerType), 3) + } + + func testFlippingPenInRangeReannouncesProximity() { + let sink = RecordingSink(), em = makeEmitter(sink) + em.handle(sample()) // pen tip enters + var e = sample(); e.inverted = true + em.handle(e) // eraser, no exit in between + let prox = sink.events.filter { $0.type == .tabletProximity } + XCTAssertEqual(prox.map { $0.getIntegerValueField(.tabletProximityEventPointerType) }, [1, 3]) + sink.clear() + em.handle(e) // steady state: no repeat + XCTAssertFalse(sink.types.contains(.tabletProximity)) + } + func testLeavingProximityWithTipDownReleasesTip() { let sink = RecordingSink(), em = makeEmitter(sink) em.handle(sample(tip: true))