Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions driver/Sources/NibCore/EventEmitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
10 changes: 8 additions & 2 deletions driver/Sources/NibCore/TouchGesture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions driver/Sources/nib/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down
46 changes: 46 additions & 0 deletions driver/Tests/NibCoreTests/EventEmitterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading