From f7bd8be2547bfaa06562f71a3ca09ec00b631cb3 Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 15:04:38 -0500 Subject: [PATCH 1/9] Inject event sink and clock into EventEmitter/TouchGestureEngine; add gesture tests Synthesized events now go through an EventSink (SystemEventSink in production) and TouchGestureEngine takes an injectable clock and onSwipe closure, so the state machines can be tested without Accessibility permission or osascript. Co-Authored-By: Claude Opus 5.5 --- driver/Sources/NibCore/EventEmitter.swift | 12 ++-- driver/Sources/NibCore/EventSink.swift | 14 +++++ driver/Sources/NibCore/TouchGesture.swift | 43 ++++++++----- driver/Tests/NibCoreTests/TestSupport.swift | 32 ++++++++++ .../NibCoreTests/TouchGestureTests.swift | 62 +++++++++++++++++++ 5 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 driver/Sources/NibCore/EventSink.swift create mode 100644 driver/Tests/NibCoreTests/TestSupport.swift create mode 100644 driver/Tests/NibCoreTests/TouchGestureTests.swift diff --git a/driver/Sources/NibCore/EventEmitter.swift b/driver/Sources/NibCore/EventEmitter.swift index f85fcce..927fdb0 100644 --- a/driver/Sources/NibCore/EventEmitter.swift +++ b/driver/Sources/NibCore/EventEmitter.swift @@ -29,9 +29,13 @@ public final class EventEmitter { /// Reports raw normalized pressure [0,1] each pointer event (for the prefs preview). public var pressureObserver: ((Double) -> Void)? - public init(mapping: DisplayMapping, deviceID: UInt16 = 1) { + private let sink: EventSink + + public init(mapping: DisplayMapping, deviceID: UInt16 = 1, + sink: EventSink = SystemEventSink()) { self.mapping = mapping self.deviceID = deviceID + self.sink = sink } public func setMapping(_ m: DisplayMapping) { self.mapping = m } @@ -84,7 +88,7 @@ public final class EventEmitter { } guard let e = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: pt, mouseButton: button) else { return } - e.post(tap: .cghidEventTap) + sink.post(e) } // MARK: - Pointer @@ -123,7 +127,7 @@ public final class EventEmitter { if s.barrelButton { buttons |= 0x2 } if s.secondaryBarrel { buttons |= 0x4 } e.setIntegerValueField(.tabletEventPointButtons, value: buttons) - e.post(tap: .cghidEventTap) + sink.post(e) } // MARK: - Proximity @@ -140,6 +144,6 @@ public final class EventEmitter { // deviceID 0x01 | absX 0x02 | absY 0x04 | pressure 0x40 | orientation/tilt 0x100. e.setIntegerValueField(.tabletProximityEventCapabilityMask, value: 0x0147) e.setIntegerValueField(.tabletProximityEventSystemTabletID, value: Int64(deviceID)) - e.post(tap: .cghidEventTap) + sink.post(e) } } diff --git a/driver/Sources/NibCore/EventSink.swift b/driver/Sources/NibCore/EventSink.swift new file mode 100644 index 0000000..a02306e --- /dev/null +++ b/driver/Sources/NibCore/EventSink.swift @@ -0,0 +1,14 @@ +import CoreGraphics + +/// Where synthesized input events go. Production posts them to the HID event tap; tests +/// substitute a recorder so emitter / gesture state machines can be checked without +/// Accessibility permission or moving the real cursor. +public protocol EventSink: AnyObject { + func post(_ event: CGEvent) +} + +/// Posts events into the system at the HID tap (requires Accessibility permission). +public final class SystemEventSink: EventSink { + public init() {} + public func post(_ event: CGEvent) { event.post(tap: .cghidEventTap) } +} diff --git a/driver/Sources/NibCore/TouchGesture.swift b/driver/Sources/NibCore/TouchGesture.swift index d20b8d1..ba77125 100644 --- a/driver/Sources/NibCore/TouchGesture.swift +++ b/driver/Sources/NibCore/TouchGesture.swift @@ -50,25 +50,35 @@ public final class TouchGestureEngine { /// Fed from the pen report stream so touch can be gated on pen proximity. public func setPenInProximity(_ v: Bool) { - if penInProximity && !v { penLeftAt = ProcessInfo.processInfo.systemUptime } + if penInProximity && !v { penLeftAt = now() } penInProximity = v } private var penBlocking: Bool { guard palmRejection else { return false } if penInProximity { return true } - return (ProcessInfo.processInfo.systemUptime - penLeftAt) < palmCooldown + return (now() - penLeftAt) < palmCooldown } - public init() {} + private let sink: EventSink + 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. + public var onSwipe: (Int) -> Void = TouchGestureEngine.postControlKey + + public init(sink: EventSink = SystemEventSink(), + now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.sink = sink + self.now = now + } public func handle(_ frame: TouchFrame) { guard enabled, !penBlocking else { lastCentroid = nil; tapActive = false; return } let down = frame.contacts.filter { $0.down } defer { lastCount = down.count } - let now = ProcessInfo.processInfo.systemUptime - if lastCount == 0 && down.count > 0 { sequenceStart = now } + let t = now() + if lastCount == 0 && down.count > 0 { sequenceStart = t } // Any sequence that reaches 3 fingers is a swipe, not a scroll — suppress scroll // for the whole sequence (incl. the 2-finger phases while landing/lifting). if down.count >= 3 { sawThreePlus = true } @@ -78,7 +88,7 @@ public final class TouchGestureEngine { if threeFingerSwipe { updateSwipe(down) } // Scroll only for a settled 2-finger touch that never reached 3 fingers. - let canScroll = down.count == 2 && !sawThreePlus && (now - sequenceStart) >= scrollSettle + let canScroll = down.count == 2 && !sawThreePlus && (t - sequenceStart) >= scrollSettle guard canScroll else { lastCentroid = nil; return } let cx = down.reduce(0.0) { $0 + Double($1.x) } / 2.0 @@ -103,19 +113,19 @@ public final class TouchGestureEngine { guard let e = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: Int32(dy), wheel2: Int32(dx), wheel3: 0) else { return } - e.post(tap: .cghidEventTap) + sink.post(e) } // MARK: - Tap to click private func updateTap(_ down: [TouchContact]) { - let now = ProcessInfo.processInfo.systemUptime + let t = now() switch down.count { case 1: let f = down[0] if !tapActive && lastCount == 0 { tapActive = true - tapStart = (Double(f.x), Double(f.y), now) + tapStart = (Double(f.x), Double(f.y), t) tapMoved = 0 } else if tapActive { let dx = Double(f.x) - tapStart.x, dy = Double(f.y) - tapStart.y @@ -123,7 +133,7 @@ public final class TouchGestureEngine { } case 0: if tapActive { - let quick = (now - tapStart.t) <= tapMaxDuration + let quick = (t - tapStart.t) <= tapMaxDuration if quick && tapMoved <= tapMoveThreshold { emitTapClick(x: Int(tapStart.x), y: Int(tapStart.y)) } @@ -138,8 +148,9 @@ public final class TouchGestureEngine { guard let m = tapMapping else { return } let pt = m.point(x: x, y: y) for type in [CGEventType.leftMouseDown, .leftMouseUp] { - CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: pt, mouseButton: .left)? - .post(tap: .cghidEventTap) + guard let e = CGEvent(mouseEventSource: nil, mouseType: type, + mouseCursorPosition: pt, mouseButton: .left) else { continue } + sink.post(e) } } @@ -174,16 +185,18 @@ public final class TouchGestureEngine { key = dy < 0 ? 126 : 125 // up → Mission Control (↑) ; down → App Exposé (↓) } debug?("swipe: FIRE key=\(key)") - postControlKey(key) + onSwipe(key) } /// Fire Control+ via System Events. Raw CGEvent posting does not reliably /// trigger these system shortcuts from a background agent, but osascript/System /// Events does. Runs detached (needs the one-time Automation permission prompt). - private func postControlKey(_ keyCode: Int) { + public static func postControlKey(_ keyCode: Int) { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") task.arguments = ["-e", "tell application \"System Events\" to key code \(keyCode) using control down"] - do { try task.run() } catch { debug?("swipe: osascript failed \(error)") } + do { try task.run() } catch { + FileHandle.standardError.write("nib: swipe osascript failed \(error)\n".data(using: .utf8)!) + } } } diff --git a/driver/Tests/NibCoreTests/TestSupport.swift b/driver/Tests/NibCoreTests/TestSupport.swift new file mode 100644 index 0000000..da2f7a3 --- /dev/null +++ b/driver/Tests/NibCoreTests/TestSupport.swift @@ -0,0 +1,32 @@ +import Foundation +import CoreGraphics +@testable import NibCore + +/// Captures synthesized events instead of posting them, so state machines can be +/// asserted without Accessibility permission. +final class RecordingSink: EventSink { + private(set) var events: [CGEvent] = [] + func post(_ event: CGEvent) { events.append(event) } + + var types: [CGEventType] { events.map { $0.type } } + /// Mouse button events only (moves/drags/proximity filtered out). + var clicks: [CGEvent] { + events.filter { + [.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp].contains($0.type) + } + } + func clear() { events.removeAll() } +} + +/// A manually advanced clock for time-dependent gesture logic. +final class TestClock { + var t: TimeInterval = 100 + func advance(_ dt: TimeInterval) { t += dt } +} + +func frame(_ points: [(Int, Int)]) -> TouchFrame { + TouchFrame(contacts: points.enumerated().map { + TouchContact(id: $0.offset, down: true, x: $0.element.0, y: $0.element.1) + }, contactCount: points.count) +} diff --git a/driver/Tests/NibCoreTests/TouchGestureTests.swift b/driver/Tests/NibCoreTests/TouchGestureTests.swift new file mode 100644 index 0000000..22a325c --- /dev/null +++ b/driver/Tests/NibCoreTests/TouchGestureTests.swift @@ -0,0 +1,62 @@ +import XCTest +import CoreGraphics +@testable import NibCore + +final class TouchGestureTests: XCTestCase { + private var sink: RecordingSink! + private var clock: TestClock! + private var engine: TouchGestureEngine! + private var swipes: [Int] = [] + + override func setUp() { + sink = RecordingSink(); clock = TestClock(); swipes = [] + let clock = self.clock! + engine = TouchGestureEngine(sink: sink, now: { clock.t }) + engine.tapMapping = DisplayMapping(targetBounds: CGRect(x: 0, y: 0, width: 1536, height: 864), + xMax: 15360, yMax: 8640) + engine.onSwipe = { [unowned self] in self.swipes.append($0) } + } + + func testQuickTapClicksAtMappedPoint() { + engine.handle(frame([(7680, 4320)])) + clock.advance(0.1) + engine.handle(frame([])) + XCTAssertEqual(sink.types, [.leftMouseDown, .leftMouseUp]) + XCTAssertEqual(sink.events[0].location, CGPoint(x: 768, y: 432)) + } + + func testSlowTouchIsNotATap() { + engine.handle(frame([(7680, 4320)])) + clock.advance(0.5) + engine.handle(frame([])) + XCTAssertTrue(sink.events.isEmpty) + } + + func testTwoFingerMoveScrollsAfterSettle() { + engine.handle(frame([(5000, 4000), (6000, 4000)])) // landing: not yet settled + clock.advance(0.1) + engine.handle(frame([(5000, 4000), (6000, 4000)])) // settled: records centroid + XCTAssertTrue(sink.events.isEmpty) + clock.advance(0.01) + engine.handle(frame([(5000, 4100), (6000, 4100)])) // move → scroll + XCTAssertEqual(sink.types, [.scrollWheel]) + } + + func testThreeFingerSwipeFiresOnce() { + engine.handle(frame([(4000, 4000), (5000, 4000), (6000, 4000)])) + clock.advance(0.05) + engine.handle(frame([(6000, 4000), (7000, 4000), (8000, 4000)])) // +2000 right + clock.advance(0.05) + engine.handle(frame([(8000, 4000), (9000, 4000), (10000, 4000)])) + XCTAssertEqual(swipes, [123]) + XCTAssertFalse(sink.types.contains(.scrollWheel)) + } + + func testPenProximityBlocksTouch() { + engine.setPenInProximity(true) + engine.handle(frame([(7680, 4320)])) + clock.advance(0.1) + engine.handle(frame([])) + XCTAssertTrue(sink.events.isEmpty) + } +} From 7726238ea8dbeae3cdc8d84680edb70ae9dc7642 Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 15:05:05 -0500 Subject: [PATCH 2/9] Release held pen buttons on proximity exit, detach and calibration EventEmitter.reset() posts leftMouseUp / side-button ups / proximity-exit at the last in-range point. Proximity exit now uses it (it previously dropped the tip without a mouse-up), and the app calls it on profile change (unplug) and before calibration. Co-Authored-By: Claude Opus 5.5 --- driver/Sources/NibCore/EventEmitter.swift | 33 +++++++++++----- driver/Sources/nib/StatusBar.swift | 11 +++++- driver/Sources/nib/main.swift | 2 + .../NibCoreTests/EventEmitterTests.swift | 39 +++++++++++++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 driver/Tests/NibCoreTests/EventEmitterTests.swift diff --git a/driver/Sources/NibCore/EventEmitter.swift b/driver/Sources/NibCore/EventEmitter.swift index 927fdb0..913780f 100644 --- a/driver/Sources/NibCore/EventEmitter.swift +++ b/driver/Sources/NibCore/EventEmitter.swift @@ -17,6 +17,8 @@ public final class EventEmitter { private var wasTipDown = false private var wasBarrel = false private var wasSecondary = false + private var lastPoint = CGPoint.zero + private var lastInverted = false private let deviceID: UInt16 private let vendorID: UInt16 = 0x056a private let pressureMax = Double(PenSample.pressureLogicalMax) @@ -43,26 +45,37 @@ public final class EventEmitter { public func handle(_ s: PenSample) { // Proximity transitions -> tablet proximity events. if s.inRange && !wasInProximity { - postProximity(entering: true, sample: s) + postProximity(entering: true, inverted: s.inverted) wasInProximity = true } if s.inRange { + lastPoint = mapping.point(x: s.x, y: s.y) + lastInverted = s.inverted postPointer(s) handleButtons(s) } if !s.inRange && wasInProximity { - // Release any held side buttons before leaving proximity. - let pt = mapping.point(x: s.x, y: s.y) - if wasBarrel { postButton(lowerBarrelAction, down: false, at: pt); wasBarrel = false } - if wasSecondary { postButton(upperBarrelAction, down: false, at: pt); wasSecondary = false } - postProximity(entering: false, sample: s) - wasInProximity = false - wasTipDown = false + reset() } } + /// Release everything currently held (tip, side buttons) and leave proximity, at the + /// last in-range position. Called on proximity exit, and by the app when the tablet + /// 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, + mouseCursorPosition: pt, mouseButton: .left) { + sink.post(e) + } + if wasBarrel { postButton(lowerBarrelAction, down: false, at: pt) } + if wasSecondary { postButton(upperBarrelAction, down: false, at: pt) } + if wasInProximity { postProximity(entering: false, inverted: lastInverted) } + wasTipDown = false; wasBarrel = false; wasSecondary = false; wasInProximity = false + } + // MARK: - Side buttons private func handleButtons(_ s: PenSample) { @@ -132,13 +145,13 @@ public final class EventEmitter { // MARK: - Proximity - private func postProximity(entering: Bool, sample s: PenSample) { + private func postProximity(entering: Bool, inverted: Bool) { guard let e = CGEvent(source: nil) else { return } e.type = .tabletProximity e.setIntegerValueField(.tabletProximityEventVendorID, value: Int64(vendorID)) e.setIntegerValueField(.tabletProximityEventDeviceID, value: Int64(deviceID)) e.setIntegerValueField(.tabletProximityEventPointerType, - value: s.inverted ? 3 /*eraser*/ : 1 /*pen*/) + value: inverted ? 3 /*eraser*/ : 1 /*pen*/) e.setIntegerValueField(.tabletProximityEventEnterProximity, value: entering ? 1 : 0) // Advertise capabilities so apps enable pressure/tilt. Bits (NX_TABLET_CAPABILITY_*): // deviceID 0x01 | absX 0x02 | absY 0x04 | pressure 0x40 | orientation/tilt 0x100. diff --git a/driver/Sources/nib/StatusBar.swift b/driver/Sources/nib/StatusBar.swift index dbec8ad..a192a08 100644 --- a/driver/Sources/nib/StatusBar.swift +++ b/driver/Sources/nib/StatusBar.swift @@ -13,20 +13,26 @@ final class StatusBarController: NSObject, NSMenuDelegate { private let calibration: CalibrationController /// Bounds of the active tablet's display, resolved live (the active profile can change). private let calibrationBounds: () -> CGRect + /// Runs before calibration takes over the pen stream (releases held pen buttons). + private let beforeCalibration: () -> Void private static let speeds: [(String, Double)] = [("Slow", 0.20), ("Medium", 0.35), ("Fast", 0.55)] init(config: NibConfig, emitter: EventEmitter, touch: TouchGestureEngine, - calibration: CalibrationController, calibrationBounds: @escaping () -> CGRect) { + calibration: CalibrationController, beforeCalibration: @escaping () -> Void, + calibrationBounds: @escaping () -> CGRect) { self.config = config self.emitter = emitter self.touch = touch self.calibration = calibration self.calibrationBounds = calibrationBounds + self.beforeCalibration = beforeCalibration self.item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) self.prefsModel = PrefsModel(config: config) self.prefs = PreferencesWindowController(model: prefsModel, - onRecalibrate: { calibration.begin(bounds: calibrationBounds()) }) + onRecalibrate: { + beforeCalibration(); calibration.begin(bounds: calibrationBounds()) + }) super.init() if let button = item.button { button.image = NSImage(systemSymbolName: "hand.draw", accessibilityDescription: "Nib") @@ -164,6 +170,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { NSWorkspace.shared.open(NibConfig.fileURL) } @objc private func recalibrate() { + beforeCalibration() calibration.begin(bounds: calibrationBounds()) } /// Restart via launchd: kickstart -k kills this instance and relaunches a fresh one. diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index 9b359ae..f7b038d 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -235,6 +235,7 @@ case "run": touchGesture.handle(f) }, onProfileChange: { p in + emitter.reset() // release anything held by the previous/unplugged device if let p = p { activeProfile = p log("now driving \(p.name)") @@ -264,6 +265,7 @@ case "run": app.setActivationPolicy(.accessory) let statusBar = StatusBarController(config: config, emitter: emitter, touch: touchGesture, calibration: calibration, + beforeCalibration: { emitter.reset() }, calibrationBounds: { cintiqDisplayBounds(activeProfile) }) _ = statusBar // retained for the process lifetime app.run() diff --git a/driver/Tests/NibCoreTests/EventEmitterTests.swift b/driver/Tests/NibCoreTests/EventEmitterTests.swift new file mode 100644 index 0000000..6670efb --- /dev/null +++ b/driver/Tests/NibCoreTests/EventEmitterTests.swift @@ -0,0 +1,39 @@ +import XCTest +import CoreGraphics +@testable import NibCore + +final class EventEmitterTests: XCTestCase { + private func sample(tip: Bool = false, barrel: Bool = false, secondary: Bool = false, + inRange: Bool = true, x: Int = 52643, y: Int = 29787) -> PenSample { + PenSample(tipDown: tip, barrelButton: barrel, secondaryBarrel: secondary, eraser: false, + inverted: false, inRange: inRange, x: x, y: y, pressure: tip ? 4000 : 0, + tiltX: 0, tiltY: 0, twist: 0, distance: 0, serial: 0, toolID: 0) + } + private func makeEmitter(_ sink: RecordingSink) -> EventEmitter { + EventEmitter(mapping: DisplayMapping(targetBounds: CGRect(x: 0, y: 0, width: 1920, height: 1080)), + sink: sink) + } + + func testLeavingProximityWithTipDownReleasesTip() { + let sink = RecordingSink(), em = makeEmitter(sink) + em.handle(sample(tip: true)) + em.handle(sample(inRange: false, x: 0, y: 0)) + XCTAssertEqual(sink.clicks.map { $0.type }, [.leftMouseDown, .leftMouseUp]) + // Released where the pen last was, not at the out-of-range sample's (0,0). + XCTAssertEqual(sink.clicks.last!.location, sink.clicks.first!.location) + XCTAssertEqual(sink.types.last, .tabletProximity) + } + + func testResetMidStrokeReleasesEverything() { + let sink = RecordingSink(), em = makeEmitter(sink) + em.handle(sample(tip: true, barrel: true, secondary: true)) + sink.clear() + em.reset() + XCTAssertEqual(Set(sink.clicks.map { $0.type.rawValue }), + Set([CGEventType.leftMouseUp, .rightMouseUp, .otherMouseUp].map { $0.rawValue })) + XCTAssertEqual(sink.types.last, .tabletProximity) + sink.clear() + em.reset() // idempotent: nothing left to release + XCTAssertTrue(sink.events.isEmpty) + } +} From 79531b79b727d6dd7b119bc019d89bd9855274be Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:42:01 -0500 Subject: [PATCH 3/9] Locate the tablet's display by EDID, then native panel size Matching on the current mode's 3840x2160 pixel size picked any 4K monitor and missed the Cintiq in scaled modes. DisplayLocator matches the profile's EDID vendor/model (DTH2420: 0x5c23/0x1056), then the native mode's pixel size. Both pen mapping and the calibration overlay use it, removing the overlay's hard-coded 3840x2160. Co-Authored-By: Claude Opus 5.5 --- driver/Sources/NibCore/DeviceProfile.swift | 13 ++++- driver/Sources/NibCore/DisplayLocator.swift | 58 +++++++++++++++++++ driver/Sources/nib/Calibration.swift | 23 ++++---- driver/Sources/nib/main.swift | 22 ++----- .../NibCoreTests/DisplayLocatorTests.swift | 28 +++++++++ 5 files changed, 115 insertions(+), 29 deletions(-) create mode 100644 driver/Sources/NibCore/DisplayLocator.swift create mode 100644 driver/Tests/NibCoreTests/DisplayLocatorTests.swift diff --git a/driver/Sources/NibCore/DeviceProfile.swift b/driver/Sources/NibCore/DeviceProfile.swift index bd11146..771e577 100644 --- a/driver/Sources/NibCore/DeviceProfile.swift +++ b/driver/Sources/NibCore/DeviceProfile.swift @@ -28,11 +28,16 @@ public struct DeviceProfile: Sendable { /// Native panel resolution in pixels, used to locate the tablet's own display for 1:1 /// pen mapping. `nil` = no built-in display (map to the main display instead). public var panelSize: CGSize? + /// EDID vendor / model of the built-in display (`CGDisplayVendorNumber` / + /// `CGDisplayModelNumber`). Preferred over `panelSize` for locating the tablet's screen, + /// since another monitor may share its resolution. `nil` = unknown; match by size. + public var displayVendor: UInt32? + public var displayModel: UInt32? public init(name: String, vendorID: UInt16, penPID: UInt16, touchPID: UInt16, pen: PenLayout, touch: TouchLayout, initSequence: [FeatureWrite], touchEnable: [OutputWrite], - panelSize: CGSize?) { + panelSize: CGSize?, displayVendor: UInt32? = nil, displayModel: UInt32? = nil) { self.name = name self.vendorID = vendorID self.penPID = penPID @@ -42,6 +47,8 @@ public struct DeviceProfile: Sendable { self.initSequence = initSequence self.touchEnable = touchEnable self.panelSize = panelSize + self.displayVendor = displayVendor + self.displayModel = displayModel } } @@ -158,7 +165,9 @@ public extension DeviceProfile { OutputWrite(reportID: 0x06, touchConfigBuffer(sub: 0x06, val: 0x02)), // enable multitouch OutputWrite(reportID: 0x06, touchConfigBuffer(sub: 0x01, val: 0x03)), // HID+touch configure ], - panelSize: CGSize(width: 3840, height: 2160)) + panelSize: CGSize(width: 3840, height: 2160), + displayVendor: 0x5c23, // "WAC" + displayModel: 0x1056) /// Build a 64-byte Wacom vendor touch-config OUTPUT report: /// [0]=0x06 id, [1]=0x06, [2]=0x00, [3]=sub-cmd, [4]=value, rest 0. diff --git a/driver/Sources/NibCore/DisplayLocator.swift b/driver/Sources/NibCore/DisplayLocator.swift new file mode 100644 index 0000000..d23298d --- /dev/null +++ b/driver/Sources/NibCore/DisplayLocator.swift @@ -0,0 +1,58 @@ +import CoreGraphics + +/// Finds the pen display's own screen so the pen maps 1:1 onto it. +/// +/// Matching order: EDID vendor+model from the profile, then the panel's *native* pixel +/// size. The native size is read from the display's native mode rather than the current +/// one, so a scaled mode (e.g. "looks like 2560×1440", backed by 5120×2880) still matches. +public enum DisplayLocator { + /// What we know about one active display (plain data, so selection is unit-testable). + public struct Candidate: Equatable, Sendable { + public var id: CGDirectDisplayID + public var vendor: UInt32 + public var model: UInt32 + public var nativePixelSize: CGSize? + public init(id: CGDirectDisplayID, vendor: UInt32, model: UInt32, nativePixelSize: CGSize?) { + self.id = id; self.vendor = vendor; self.model = model; self.nativePixelSize = nativePixelSize + } + } + + /// Pick the tablet's display among `candidates`, or nil if none matches. + public static func choose(_ candidates: [Candidate], for profile: DeviceProfile) -> CGDirectDisplayID? { + if let v = profile.displayVendor, let m = profile.displayModel, + let hit = candidates.first(where: { $0.vendor == v && $0.model == m }) { + return hit.id + } + guard let panel = profile.panelSize else { return nil } + return candidates.first(where: { $0.nativePixelSize == panel })?.id + } + + /// The tablet's display among the currently active displays, or nil. + public static func tabletDisplayID(for profile: DeviceProfile) -> CGDirectDisplayID? { + choose(activeCandidates(), for: profile) + } + + public static func activeCandidates() -> [Candidate] { + var count: UInt32 = 0 + CGGetActiveDisplayList(0, nil, &count) + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + CGGetActiveDisplayList(count, &ids, &count) + return ids.prefix(Int(count)).map { + Candidate(id: $0, vendor: CGDisplayVendorNumber($0), model: CGDisplayModelNumber($0), + nativePixelSize: nativePixelSize($0)) + } + } + + /// Pixel size of the display's native mode (IOKit kDisplayModeNativeFlag), falling back + /// to the current mode's pixel size if no mode is flagged native. + static func nativePixelSize(_ id: CGDirectDisplayID) -> CGSize? { + let kDisplayModeNativeFlag: UInt32 = 0x0200_0000 + let opts = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary + if let modes = CGDisplayCopyAllDisplayModes(id, opts) as? [CGDisplayMode], + let native = modes.first(where: { $0.ioFlags & kDisplayModeNativeFlag != 0 }) { + return CGSize(width: native.pixelWidth, height: native.pixelHeight) + } + guard let cur = CGDisplayCopyDisplayMode(id) else { return nil } + return CGSize(width: cur.pixelWidth, height: cur.pixelHeight) + } +} diff --git a/driver/Sources/nib/Calibration.swift b/driver/Sources/nib/Calibration.swift index ee0310b..6c523b5 100644 --- a/driver/Sources/nib/Calibration.swift +++ b/driver/Sources/nib/Calibration.swift @@ -15,6 +15,8 @@ final class CalibrationController: ObservableObject { private var escMonitor: Any? private var bounds: CGRect = .zero private let apply: (Calibration) -> Void + /// The active device profile, used to locate the tablet's screen for the overlay. + private let profile: () -> DeviceProfile // Target positions as a fraction of the display (inset from the corners). private let p1 = CGPoint(x: 0.1, y: 0.1) @@ -24,7 +26,10 @@ final class CalibrationController: ObservableObject { private var rawP1 = (0.0, 0.0) private let K = 40, tol = 1200 - init(apply: @escaping (Calibration) -> Void) { self.apply = apply } + init(profile: @escaping () -> DeviceProfile, apply: @escaping (Calibration) -> Void) { + self.profile = profile + self.apply = apply + } func begin(bounds: CGRect) { self.bounds = bounds @@ -82,18 +87,14 @@ final class CalibrationController: ObservableObject { close() } - /// The Cintiq's NSScreen (3840x2160 native), whose .frame is in Cocoa coordinates - /// — CGDisplayBounds is Quartz (top-left origin) and would place the window off-screen. + /// The tablet's NSScreen, whose .frame is in Cocoa coordinates — CGDisplayBounds is + /// Quartz (top-left origin) and would place the window off-screen. private func cintiqScreen() -> NSScreen? { - for s in NSScreen.screens { - if let num = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber { - let id = CGDirectDisplayID(truncating: num) - if let mode = CGDisplayCopyDisplayMode(id), mode.pixelWidth == 3840, mode.pixelHeight == 2160 { - return s - } - } + guard let id = DisplayLocator.tabletDisplayID(for: profile()) else { return nil } + return NSScreen.screens.first { + ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + .map { CGDirectDisplayID(truncating: $0) } == id } - return nil } // Called on the main thread (HID transports + menu actions run on the main run loop). diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index f7b038d..728e4ec 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -35,22 +35,12 @@ func displayReconfigCallback(_ display: CGDirectDisplayID, } /// Locate the tablet's own display so the pen maps 1:1 onto the tablet surface. -/// Match on the profile's native panel resolution; fall back to the main display. +/// Matched by EDID, then native panel size (see DisplayLocator); falls back to the main display. func cintiqDisplayBounds(_ profile: DeviceProfile) -> CGRect { - guard let panel = profile.panelSize else { return CGDisplayBounds(CGMainDisplayID()) } - var count: UInt32 = 0 - CGGetActiveDisplayList(0, nil, &count) - var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) - CGGetActiveDisplayList(count, &ids, &count) - for id in ids { - // Match on native pixel resolution; the panel may run HiDPI, so CGDisplayBounds - // returns the point-space rect used for cursor mapping. - guard let mode = CGDisplayCopyDisplayMode(id) else { continue } - if mode.pixelWidth == Int(panel.width) && mode.pixelHeight == Int(panel.height) { - let b = CGDisplayBounds(id) - FileHandle.standardError.write("nib: mapping to tablet display id=\(id) bounds=\(b)\n".data(using: .utf8)!) - return b - } + if let id = DisplayLocator.tabletDisplayID(for: profile) { + let b = CGDisplayBounds(id) + FileHandle.standardError.write("nib: mapping to tablet display id=\(id) bounds=\(b)\n".data(using: .utf8)!) + return b } FileHandle.standardError.write("nib: tablet display not found, using main display\n".data(using: .utf8)!) return CGDisplayBounds(CGMainDisplayID()) @@ -215,7 +205,7 @@ case "run": // In-app calibration: captures raw pen coords at two on-screen targets, then applies // them against the currently-active device's display bounds. - let calibration = CalibrationController(apply: { cal in + let calibration = CalibrationController(profile: { activeProfile }, apply: { cal in let m = DisplayMapping(targetBounds: cintiqDisplayBounds(activeProfile), xMin: cal.xMin, xMax: cal.xMax, yMin: cal.yMin, yMax: cal.yMax) emitter.setMapping(m) diff --git a/driver/Tests/NibCoreTests/DisplayLocatorTests.swift b/driver/Tests/NibCoreTests/DisplayLocatorTests.swift new file mode 100644 index 0000000..f533a44 --- /dev/null +++ b/driver/Tests/NibCoreTests/DisplayLocatorTests.swift @@ -0,0 +1,28 @@ +import XCTest +import CoreGraphics +@testable import NibCore + +final class DisplayLocatorTests: XCTestCase { + private let k4 = CGSize(width: 3840, height: 2160) + + func testEDIDMatchWinsOverAnotherDisplayWithTheSameSize() { + let other4K = DisplayLocator.Candidate(id: 2, vendor: 0x1e6d, model: 0x1, nativePixelSize: k4) + let cintiq = DisplayLocator.Candidate(id: 4, vendor: 0x5c23, model: 0x1056, nativePixelSize: k4) + XCTAssertEqual(DisplayLocator.choose([other4K, cintiq], for: .dth2420), 4) + } + + func testFallsBackToNativeSize() { + var p = DeviceProfile.dth2420 + p.displayModel = 0xdead // EDID doesn't match anything + let small = DisplayLocator.Candidate(id: 1, vendor: 0x0610, model: 0xa04f, + nativePixelSize: CGSize(width: 3024, height: 1964)) + let panel = DisplayLocator.Candidate(id: 7, vendor: 0x5c23, model: 0x1056, nativePixelSize: k4) + XCTAssertEqual(DisplayLocator.choose([small, panel], for: p), 7) + } + + func testNoMatchReturnsNil() { + let small = DisplayLocator.Candidate(id: 1, vendor: 0x0610, model: 0xa04f, + nativePixelSize: CGSize(width: 3024, height: 1964)) + XCTAssertNil(DisplayLocator.choose([small], for: .dth2420)) + } +} From 4bcdf609219b0b2d38bad9df917902bfc298c60a Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:42:48 -0500 Subject: [PATCH 4/9] Set click count on synthesized clicks so double-click works The window server doesn't derive .mouseEventClickState for posted events, so every pen or tap click arrived as a single click. ClickCounter tracks multi-clicks using the system double-click interval and a small distance threshold; pen tip, side buttons and tap-to-click all set it (on downs, drags and ups). Co-Authored-By: Claude Opus 5.5 --- driver/Sources/NibCore/ClickCounter.swift | 32 +++++++++++++++++ driver/Sources/NibCore/EventEmitter.swift | 36 +++++++++++++++---- driver/Sources/NibCore/TouchGesture.swift | 8 +++++ driver/Sources/nib/StatusBar.swift | 2 ++ .../NibCoreTests/ClickCounterTests.swift | 24 +++++++++++++ .../NibCoreTests/EventEmitterTests.swift | 11 ++++++ .../NibCoreTests/TouchGestureTests.swift | 10 ++++++ 7 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 driver/Sources/NibCore/ClickCounter.swift create mode 100644 driver/Tests/NibCoreTests/ClickCounterTests.swift diff --git a/driver/Sources/NibCore/ClickCounter.swift b/driver/Sources/NibCore/ClickCounter.swift new file mode 100644 index 0000000..85515c3 --- /dev/null +++ b/driver/Sources/NibCore/ClickCounter.swift @@ -0,0 +1,32 @@ +import Foundation +import CoreGraphics + +/// Computes the click count (`.mouseEventClickState`) for synthesized mouse-downs. The +/// window server does not derive it for posted events, so without this every click is a +/// single click and apps never see a double-click. +public struct ClickCounter: Sendable { + /// Max time between downs to continue a multi-click (set from NSEvent.doubleClickInterval). + public var interval: TimeInterval + /// Max distance (points) between downs to continue a multi-click. + public var maxDistance: Double + + private var lastDown: (time: TimeInterval, point: CGPoint)? + private var count = 0 + + public init(interval: TimeInterval = 0.5, maxDistance: Double = 6) { + self.interval = interval + self.maxDistance = maxDistance + } + + /// Register a mouse-down and return its click count (1 = single, 2 = double, …). + public mutating func down(at point: CGPoint, time: TimeInterval) -> Int { + if let last = lastDown, time - last.time <= interval, + hypot(point.x - last.point.x, point.y - last.point.y) <= maxDistance { + count += 1 + } else { + count = 1 + } + lastDown = (time, point) + return count + } +} diff --git a/driver/Sources/NibCore/EventEmitter.swift b/driver/Sources/NibCore/EventEmitter.swift index 913780f..13993a3 100644 --- a/driver/Sources/NibCore/EventEmitter.swift +++ b/driver/Sources/NibCore/EventEmitter.swift @@ -31,13 +31,29 @@ public final class EventEmitter { /// Reports raw normalized pressure [0,1] each pointer event (for the prefs preview). public var pressureObserver: ((Double) -> Void)? + /// Multi-click detection for the tip and each side button. + private var tipClicks = ClickCounter(), lowerClicks = ClickCounter(), upperClicks = ClickCounter() + /// Click state of the current tip press / held side buttons (carried on drags and ups). + private var tipClickState = 1, lowerClickState = 1, upperClickState = 1 + /// System double-click interval (the app sets it from NSEvent.doubleClickInterval). + public var doubleClickInterval: TimeInterval = 0.5 { + didSet { + tipClicks.interval = doubleClickInterval + lowerClicks.interval = doubleClickInterval + upperClicks.interval = doubleClickInterval + } + } + private let sink: EventSink + private let now: () -> TimeInterval public init(mapping: DisplayMapping, deviceID: UInt16 = 1, - sink: EventSink = SystemEventSink()) { + sink: EventSink = SystemEventSink(), + now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { self.mapping = mapping self.deviceID = deviceID self.sink = sink + self.now = now } public func setMapping(_ m: DisplayMapping) { self.mapping = m } @@ -68,10 +84,11 @@ public final class EventEmitter { let pt = lastPoint if wasTipDown, let e = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: pt, mouseButton: .left) { + e.setIntegerValueField(.mouseEventClickState, value: Int64(tipClickState)) sink.post(e) } - if wasBarrel { postButton(lowerBarrelAction, down: false, at: pt) } - if wasSecondary { postButton(upperBarrelAction, down: false, at: pt) } + if wasBarrel { postButton(lowerBarrelAction, down: false, at: pt, clicks: lowerClickState) } + if wasSecondary { postButton(upperBarrelAction, down: false, at: pt, clicks: upperClickState) } if wasInProximity { postProximity(entering: false, inverted: lastInverted) } wasTipDown = false; wasBarrel = false; wasSecondary = false; wasInProximity = false } @@ -81,16 +98,18 @@ public final class EventEmitter { private func handleButtons(_ s: PenSample) { let pt = mapping.point(x: s.x, y: s.y) if s.barrelButton != wasBarrel { - postButton(lowerBarrelAction, down: s.barrelButton, at: pt) + if s.barrelButton { lowerClickState = lowerClicks.down(at: pt, time: now()) } + postButton(lowerBarrelAction, down: s.barrelButton, at: pt, clicks: lowerClickState) wasBarrel = s.barrelButton } if s.secondaryBarrel != wasSecondary { - postButton(upperBarrelAction, down: s.secondaryBarrel, at: pt) + if s.secondaryBarrel { upperClickState = upperClicks.down(at: pt, time: now()) } + postButton(upperBarrelAction, down: s.secondaryBarrel, at: pt, clicks: upperClickState) wasSecondary = s.secondaryBarrel } } - private func postButton(_ action: PenButtonAction, down: Bool, at pt: CGPoint) { + private func postButton(_ action: PenButtonAction, down: Bool, at pt: CGPoint, clicks: Int) { let type: CGEventType let button: CGMouseButton switch action { @@ -101,6 +120,7 @@ public final class EventEmitter { } guard let e = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: pt, mouseButton: button) else { return } + e.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) sink.post(e) } @@ -115,9 +135,13 @@ public final class EventEmitter { else if s.tipDown { type = .leftMouseDragged } else { type = .mouseMoved } wasTipDown = s.tipDown + if type == .leftMouseDown { tipClickState = tipClicks.down(at: pt, time: now()) } guard let e = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: pt, mouseButton: .left) else { return } + if type != .mouseMoved { + e.setIntegerValueField(.mouseEventClickState, value: Int64(tipClickState)) + } let rawPressure = Double(s.pressure) / pressureMax // 0.0 ... 1.0 pressureObserver?(rawPressure) diff --git a/driver/Sources/NibCore/TouchGesture.swift b/driver/Sources/NibCore/TouchGesture.swift index ba77125..e549d04 100644 --- a/driver/Sources/NibCore/TouchGesture.swift +++ b/driver/Sources/NibCore/TouchGesture.swift @@ -28,6 +28,12 @@ public final class TouchGestureEngine { private var tapActive = false private var tapStart: (x: Double, y: Double, t: TimeInterval) = (0, 0, 0) private var tapMoved = 0.0 + private var tapClicks = ClickCounter(maxDistance: 20) // fingers land less precisely than a pen + /// System double-click interval (the app sets it from NSEvent.doubleClickInterval). + public var doubleClickInterval: TimeInterval { + get { tapClicks.interval } + set { tapClicks.interval = newValue } + } /// Three-finger swipe → Mission Control shortcuts (switch spaces / Mission Control / /// App Exposé). Uses the default macOS keyboard shortcuts, so they must be enabled @@ -147,9 +153,11 @@ public final class TouchGestureEngine { private func emitTapClick(x: Int, y: Int) { guard let m = tapMapping else { return } 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, mouseCursorPosition: pt, mouseButton: .left) else { continue } + e.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) sink.post(e) } } diff --git a/driver/Sources/nib/StatusBar.swift b/driver/Sources/nib/StatusBar.swift index a192a08..caced93 100644 --- a/driver/Sources/nib/StatusBar.swift +++ b/driver/Sources/nib/StatusBar.swift @@ -76,6 +76,8 @@ final class StatusBarController: NSObject, NSMenuDelegate { emitter.lowerBarrelAction = config.lowerBarrel emitter.upperBarrelAction = config.upperBarrel emitter.pressureCurve = config.pressureCurve + emitter.doubleClickInterval = NSEvent.doubleClickInterval + touch.doubleClickInterval = NSEvent.doubleClickInterval } /// Apply to the engines and persist. Also keeps the prefs window in sync. diff --git a/driver/Tests/NibCoreTests/ClickCounterTests.swift b/driver/Tests/NibCoreTests/ClickCounterTests.swift new file mode 100644 index 0000000..4756c5f --- /dev/null +++ b/driver/Tests/NibCoreTests/ClickCounterTests.swift @@ -0,0 +1,24 @@ +import XCTest +import CoreGraphics +@testable import NibCore + +final class ClickCounterTests: XCTestCase { + func testDoubleAndTripleClickWithinInterval() { + var c = ClickCounter(interval: 0.5) + XCTAssertEqual(c.down(at: .zero, time: 0), 1) + XCTAssertEqual(c.down(at: CGPoint(x: 2, y: 2), time: 0.3), 2) + XCTAssertEqual(c.down(at: .zero, time: 0.6), 3) + } + + func testResetsAfterTimeout() { + var c = ClickCounter(interval: 0.5) + _ = c.down(at: .zero, time: 0) + XCTAssertEqual(c.down(at: .zero, time: 0.8), 1) + } + + func testResetsWhenTooFarApart() { + var c = ClickCounter(interval: 0.5, maxDistance: 6) + _ = c.down(at: .zero, time: 0) + XCTAssertEqual(c.down(at: CGPoint(x: 50, y: 0), time: 0.1), 1) + } +} diff --git a/driver/Tests/NibCoreTests/EventEmitterTests.swift b/driver/Tests/NibCoreTests/EventEmitterTests.swift index 6670efb..a52bdb3 100644 --- a/driver/Tests/NibCoreTests/EventEmitterTests.swift +++ b/driver/Tests/NibCoreTests/EventEmitterTests.swift @@ -36,4 +36,15 @@ final class EventEmitterTests: XCTestCase { em.reset() // idempotent: nothing left to release XCTAssertTrue(sink.events.isEmpty) } + + func testPenDoubleTapIsADoubleClick() { + let sink = RecordingSink(), clock = TestClock() + let em = EventEmitter(mapping: DisplayMapping(targetBounds: CGRect(x: 0, y: 0, width: 1920, height: 1080)), + sink: sink, now: { clock.t }) + em.handle(sample(tip: true)); em.handle(sample()) + clock.advance(0.2) + em.handle(sample(tip: true)); em.handle(sample()) + let states = sink.clicks.map { $0.getIntegerValueField(.mouseEventClickState) } + XCTAssertEqual(states, [1, 1, 2, 2]) + } } diff --git a/driver/Tests/NibCoreTests/TouchGestureTests.swift b/driver/Tests/NibCoreTests/TouchGestureTests.swift index 22a325c..3f6b034 100644 --- a/driver/Tests/NibCoreTests/TouchGestureTests.swift +++ b/driver/Tests/NibCoreTests/TouchGestureTests.swift @@ -59,4 +59,14 @@ final class TouchGestureTests: XCTestCase { engine.handle(frame([])) XCTAssertTrue(sink.events.isEmpty) } + + func testDoubleTapIsADoubleClick() { + for _ in 0..<2 { + engine.handle(frame([(7680, 4320)])) + clock.advance(0.1) + engine.handle(frame([])) + clock.advance(0.1) + } + XCTAssertEqual(sink.events.map { $0.getIntegerValueField(.mouseEventClickState) }, [1, 1, 2, 2]) + } } From ecf88e52876e82e25a0e23c19ab7990f0737f63c Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:43:28 -0500 Subject: [PATCH 5/9] Stop watch-only HID managers receiving reports; per-device buffers The vendor-wide hotplug watcher registered an input-report callback on every Wacom device and copied each pen report into a Swift array only to discard it. HIDTransport's onReport is now optional; with none, the C shim registers no report callback. Each matched device also gets its own buffer (sized from kIOHIDMaxInputReportSizeKey) instead of all devices sharing one 64-byte array, writes fall back to another matched device on removal, and allocation failures are handled. Co-Authored-By: Claude Opus 5.5 --- driver/Sources/CNibHID/CNibHID.c | 56 +++++++++++++++++++--- driver/Sources/CNibHID/include/CNibHID.h | 4 +- driver/Sources/NibCore/DeviceManager.swift | 3 +- driver/Sources/NibCore/HIDTransport.swift | 26 ++++++---- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/driver/Sources/CNibHID/CNibHID.c b/driver/Sources/CNibHID/CNibHID.c index bffbef6..e442ad3 100644 --- a/driver/Sources/CNibHID/CNibHID.c +++ b/driver/Sources/CNibHID/CNibHID.c @@ -5,16 +5,34 @@ #include #include +// One matched device and its own input-report buffer (IOKit writes each report into the +// buffer registered for that device, so devices must not share one). +typedef struct NibHIDDeviceNode { + IOHIDDeviceRef device; + uint8_t *buffer; // NULL when the context has no report callback + struct NibHIDDeviceNode *next; +} NibHIDDeviceNode; + struct NibHIDContext { IOHIDManagerRef manager; - IOHIDDeviceRef device; // first matched device + IOHIDDeviceRef device; // device used for feature/output writes + NibHIDDeviceNode *devices; // every currently matched device uint16_t vendorID, productID; NibHIDReportCallback onReport; NibHIDDeviceCallback onAttach, onDetach; void *userContext; - uint8_t inputBuffer[64]; }; +enum { kNibDefaultInputReportSize = 64 }; + +static size_t maxInputReportSize(IOHIDDeviceRef dev) { + CFTypeRef p = IOHIDDeviceGetProperty(dev, CFSTR(kIOHIDMaxInputReportSizeKey)); + int v = 0; + if (p && CFGetTypeID(p) == CFNumberGetTypeID()) + CFNumberGetValue((CFNumberRef)p, kCFNumberIntType, &v); + return v > 0 ? (size_t)v : kNibDefaultInputReportSize; +} + // Read a numeric IOHIDDevice property (e.g. VendorID/ProductID). Returns 0 if absent. static uint16_t readDeviceID(IOHIDDeviceRef dev, CFStringRef key) { CFTypeRef p = IOHIDDeviceGetProperty(dev, key); // "get" rule: not owned, no release @@ -36,10 +54,21 @@ static void inputReportCB(void *context, IOReturn result, void *sender, static void matchCB(void *context, IOReturn result, void *sender, IOHIDDeviceRef dev) { (void)result; (void)sender; NibHIDContext *ctx = (NibHIDContext *)context; + NibHIDDeviceNode *node = calloc(1, sizeof(*node)); + if (!node) return; + node->device = dev; + // Watch-only contexts (no report callback) skip report delivery entirely, so they cost + // nothing per report. + if (ctx->onReport) { + size_t len = maxInputReportSize(dev); + node->buffer = calloc(1, len); + if (node->buffer) + IOHIDDeviceRegisterInputReportCallback(dev, node->buffer, (CFIndex)len, + inputReportCB, ctx); + } + node->next = ctx->devices; + ctx->devices = node; if (!ctx->device) ctx->device = dev; - IOHIDDeviceRegisterInputReportCallback(dev, ctx->inputBuffer, - sizeof(ctx->inputBuffer), - inputReportCB, ctx); // Report the device's real IDs (the match filter may be vendor-only, productID = 0). if (ctx->onAttach) ctx->onAttach(ctx->userContext, readDeviceID(dev, CFSTR(kIOHIDVendorIDKey)), @@ -51,7 +80,17 @@ static void removalCB(void *context, IOReturn result, void *sender, IOHIDDeviceR NibHIDContext *ctx = (NibHIDContext *)context; uint16_t vid = readDeviceID(dev, CFSTR(kIOHIDVendorIDKey)); uint16_t pid = readDeviceID(dev, CFSTR(kIOHIDProductIDKey)); - if (ctx->device == dev) ctx->device = NULL; + for (NibHIDDeviceNode **pp = &ctx->devices; *pp; pp = &(*pp)->next) { + if ((*pp)->device == dev) { + NibHIDDeviceNode *gone = *pp; + *pp = gone->next; + free(gone->buffer); + free(gone); + break; + } + } + // Fall back to another still-matched device for writes, if any. + if (ctx->device == dev) ctx->device = ctx->devices ? ctx->devices->device : NULL; if (ctx->onDetach) ctx->onDetach(ctx->userContext, vid, pid); } @@ -75,6 +114,7 @@ static CFDictionaryRef makeMatch(uint16_t vid, uint16_t pid) { size_t nib_hid_enumerate(uint16_t vendorID, NibHIDDeviceInfo *out, size_t maxOut) { if (!out || maxOut == 0) return 0; IOHIDManagerRef mgr = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); + if (!mgr) return 0; if (vendorID) { CFDictionaryRef m = makeMatch(vendorID, 0); IOHIDManagerSetDeviceMatching(mgr, m); @@ -111,10 +151,12 @@ NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID, NibHIDDeviceCallback onDetach, void *userContext) { NibHIDContext *ctx = calloc(1, sizeof(*ctx)); + if (!ctx) return NULL; ctx->vendorID = vendorID; ctx->productID = productID; ctx->onReport = onReport; ctx->onAttach = onAttach; ctx->onDetach = onDetach; ctx->userContext = userContext; ctx->manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); + if (!ctx->manager) { free(ctx); return NULL; } CFDictionaryRef m = makeMatch(vendorID, productID); IOHIDManagerSetDeviceMatching(ctx->manager, m); CFRelease(m); @@ -159,5 +201,7 @@ void nib_hid_stop(NibHIDContext *ctx) { void nib_hid_free(NibHIDContext *ctx) { if (!ctx) return; if (ctx->manager) CFRelease(ctx->manager); + NibHIDDeviceNode *n = ctx->devices; + while (n) { NibHIDDeviceNode *next = n->next; free(n->buffer); free(n); n = next; } free(ctx); } diff --git a/driver/Sources/CNibHID/include/CNibHID.h b/driver/Sources/CNibHID/include/CNibHID.h index 3722561..1a99f0f 100644 --- a/driver/Sources/CNibHID/include/CNibHID.h +++ b/driver/Sources/CNibHID/include/CNibHID.h @@ -35,7 +35,9 @@ typedef struct { size_t nib_hid_enumerate(uint16_t vendorID, NibHIDDeviceInfo *out, size_t maxOut); // Create a manager that matches (vendorID, productID). Pass productID = 0 to -// match any product for the vendor. Does not open until nib_hid_run(). +// match any product for the vendor. Pass onReport = NULL for a watch-only manager +// (attach/detach callbacks, no report delivery). Does not open until nib_hid_start(). +// Returns NULL if allocation fails. NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID, NibHIDReportCallback onReport, NibHIDDeviceCallback onAttach, diff --git a/driver/Sources/NibCore/DeviceManager.swift b/driver/Sources/NibCore/DeviceManager.swift index 27b4c89..b6d19f6 100644 --- a/driver/Sources/NibCore/DeviceManager.swift +++ b/driver/Sources/NibCore/DeviceManager.swift @@ -53,8 +53,7 @@ public final class DeviceManager { let watcher = HIDTransport( vendorID: v, productID: 0, // any product for this vendor onAttach: { [weak self] in self?.reconcile() }, - onDetach: { [weak self] in self?.reconcile() }, - onReport: { _, _ in }) // watch-only; no report decoding + onDetach: { [weak self] in self?.reconcile() }) // watch-only: no reports watcher.start() watchers.append(watcher) } diff --git a/driver/Sources/NibCore/HIDTransport.swift b/driver/Sources/NibCore/HIDTransport.swift index 5b73971..8f83308 100644 --- a/driver/Sources/NibCore/HIDTransport.swift +++ b/driver/Sources/NibCore/HIDTransport.swift @@ -10,7 +10,7 @@ public final class HIDTransport { private var selfRef: Unmanaged? // the +1 retain given to the C userContext private let vendorID: UInt16 private let productID: UInt16 - private let onReport: ReportHandler + private let onReport: ReportHandler? private let onAttach: (() -> Void)? private let onDetach: (() -> Void)? @@ -33,7 +33,7 @@ public final class HIDTransport { public init(vendorID: UInt16, productID: UInt16, onAttach: (() -> Void)? = nil, onDetach: (() -> Void)? = nil, - onReport: @escaping ReportHandler) { + onReport: ReportHandler? = nil) { self.vendorID = vendorID self.productID = productID self.onAttach = onAttach @@ -47,16 +47,18 @@ public final class HIDTransport { // Keep `self` alive for the C callbacks via a +1 retain handed to userContext; // `stop()` balances it. This matters because transports are now ephemeral (a fresh // pen/touch pair per hotplug), so an unbalanced retain would leak each swap. + guard ctx == nil else { return } // already started let ref = Unmanaged.passRetained(self) - selfRef = ref + // nil report handler → watch-only: the C layer skips report delivery entirely. + let reportCB: NibHIDReportCallback? = onReport == nil ? nil : { userCtx, reportID, report, length in + guard let userCtx, let report else { return } + let me = Unmanaged.fromOpaque(userCtx).takeUnretainedValue() + let payload = Array(UnsafeBufferPointer(start: report, count: length)) + me.onReport?(reportID, payload) + } ctx = nib_hid_create( vendorID, productID, - { userCtx, reportID, report, length in - guard let userCtx, let report else { return } - let me = Unmanaged.fromOpaque(userCtx).takeUnretainedValue() - let payload = Array(UnsafeBufferPointer(start: report, count: length)) - me.onReport(reportID, payload) - }, + reportCB, { userCtx, _, _ in guard let userCtx else { return } let me = Unmanaged.fromOpaque(userCtx).takeUnretainedValue() @@ -69,6 +71,12 @@ public final class HIDTransport { }, ref.toOpaque() ) + guard let ctx else { + ref.release() // creation failed: no callbacks will ever use the retain + FileHandle.standardError.write("nib: failed to create HID manager for \(String(format: "%04x:%04x", vendorID, productID))\n".data(using: .utf8)!) + return + } + selfRef = ref nib_hid_start(ctx) } From daabc3eba4001f9e75faa77692fc72ee6e9933f5 Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:43:47 -0500 Subject: [PATCH 6/9] Take vendor ID and pressure range from the active profile EventEmitter hard-coded Wacom's vendor ID and the DTH2420's 8191 pressure max, so a model with a different pressure resolution would be scaled wrongly. setProfile() adopts them from the DeviceProfile whenever mappings are rebuilt. Co-Authored-By: Claude Opus 5.5 --- driver/Sources/NibCore/EventEmitter.swift | 11 +++++++++-- driver/Sources/nib/main.swift | 1 + driver/Tests/NibCoreTests/EventEmitterTests.swift | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/driver/Sources/NibCore/EventEmitter.swift b/driver/Sources/NibCore/EventEmitter.swift index 13993a3..d2a80f9 100644 --- a/driver/Sources/NibCore/EventEmitter.swift +++ b/driver/Sources/NibCore/EventEmitter.swift @@ -20,8 +20,9 @@ public final class EventEmitter { private var lastPoint = CGPoint.zero private var lastInverted = false private let deviceID: UInt16 - private let vendorID: UInt16 = 0x056a - private let pressureMax = Double(PenSample.pressureLogicalMax) + /// From the active profile (see `setProfile`); DTH2420 values until one is set. + private var vendorID: UInt16 = DeviceProfile.dth2420.vendorID + private var pressureMax = Double(DeviceProfile.dth2420.pen.pressureLogicalMax) /// Verified: lower side button = `barrelButton`, upper = `secondaryBarrel`. public var lowerBarrelAction: PenButtonAction = .rightClick @@ -58,6 +59,12 @@ public final class EventEmitter { public func setMapping(_ m: DisplayMapping) { self.mapping = m } + /// Adopt the model-specific values (USB vendor, pressure range) of the active device. + public func setProfile(_ p: DeviceProfile) { + vendorID = p.vendorID + pressureMax = Double(max(1, p.pen.pressureLogicalMax)) + } + public func handle(_ s: PenSample) { // Proximity transitions -> tablet proximity events. if s.inRange && !wasInProximity { diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index 728e4ec..3272eb3 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -197,6 +197,7 @@ case "run": log("no calibration found, using full pen range") } emitter.setMapping(penMap) + emitter.setProfile(profile) touchGesture.tapMapping = DisplayMapping( targetBounds: bounds, xMin: 0, xMax: Double(profile.touch.xLogicalMax), diff --git a/driver/Tests/NibCoreTests/EventEmitterTests.swift b/driver/Tests/NibCoreTests/EventEmitterTests.swift index a52bdb3..c3b6e25 100644 --- a/driver/Tests/NibCoreTests/EventEmitterTests.swift +++ b/driver/Tests/NibCoreTests/EventEmitterTests.swift @@ -47,4 +47,18 @@ final class EventEmitterTests: XCTestCase { let states = sink.clicks.map { $0.getIntegerValueField(.mouseEventClickState) } XCTAssertEqual(states, [1, 1, 2, 2]) } + + func testPressureIsScaledByTheProfilesMax() { + let sink = RecordingSink(), em = makeEmitter(sink) + var p = DeviceProfile.dth2420 + p.pen.pressureLogicalMax = 2047 // a lower-resolution model + em.setProfile(p) + em.handle(sample(tip: true)) // pressure 4000 > 2047 → clamped to 1.0 + let down = sink.events.first { $0.type == .leftMouseDown }! + XCTAssertEqual(down.getDoubleValueField(.mouseEventPressure), 1.0, accuracy: 1e-6) + em.setProfile(.dth2420) + em.handle(sample(tip: true)) // 4000 / 8191 + XCTAssertEqual(sink.events.last { $0.type == .leftMouseDragged }! + .getDoubleValueField(.tabletEventPointPressure), 4000.0 / 8191.0, accuracy: 1e-3) + } } From 5db9be7f4211dfd699d716788b63385f3351f7fc Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:44:17 -0500 Subject: [PATCH 7/9] Make nib capture passive by default; add --send-enable Capture used to send the DTH2420's vendor feature/output reports to every interface of every Wacom device, where they may mean something else. It now only listens unless --send-enable is given; ADDING-A-DEVICE.md describes running passive first. Co-Authored-By: Claude Opus 5.5 --- docs/ADDING-A-DEVICE.md | 24 ++++++++++++++++++------ driver/Sources/nib/main.swift | 22 +++++++++++++++++----- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/docs/ADDING-A-DEVICE.md b/docs/ADDING-A-DEVICE.md index 090d010..9937655 100644 --- a/docs/ADDING-A-DEVICE.md +++ b/docs/ADDING-A-DEVICE.md @@ -46,13 +46,25 @@ Precision-Touchpad-style digitizer). Note both PIDs. ## Step 2 — capture labeled gestures -`nib capture` opens every connected interface of the known vendors, sends the best-guess -Wacom enable sequences, and prints every report as raw hex tagged by PID: +`nib capture` opens every connected interface of the known vendors and prints every report +as raw hex tagged by PID. By default it only **listens** — it sends nothing to the tablet: ``` nib capture > my-device-capture.txt ``` +If the pen produces no high-res report this way, run it again with `--send-enable`, which +also sends the DTH2420's mode-switch and touch-enable reports to every interface (these +work across much of the Wacom family): + +``` +nib capture --send-enable > my-device-capture-enabled.txt +``` + +`--send-enable` writes vendor-specific reports whose meaning on another model is unknown, +so only use it when the passive capture shows no pen report, and say in the bundle which +mode produced each file. Unplugging and replugging the tablet resets it. + Then perform each gesture below **one at a time**, pausing between them, and keep a note of which lines correspond to which gesture (timestamps or "I did X now" markers help): @@ -66,8 +78,8 @@ which lines correspond to which gesture (timestamps or "I did X now" markers hel **Touch (find the touch PID's lines):** - **One finger** down at a known spot, **two fingers**, **three fingers**. -> If the pen produces **no** high-res report under `nib capture`, its mode-switch differs -> from the DTH2420's. That's the one part the descriptor can't give you — note it in the +> If the pen produces **no** high-res report even under `nib capture --send-enable`, its +> mode-switch differs from the DTH2420's. That's the one part the descriptor can't give you — note it in the > bundle; it needs a person with the device to help recover (see "What still needs the > hardware"). @@ -163,8 +175,8 @@ The descriptor gives the **decode layout** for free. It does **not** give: - **The pen mode-switch** (`initSequence`) — some models only emit their high-res report after a vendor feature write. For the DTH2420 this was recovered by decompiling Wacom's driver (`recon/`), not from any descriptor. If `nib capture` shows no pen report, this is - why, and recovering it needs someone with the device (try the DTH2420 sequence first; it - covers much of the Wacom family). + why, and recovering it needs someone with the device (try `nib capture --send-enable` + first; the DTH2420 sequence covers much of the Wacom family). - **The touch-enable sequence** (`touchEnable`) — likewise for the multitouch sensor. - **Final sign-off** — that pen tracking, pressure curve and gestures actually feel right. diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index 3272eb3..6a99853 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -263,11 +263,19 @@ case "run": case "capture": // Raw multi-interface HID dump for bringing up a NEW device. Opens every connected - // interface of each known vendor, sends the best-guess Wacom enable sequences, and - // dumps every report as raw hex tagged by PID. Run this, then perform the labeled - // gestures from docs/ADDING-A-DEVICE.md (corners, hard press, lift, 1/2/3-finger - // touches) to produce a capture bundle for a new DeviceProfile + fixtures. + // interface of each known vendor and dumps every report as raw hex tagged by PID; with + // --send-enable it first sends the best-guess (DTH2420) Wacom enable sequences. Run + // this, then perform the labeled gestures from docs/ADDING-A-DEVICE.md (corners, hard + // press, lift, 1/2/3-finger touches) to produce a capture bundle for a new + // DeviceProfile + fixtures. + // Passive by default: the DTH2420's vendor reports may mean something else on another + // model, so they are only sent when the contributor opts in with --send-enable. + let sendEnable = args.dropFirst(2).contains("--send-enable") FileHandle.standardError.write("nib: CAPTURE mode — raw report dump for a new device (Ctrl-C to stop)\n".data(using: .utf8)!) + FileHandle.standardError.write((sendEnable + ? "nib: --send-enable: sending the DTH2420 mode-switch + touch-enable to every interface\n" + : "nib: passive capture (nothing is sent to the tablet; add --send-enable if the pen shows no reports)\n") + .data(using: .utf8)!) let guess = DeviceProfile.dth2420 // best-guess enable sequences for the Wacom family var ifaces: [(vendor: UInt16, pid: UInt16)] = [] for v in Set(DeviceProfile.all.map({ $0.vendorID })) { @@ -288,6 +296,10 @@ case "capture": t = HIDTransport( vendorID: iface.vendor, productID: iface.pid, onAttach: { + guard sendEnable else { + FileHandle.standardError.write(String(format: "nib: opened 0x%04x (passive)\n", iface.pid).data(using: .utf8)!) + return + } FileHandle.standardError.write(String(format: "nib: opened 0x%04x; sending best-guess enable\n", iface.pid).data(using: .utf8)!) _ = t.applyFeatures(guess.initSequence) // best effort; failures are expected on non-pen interfaces _ = t.applyOutputs(guess.touchEnable) // best effort; failures are expected on non-touch interfaces @@ -304,6 +316,6 @@ case "capture": CFRunLoopRun() default: - FileHandle.standardError.write("usage: nib [run|log|touchlog|calibrate|capture]\n".data(using: .utf8)!) + FileHandle.standardError.write("usage: nib [run|log|touchlog|calibrate|capture [--send-enable]]\n".data(using: .utf8)!) exit(2) } From 1071a9cdc948e4015d39c83777c31978f35fc6d2 Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:45:24 -0500 Subject: [PATCH 8/9] Share calibration logic in NibCore and reject implausible point pairs The CLI calibrate command and the in-app overlay each had a copy of the hold-detection state machine and solved without checks, so tapping the same spot twice gave xMin == xMax and a divide-by-zero in the mapping. CalibrationSolver now holds the state machine and rejects pairs that are too close (<10% of the logical range) or reversed, restarting at point 1. DisplayMapping also maps a degenerate range to 0 instead of NaN. Co-Authored-By: Claude Opus 5.5 --- .../Sources/NibCore/CalibrationSolver.swift | 107 ++++++++++++++++++ driver/Sources/NibCore/DisplayMapping.swift | 15 ++- driver/Sources/nib/Calibration.swift | 61 ++++------ driver/Sources/nib/main.swift | 63 ++++------- .../NibCoreTests/CalibrationSolverTests.swift | 66 +++++++++++ 5 files changed, 224 insertions(+), 88 deletions(-) create mode 100644 driver/Sources/NibCore/CalibrationSolver.swift create mode 100644 driver/Tests/NibCoreTests/CalibrationSolverTests.swift diff --git a/driver/Sources/NibCore/CalibrationSolver.swift b/driver/Sources/NibCore/CalibrationSolver.swift new file mode 100644 index 0000000..fe0544e --- /dev/null +++ b/driver/Sources/NibCore/CalibrationSolver.swift @@ -0,0 +1,107 @@ +import CoreGraphics + +/// Two-point pen calibration, shared by the `calibrate` CLI and the in-app overlay. +/// +/// The user holds the pen tip on target 1, lifts, then holds it on target 2. A hold counts +/// once `holdSamples` consecutive tip-down samples stay within `tolerance` raw units. The two +/// captured raw points and the targets' known positions (as fractions of the display) give +/// the raw pen range that spans the display. +public struct CalibrationSolver { + public enum Event: Equatable { + case none + /// Target 1 captured; waiting for the pen to lift. + case firstCaptured + /// Pen lifted after target 1; now waiting on target 2. + case awaitingSecond + /// Both targets captured and the result is plausible. + case finished(Calibration) + /// The two points were too close or in the wrong order; restarted at target 1. + case rejected + } + + /// Target positions as fractions of the display (0...1), target 1 before target 2. + public let target1: CGPoint + public let target2: CGPoint + public var holdSamples = 40 + public var tolerance = 1200 + /// The two raw points must be at least this fraction of the logical range apart on + /// each axis (in the direction of the targets). + public var minSpanFraction = 0.1 + private let xLogicalMax: Double + private let yLogicalMax: Double + + private enum Stage { case holdFirst, lift, holdSecond, done } + private var stage = Stage.holdFirst + private var buf: [(Int, Int)] = [] + private var raw1 = (0.0, 0.0) + + public init(target1: CGPoint, target2: CGPoint, pen: PenLayout) { + self.target1 = target1 + self.target2 = target2 + self.xLogicalMax = Double(pen.xLogicalMax) + self.yLogicalMax = Double(pen.yLogicalMax) + } + + /// Fraction position of the target currently being asked for. + public var currentTarget: CGPoint { stage == .holdFirst ? target1 : target2 } + + public mutating func reset() { stage = .holdFirst; buf.removeAll() } + + public mutating func feed(_ s: PenSample) -> Event { + guard stage != .done else { return .none } + if !s.tipDown || !s.inRange { + buf.removeAll() + if stage == .lift { stage = .holdSecond; return .awaitingSecond } + return .none + } + guard stage == .holdFirst || stage == .holdSecond else { buf.removeAll(); return .none } + buf.append((s.x, s.y)) + if buf.count > holdSamples { buf.removeFirst() } + guard buf.count == holdSamples, isSteady() else { return .none } + + let m = mean() + buf.removeAll() + if stage == .holdFirst { + raw1 = m; stage = .lift + return .firstCaptured + } + if let cal = Self.solve(raw1: raw1, raw2: m, target1: target1, target2: target2, + xLogicalMax: xLogicalMax, yLogicalMax: yLogicalMax, + minSpanFraction: minSpanFraction) { + stage = .done + return .finished(cal) + } + stage = .holdFirst + return .rejected + } + + /// Solve raw = a·fraction + b on each axis and return the raw range at fractions 0 and 1, + /// or nil if the points are implausible (too close, or ordered against the targets). + public static func solve(raw1: (Double, Double), raw2: (Double, Double), + target1: CGPoint, target2: CGPoint, + xLogicalMax: Double, yLogicalMax: Double, + minSpanFraction: Double = 0.1) -> Calibration? { + func axis(_ r1: Double, _ r2: Double, _ f1: Double, _ f2: Double, _ logicalMax: Double) -> (Double, Double)? { + guard f1 != f2 else { return nil } + let towardTarget2 = (r2 - r1) * (f2 > f1 ? 1 : -1) + guard towardTarget2 >= minSpanFraction * logicalMax else { return nil } + let a = (r2 - r1) / (f2 - f1) + let b = r1 - a * f1 + return (b, a + b) + } + guard let (xMin, xMax) = axis(raw1.0, raw2.0, Double(target1.x), Double(target2.x), xLogicalMax), + let (yMin, yMax) = axis(raw1.1, raw2.1, Double(target1.y), Double(target2.y), yLogicalMax) + else { return nil } + return Calibration(xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax) + } + + private func isSteady() -> Bool { + let xs = buf.map { $0.0 }, ys = buf.map { $0.1 } + return xs.max()! - xs.min()! < tolerance && ys.max()! - ys.min()! < tolerance + } + + private func mean() -> (Double, Double) { + (buf.reduce(0.0) { $0 + Double($1.0) } / Double(buf.count), + buf.reduce(0.0) { $0 + Double($1.1) } / Double(buf.count)) + } +} diff --git a/driver/Sources/NibCore/DisplayMapping.swift b/driver/Sources/NibCore/DisplayMapping.swift index 7636906..82052c2 100644 --- a/driver/Sources/NibCore/DisplayMapping.swift +++ b/driver/Sources/NibCore/DisplayMapping.swift @@ -23,20 +23,27 @@ public struct DisplayMapping: Sendable { /// Map raw tablet (x, y) to a global display point. public func point(x: Int, y: Int) -> CGPoint { - let fx = clamp((Double(x) - xMin) / (xMax - xMin)) - let fy = clamp((Double(y) - yMin) / (yMax - yMin)) + let fx = fraction(Double(x), xMin, xMax) + let fy = fraction(Double(y), yMin, yMax) return CGPoint( x: targetBounds.minX + fx * targetBounds.width, y: targetBounds.minY + fy * targetBounds.height ) } - private func clamp(_ v: Double) -> Double { min(1, max(0, v)) } + /// Position of `v` within `lo...hi`, clamped to [0,1]. A degenerate (zero-width or + /// non-finite) range maps to 0 rather than producing NaN. + private func fraction(_ v: Double, _ lo: Double, _ hi: Double) -> Double { + let span = hi - lo + guard span != 0, span.isFinite else { return 0 } + let f = (v - lo) / span + return f.isFinite ? min(1, max(0, f)) : 0 + } } /// Calibration constants (the raw pen active-range), persisted to disk so `run` picks /// them up. Bounds are recomputed live from the Cintiq display each launch. -public struct Calibration: Codable, Sendable { +public struct Calibration: Codable, Sendable, Equatable { public var xMin, xMax, yMin, yMax: Double public init(xMin: Double, xMax: Double, yMin: Double, yMax: Double) { self.xMin = xMin; self.xMax = xMax; self.yMin = yMin; self.yMax = yMax diff --git a/driver/Sources/nib/Calibration.swift b/driver/Sources/nib/Calibration.swift index 6c523b5..fc2c311 100644 --- a/driver/Sources/nib/Calibration.swift +++ b/driver/Sources/nib/Calibration.swift @@ -21,10 +21,7 @@ final class CalibrationController: ObservableObject { // Target positions as a fraction of the display (inset from the corners). private let p1 = CGPoint(x: 0.1, y: 0.1) private let p2 = CGPoint(x: 0.9, y: 0.9) - private var state = 0 // 0: hold P1, 1: lift, 2: hold P2 - private var buf: [(Int, Int)] = [] - private var rawP1 = (0.0, 0.0) - private let K = 40, tol = 1200 + private var solver: CalibrationSolver? init(profile: @escaping () -> DeviceProfile, apply: @escaping (Calibration) -> Void) { self.profile = profile @@ -33,7 +30,7 @@ final class CalibrationController: ObservableObject { func begin(bounds: CGRect) { self.bounds = bounds - state = 0; buf.removeAll() + solver = CalibrationSolver(target1: p1, target2: p2, pen: profile().pen) targetFraction = p1; pointLabel = "Point 1 of 2" instruction = "Press the pen tip on the crosshair and hold still" isActive = true @@ -47,44 +44,24 @@ final class CalibrationController: ObservableObject { /// Fed from the pen report stream while calibration is active. func feed(_ s: PenSample) { - guard isActive else { return } - if !s.tipDown || !s.inRange { - if state == 1 { - state = 2; targetFraction = p2; pointLabel = "Point 2 of 2" - instruction = "Now press the second crosshair and hold" - } - buf.removeAll(); return - } - guard state == 0 || state == 2 else { buf.removeAll(); return } - buf.append((s.x, s.y)); if buf.count > K { buf.removeFirst() } - if buf.count == K && stable() { - let m = mean() - if state == 0 { rawP1 = m; state = 1; instruction = "Captured — lift the pen" } - else { finish(m) } - } - } - - private func stable() -> Bool { - let xs = buf.map { $0.0 }, ys = buf.map { $0.1 } - return (xs.max()! - xs.min()! < tol) && (ys.max()! - ys.min()! < tol) - } - private func mean() -> (Double, Double) { - (buf.reduce(0.0) { $0 + Double($1.0) } / Double(buf.count), - buf.reduce(0.0) { $0 + Double($1.1) } / Double(buf.count)) - } - - private func finish(_ rawP2: (Double, Double)) { - // raw = a*frac + b, solved from the two known target fractions. - func range(_ r1: Double, _ r2: Double, _ f1: Double, _ f2: Double) -> (Double, Double) { - let a = (r2 - r1) / (f2 - f1) - let b = r1 - a * f1 - return (b, a + b) // raw at frac 0, raw at frac 1 + guard isActive, var solver else { return } + let event = solver.feed(s) + self.solver = solver + switch event { + case .none: break + case .firstCaptured: + instruction = "Captured — lift the pen" + case .awaitingSecond: + targetFraction = p2; pointLabel = "Point 2 of 2" + instruction = "Now press the second crosshair and hold" + case .finished(let cal): + apply(cal) + instruction = "Calibration saved" + close() + case .rejected: + targetFraction = p1; pointLabel = "Point 1 of 2" + instruction = "Those points didn't look right — try again: press the first crosshair and hold" } - let (xMin, xMax) = range(rawP1.0, rawP2.0, Double(p1.x), Double(p2.x)) - let (yMin, yMax) = range(rawP1.1, rawP2.1, Double(p1.y), Double(p2.y)) - apply(Calibration(xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax)) - instruction = "Calibration saved" - close() } /// The tablet's NSScreen, whose .frame is in Cocoa coordinates — CGDisplayBounds is diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index 6a99853..0e30391 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -109,36 +109,11 @@ case "calibrate": let profile = detectProfile() let bounds = cintiqDisplayBounds(profile) func cerr(_ s: String) { FileHandle.standardError.write((s + "\n").data(using: .utf8)!) } - let p1 = CGPoint(x: bounds.minX + 0.15 * bounds.width, y: bounds.minY + 0.15 * bounds.height) - let p2 = CGPoint(x: bounds.minX + 0.85 * bounds.width, y: bounds.minY + 0.85 * bounds.height) - - var state = 0 // 0: hold P1, 1: lift, 2: hold P2 - var buf: [(Int, Int)] = [] - let K = 40, tol = 1200 // samples to confirm a steady hold; raw-unit spread tolerance - var rawP1 = (0.0, 0.0) - - func mean(_ b: [(Int, Int)]) -> (Double, Double) { - (b.reduce(0.0) { $0 + Double($1.0) } / Double(b.count), - b.reduce(0.0) { $0 + Double($1.1) } / Double(b.count)) - } - func stable(_ b: [(Int, Int)]) -> Bool { - let xs = b.map { $0.0 }, ys = b.map { $0.1 } - return (xs.max()! - xs.min()! < tol) && (ys.max()! - ys.min()! < tol) - } - func finish(_ rawP2: (Double, Double)) { - let sx = (Double(p2.x) - Double(p1.x)) / (rawP2.0 - rawP1.0) - let ox = Double(p1.x) - sx * rawP1.0 // screenX = sx*raw + ox - let sy = (Double(p2.y) - Double(p1.y)) / (rawP2.1 - rawP1.1) - let oy = Double(p1.y) - sy * rawP1.1 - let cal = Calibration( - xMin: (Double(bounds.minX) - ox) / sx, xMax: (Double(bounds.maxX) - ox) / sx, - yMin: (Double(bounds.minY) - oy) / sy, yMax: (Double(bounds.maxY) - oy) / sy) - do { try cal.save(); cerr("nib: calibration saved to \(Calibration.fileURL.path)") } - catch { cerr("nib: save failed: \(error)") } - cerr(String(format: "nib: active raw range X[%.0f..%.0f] Y[%.0f..%.0f]", - cal.xMin, cal.xMax, cal.yMin, cal.yMax)) - exit(0) - } + // Targets as fractions of the display (the solver's space) and as screen points. + let f1 = CGPoint(x: 0.15, y: 0.15), f2 = CGPoint(x: 0.85, y: 0.85) + let p1 = CGPoint(x: bounds.minX + f1.x * bounds.width, y: bounds.minY + f1.y * bounds.height) + let p2 = CGPoint(x: bounds.minX + f2.x * bounds.width, y: bounds.minY + f2.y * bounds.height) + var solver = CalibrationSolver(target1: f1, target2: f2, pen: profile.pen) CGWarpMouseCursorPosition(p1) cerr("nib: CALIBRATION — place the pen tip exactly on the cursor dot (upper-left area) and hold still…") @@ -148,18 +123,22 @@ case "calibrate": onAttach: { enableNativeMode(calTransport, profile) }, onReport: { reportID, payload in guard reportID == profile.pen.reportID, let s = PenReport.parse(payload, profile.pen) else { return } - if !s.tipDown || !s.inRange { - if state == 1 { state = 2; CGWarpMouseCursorPosition(p2) - cerr("nib: point 1 captured. Now align the pen to the cursor (lower-right area) and hold…") } - buf.removeAll(); return - } - guard state == 0 || state == 2 else { buf.removeAll(); return } - buf.append((s.x, s.y)); if buf.count > K { buf.removeFirst() } - if buf.count == K && stable(buf) { - let m = mean(buf) - if state == 0 { rawP1 = m; state = 1; buf.removeAll() - cerr("nib: got point 1 raw=(\(Int(m.0)),\(Int(m.1))). Lift the pen.") } - else { finish(m) } + switch solver.feed(s) { + case .none: break + case .firstCaptured: + cerr("nib: got point 1. Lift the pen.") + case .awaitingSecond: + CGWarpMouseCursorPosition(p2) + cerr("nib: point 1 captured. Now align the pen to the cursor (lower-right area) and hold…") + case .finished(let cal): + do { try cal.save(); cerr("nib: calibration saved to \(Calibration.fileURL.path)") } + catch { cerr("nib: save failed: \(error)") } + cerr(String(format: "nib: active raw range X[%.0f..%.0f] Y[%.0f..%.0f]", + cal.xMin, cal.xMax, cal.yMin, cal.yMax)) + exit(0) + case .rejected: + CGWarpMouseCursorPosition(p1) + cerr("nib: calibration rejected (points too close or reversed) — try again from the upper-left dot") } }) calTransport.start() diff --git a/driver/Tests/NibCoreTests/CalibrationSolverTests.swift b/driver/Tests/NibCoreTests/CalibrationSolverTests.swift new file mode 100644 index 0000000..811ba54 --- /dev/null +++ b/driver/Tests/NibCoreTests/CalibrationSolverTests.swift @@ -0,0 +1,66 @@ +import XCTest +import CoreGraphics +@testable import NibCore + +final class CalibrationSolverTests: XCTestCase { + private let t1 = CGPoint(x: 0.1, y: 0.1), t2 = CGPoint(x: 0.9, y: 0.9) + + private func pen(tip: Bool, x: Int = 0, y: Int = 0) -> PenSample { + PenSample(tipDown: tip, barrelButton: false, secondaryBarrel: false, eraser: false, + inverted: false, inRange: true, x: x, y: y, pressure: tip ? 3000 : 0, + tiltX: 0, tiltY: 0, twist: 0, distance: 0, serial: 0, toolID: 0) + } + + func testSolveRecoversTheRange() { + // Raw range 1000...101000 (x) and 2000...52000 (y) → targets at 10% / 90%. + let cal = CalibrationSolver.solve(raw1: (11000, 7000), raw2: (91000, 47000), + target1: t1, target2: t2, + xLogicalMax: 105286, yLogicalMax: 59574) + XCTAssertEqual(cal!.xMin, 1000, accuracy: 1e-6) + XCTAssertEqual(cal!.xMax, 101000, accuracy: 1e-6) + XCTAssertEqual(cal!.yMin, 2000, accuracy: 1e-6) + XCTAssertEqual(cal!.yMax, 52000, accuracy: 1e-6) + } + + func testRejectsPointsTooClose() { + XCTAssertNil(CalibrationSolver.solve(raw1: (50000, 30000), raw2: (50500, 30500), + target1: t1, target2: t2, + xLogicalMax: 105286, yLogicalMax: 59574)) + } + + func testRejectsReversedPoints() { + XCTAssertNil(CalibrationSolver.solve(raw1: (91000, 47000), raw2: (11000, 7000), + target1: t1, target2: t2, + xLogicalMax: 105286, yLogicalMax: 59574)) + } + + func testStateMachineNeedsASteadyHoldThenLift() { + var s = CalibrationSolver(target1: t1, target2: t2, pen: .dth2420) + for _ in 0..<39 { XCTAssertEqual(s.feed(pen(tip: true, x: 11000, y: 7000)), .none) } + XCTAssertEqual(s.feed(pen(tip: true, x: 11000, y: 7000)), .firstCaptured) + XCTAssertEqual(s.feed(pen(tip: true, x: 11000, y: 7000)), .none) // still down: ignored + XCTAssertEqual(s.feed(pen(tip: false)), .awaitingSecond) + for _ in 0..<39 { _ = s.feed(pen(tip: true, x: 91000, y: 47000)) } + guard case .finished = s.feed(pen(tip: true, x: 91000, y: 47000)) else { + return XCTFail("expected a finished calibration") + } + } + + func testSamePointTwiceIsRejectedAndRestarts() { + var s = CalibrationSolver(target1: t1, target2: t2, pen: .dth2420) + for _ in 0..<40 { _ = s.feed(pen(tip: true, x: 50000, y: 30000)) } + _ = s.feed(pen(tip: false)) + var last = CalibrationSolver.Event.none + for _ in 0..<40 { last = s.feed(pen(tip: true, x: 50000, y: 30000)) } + XCTAssertEqual(last, .rejected) + XCTAssertEqual(s.currentTarget, t1) + } + + func testDegenerateMappingDoesNotProduceNaN() { + let m = DisplayMapping(targetBounds: CGRect(x: 0, y: 0, width: 100, height: 100), + xMin: 500, xMax: 500, yMin: 0, yMax: 1000) + let p = m.point(x: 700, y: 500) + XCTAssertFalse(p.x.isNaN) + XCTAssertEqual(p.y, 50, accuracy: 1e-9) + } +} From 95832d23be62848dedb988b861b98461d6263af0 Mon Sep 17 00:00:00 2001 From: modfox Date: Wed, 4 Nov 2026 16:45:58 -0500 Subject: [PATCH 9/9] Cleanups: cache sorted pressure points, share natural-scroll helper, roadmap - PressureCurve keeps its points sorted once instead of sorting on every pen sample; the encoded format (just "points") is unchanged. - NibConfig.effectiveNaturalScrolling() replaces identical copies in the menu and the preferences window; remove the leftover var/_ = def in NibConfig.init(from:). - ROADMAP: Developer ID signing + notarization is done. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 +-- driver/Sources/NibCore/Config.swift | 13 ++++++-- driver/Sources/NibCore/PressureCurve.swift | 24 ++++++++++++-- driver/Sources/nib/Preferences.swift | 8 +---- driver/Sources/nib/StatusBar.swift | 8 +---- .../NibCoreTests/PressureCurveTests.swift | 31 +++++++++++++++++++ 6 files changed, 68 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82af42e..8ced79b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,12 +8,13 @@ These are the remaining and possible enhancements. mode-switch and touch-enable live in a `DeviceProfile`, chosen at runtime from the connected hardware (with hotplug re-resolution). Adding a model is data, not decode code. Contribution workflow: `docs/ADDING-A-DEVICE.md`. +- [x] **Developer ID signing + notarization** — a tagged release builds a signed, + notarized DMG (`driver/RELEASING.md`), so Nib installs on other Macs without a + self-signed certificate. ## Planned - [ ] **ExpressKeys / touch-ring** — the tablet's physical buttons and ring (pen reports `0x11` / `0x13`), mapped to shortcuts. -- [ ] **Developer ID signing + notarization** — so Nib can be installed on other Macs - without setting up a self-signed certificate. - [ ] **More device profiles** — bring up additional Wacom models from contributed capture bundles (`docs/ADDING-A-DEVICE.md`). diff --git a/driver/Sources/NibCore/Config.swift b/driver/Sources/NibCore/Config.swift index 63d865c..761b74e 100644 --- a/driver/Sources/NibCore/Config.swift +++ b/driver/Sources/NibCore/Config.swift @@ -18,7 +18,7 @@ public struct NibConfig: Codable, Sendable, Equatable { public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - var def = NibConfig() + let def = NibConfig() touchEnabled = try c.decodeIfPresent(Bool.self, forKey: .touchEnabled) ?? def.touchEnabled palmRejection = try c.decodeIfPresent(Bool.self, forKey: .palmRejection) ?? def.palmRejection tapToClick = try c.decodeIfPresent(Bool.self, forKey: .tapToClick) ?? def.tapToClick @@ -28,7 +28,16 @@ public struct NibConfig: Codable, Sendable, Equatable { lowerBarrel = try c.decodeIfPresent(PenButtonAction.self, forKey: .lowerBarrel) ?? def.lowerBarrel upperBarrel = try c.decodeIfPresent(PenButtonAction.self, forKey: .upperBarrel) ?? def.upperBarrel pressureCurve = try c.decodeIfPresent(PressureCurve.self, forKey: .pressureCurve) ?? def.pressureCurve - _ = def + } + + /// Natural scrolling to apply: the explicit override if set, else the system setting + /// (`com.apple.swipescrolldirection`, read from `defaults`; macOS defaults to true). + public func effectiveNaturalScrolling(defaults: UserDefaults = .standard) -> Bool { + if let v = naturalScrolling { return v } + if defaults.object(forKey: "com.apple.swipescrolldirection") != nil { + return defaults.bool(forKey: "com.apple.swipescrolldirection") + } + return true } public static var fileURL: URL { diff --git a/driver/Sources/NibCore/PressureCurve.swift b/driver/Sources/NibCore/PressureCurve.swift index 0c68cdf..0acbdf2 100644 --- a/driver/Sources/NibCore/PressureCurve.swift +++ b/driver/Sources/NibCore/PressureCurve.swift @@ -9,9 +9,27 @@ public struct PressureCurve: Codable, Sendable, Equatable { public init(x: Double, y: Double) { self.x = x; self.y = y } } - public var points: [Point] + public var points: [Point] { didSet { sorted = Self.sort(points) } } + /// `points` ordered by x, kept in sync so `evaluate` (called per pen sample) doesn't sort. + private var sorted: [Point] - public init(points: [Point]) { self.points = points } + public init(points: [Point]) { + self.points = points + self.sorted = Self.sort(points) + } + + // Encode/decode only `points`, so the on-disk config format is unchanged. + private enum CodingKeys: String, CodingKey { case points } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init(points: try c.decode([Point].self, forKey: .points)) + } + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(points, forKey: .points) + } + + private static func sort(_ p: [Point]) -> [Point] { p.sorted { $0.x < $1.x } } /// Identity curve (input == output). public static var linear: PressureCurve { @@ -22,7 +40,7 @@ public struct PressureCurve: Codable, Sendable, Equatable { /// Evaluate the curve, clamped to [0,1]. public func evaluate(_ x: Double) -> Double { let cx = clamp(x) - let pts = points.sorted { $0.x < $1.x } + let pts = sorted guard let first = pts.first else { return cx } if cx <= first.x { return clamp(first.y) } for i in 1.. Bool { - if let v = model.config.naturalScrolling { return v } - if UserDefaults.standard.object(forKey: "com.apple.swipescrolldirection") != nil { - return UserDefaults.standard.bool(forKey: "com.apple.swipescrolldirection") - } - return true - } + private func effectiveNatural() -> Bool { model.config.effectiveNaturalScrolling() } var body: some View { Form { diff --git a/driver/Sources/nib/StatusBar.swift b/driver/Sources/nib/StatusBar.swift index caced93..930ad1b 100644 --- a/driver/Sources/nib/StatusBar.swift +++ b/driver/Sources/nib/StatusBar.swift @@ -57,13 +57,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { } /// Effective natural-scrolling: explicit override, else the system setting. - private func effectiveNatural() -> Bool { - if let v = config.naturalScrolling { return v } - if UserDefaults.standard.object(forKey: "com.apple.swipescrolldirection") != nil { - return UserDefaults.standard.bool(forKey: "com.apple.swipescrolldirection") - } - return true - } + private func effectiveNatural() -> Bool { config.effectiveNaturalScrolling() } /// Push config into the live engines (no disk write). private func applyEngines() { diff --git a/driver/Tests/NibCoreTests/PressureCurveTests.swift b/driver/Tests/NibCoreTests/PressureCurveTests.swift index 50791b2..57af7d4 100644 --- a/driver/Tests/NibCoreTests/PressureCurveTests.swift +++ b/driver/Tests/NibCoreTests/PressureCurveTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest @testable import NibCore @@ -23,3 +24,33 @@ final class PressureCurveTests: XCTestCase { XCTAssertGreaterThan(c.evaluate(0.25), 0.25) // more output than linear } } + +final class PressureCurveCodableTests: XCTestCase { + func testEncodesOnlyPointsAndRoundTrips() throws { + let curve = PressureCurve(points: [.init(x: 1, y: 1), .init(x: 0, y: 0), .init(x: 0.5, y: 0.8)]) + let data = try JSONEncoder().encode(curve) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(Array(json.keys), ["points"]) + let back = try JSONDecoder().decode(PressureCurve.self, from: data) + XCTAssertEqual(back, curve) + XCTAssertEqual(back.evaluate(0.25), 0.4, accuracy: 1e-9) // unsorted input still evaluates + } + + func testEditingPointsKeepsEvaluationInSync() { + var curve = PressureCurve.linear + curve.points[2] = .init(x: 0.5, y: 0.9) + XCTAssertEqual(curve.evaluate(0.5), 0.9, accuracy: 1e-9) + } +} + +final class NibConfigTests: XCTestCase { + func testNaturalScrollingOverrideAndSystemFallback() { + let d = UserDefaults(suiteName: "nib-tests-\(UUID().uuidString)")! + var c = NibConfig() + XCTAssertTrue(c.effectiveNaturalScrolling(defaults: d)) // unset → macOS default + d.set(false, forKey: "com.apple.swipescrolldirection") + XCTAssertFalse(c.effectiveNaturalScrolling(defaults: d)) // follows the system + c.naturalScrolling = true + XCTAssertTrue(c.effectiveNaturalScrolling(defaults: d)) // explicit override wins + } +}