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))