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/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/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/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/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/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/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/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/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/NibCore/EventEmitter.swift b/driver/Sources/NibCore/EventEmitter.swift index f85fcce..d2a80f9 100644 --- a/driver/Sources/NibCore/EventEmitter.swift +++ b/driver/Sources/NibCore/EventEmitter.swift @@ -17,9 +17,12 @@ 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) + /// 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 @@ -29,51 +32,91 @@ 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) { + /// 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(), + 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 } + /// 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 { - 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) { + e.setIntegerValueField(.mouseEventClickState, value: Int64(tipClickState)) + sink.post(e) + } + 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 + } + // MARK: - Side buttons 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 { @@ -84,7 +127,8 @@ public final class EventEmitter { } guard let e = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: pt, mouseButton: button) else { return } - e.post(tap: .cghidEventTap) + e.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) + sink.post(e) } // MARK: - Pointer @@ -98,9 +142,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) @@ -123,23 +171,23 @@ 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 - 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. 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/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) } 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.. 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 +94,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 +119,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 +139,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)) } @@ -137,9 +153,12 @@ 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] { - 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 } + e.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) + sink.post(e) } } @@ -174,16 +193,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/Sources/nib/Calibration.swift b/driver/Sources/nib/Calibration.swift index ee0310b..fc2c311 100644 --- a/driver/Sources/nib/Calibration.swift +++ b/driver/Sources/nib/Calibration.swift @@ -15,20 +15,22 @@ 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) 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(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 - 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 @@ -42,58 +44,34 @@ 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) } + 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" } } - 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 - } - 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 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/Preferences.swift b/driver/Sources/nib/Preferences.swift index 96fd9b9..5fc8b58 100644 --- a/driver/Sources/nib/Preferences.swift +++ b/driver/Sources/nib/Preferences.swift @@ -36,13 +36,7 @@ struct PreferencesView: View { Binding(get: { effectiveNatural() }, set: { model.config.naturalScrolling = $0; model.commit() }) } - private func effectiveNatural() -> 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 dbec8ad..930ad1b 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") @@ -51,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() { @@ -70,6 +70,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. @@ -164,6 +166,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..0e30391 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()) @@ -119,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…") @@ -158,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() @@ -207,6 +176,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), @@ -215,7 +185,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) @@ -235,6 +205,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,17 +235,26 @@ 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() 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 })) { @@ -295,6 +275,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 @@ -311,6 +295,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) } 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) + } +} 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/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)) + } +} diff --git a/driver/Tests/NibCoreTests/EventEmitterTests.swift b/driver/Tests/NibCoreTests/EventEmitterTests.swift new file mode 100644 index 0000000..c3b6e25 --- /dev/null +++ b/driver/Tests/NibCoreTests/EventEmitterTests.swift @@ -0,0 +1,64 @@ +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) + } + + 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]) + } + + 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) + } +} 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 + } +} 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..3f6b034 --- /dev/null +++ b/driver/Tests/NibCoreTests/TouchGestureTests.swift @@ -0,0 +1,72 @@ +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) + } + + 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]) + } +}