diff --git a/.Jules/palette.md b/.Jules/palette.md index 58909748..cce8736c 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -26,6 +26,6 @@ **Learning:** Found a pattern where SwiftUI icon-only buttons or minimal UI elements were given `.help()` modifiers for hover tooltips but lacked `.accessibilityLabel()` modifiers for screen readers. **Action:** When adding `.help()` to buttons, always pair it with a corresponding `.accessibilityLabel()` to ensure full accessibility. -## 2024-07-12 - [Dynamic Accessibility Labels for List Item Actions] -**Learning:** Icon-only buttons (like Edit/Delete) inside lists pose a major accessibility challenge for VoiceOver users when identical labels ("Edit") are repeated without context, making it impossible to know which row is being acted on. -**Action:** Always interpolate the dynamic item context (e.g., `item.name`) into both `.help()` tooltips and `.accessibilityLabel()` modifiers in repeated SwiftUI lists. Include fallback text for empty states (e.g., `"Unnamed Item"`). +## 2023-10-25 - Added Context to Icon-Only List Buttons +**Learning:** Found an icon-only list item checkbox button (`onToggleSelect` in `CommunityProfileRow`) missing accessibility/help labels. Unlike standard text buttons, icon-only interaction targets must always explicitly provide context (via `.help` and `.accessibilityLabel`), particularly within repeated lists where context comes from dynamic adjacent content (`profileInfo.displayName`). +**Action:** Always verify icon-only interactive elements in loops or lists. Append `.help` and `.accessibilityLabel` with dynamic context using the current loop variable's properties to explicitly specify *what* the button is selecting/deselecting. diff --git a/.Jules/sentinel.md b/.Jules/sentinel.md index 5de2fc05..70f67594 100644 --- a/.Jules/sentinel.md +++ b/.Jules/sentinel.md @@ -18,7 +18,3 @@ **Vulnerability:** The application was falling back to storing OBS passwords as plaintext strings inside exported/saved JSON models (`Macro.swift` and `SystemCommand.swift`) if saving to the macOS Keychain failed. **Learning:** Saving secrets to unencrypted formats simply because secure storage fails is a critical anti-pattern known as "failing open" that results in data exposure. **Prevention:** Always fail securely. If secure storage operations fail, discard the sensitive credential in memory rather than writing it insecurely to disk, even if it requires the user to re-authenticate later. -## 2026-06-26 - [Denial of Service via Pipe Deadlock] -**Vulnerability:** The application executed `Foundation.Process` shell commands and waited for them to exit (`process.waitUntilExit()`) before attempting to read the standard output and error pipes. -**Learning:** If a child process writes more data than the operating system's pipe buffer can hold (typically ~64KB), the child process will block waiting for the parent to read the data. If the parent is blocked on `waitUntilExit()`, a deadlock occurs, resulting in a Denial of Service. -**Prevention:** Always read data from process pipes (e.g., using `fileHandleForReading.readDataToEndOfFile()`) *before* calling `process.waitUntilExit()` to ensure the pipe buffer is drained and the child process can finish executing. However, ensure that this fix does not introduce deadlocks when used with handlers like `readabilityHandler` or processes that don't close their streams until parent exit. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b181cd0..a3f22f9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,9 +26,13 @@ jobs: - name: Run tests run: | set -o pipefail - TEST_DERIVED_DATA=DerivedData \ - TEST_DESTINATION='platform=macOS,arch=arm64' \ - ./Scripts/run-direct-xctest.sh \ + xcodebuild test \ + -project XboxControllerMapper/XboxControllerMapper.xcodeproj \ + -scheme XboxControllerMapper \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath DerivedData \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGNING_REQUIRED=NO \ 2>&1 | tee xcodebuild.log | grep -vE '^\s*$' | tail -200 - name: Upload log on failure diff --git a/.gitignore b/.gitignore index 7910e657..9b4db10d 100644 --- a/.gitignore +++ b/.gitignore @@ -91,11 +91,7 @@ release/ DerivedData/ # Debug/diagnostic tools -Tools/* -!Tools/oura-calibration/ -!Tools/oura-calibration/** -Tools/oura-calibration/captures/ -Tools/oura-calibration/__pycache__/ +Tools/ # Marketing docs marketing/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea72a2b4..2acaefca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -238,20 +238,16 @@ Invariants the store integration maintains: |---------|------|---------------| | `ControllerService` | Services/ControllerService.swift | Controller connection, raw input handling, haptics, battery, DualSense HID | | `MappingEngine` | Services/MappingEngine.swift | Button->action mapping, chords, long hold, double tap, macros, system commands | -| `ControllerInputEvent` | Services/Mapping/ControllerInputEvent.swift | Event envelope and queue policy for MappingEngine controller callbacks | | `InputSimulator` | Services/InputSimulator.swift | CGEvent-based keyboard/mouse output | -| `ModifierKeyEmissionPolicy` | Services/Input/ModifierKeyEmissionPolicy.swift | Pure side-aware modifier key selection for InputSimulator | | `ProfileManager` | Services/ProfileManager.swift | Profile CRUD, persistence, backup | | `AppMonitor` | Services/AppMonitor.swift | Active app detection for profile auto-switching | | `XboxGuideMonitor` | Services/XboxGuideMonitor.swift | IOKit HID for Xbox Guide button (swallowed by GameController) | | `GameControllerDatabase` | Services/GameControllerDatabase.swift | SDL database parsing and lookup | | `GenericHIDController` | Services/GenericHIDController.swift | Raw HID input for third-party controllers | -| `HIDControllerDriverDescriptor` | Services/Controller/HIDControllerDriverDescriptor.swift | Testable raw-HID matching criteria for specialized controller backends | | `OnScreenKeyboardManager` | Services/OnScreenKeyboardManager.swift | Floating keyboard overlay window | | `CommandWheelManager` | Services/CommandWheelManager.swift | Radial menu for app/website switching | | `InputLogService` | Services/InputLogService.swift | Debug input logging | | `SystemCommandExecutor` | Services/SystemCommandExecutor.swift | Shell commands, app launch, URL open | -| `ControllerVisualDescriptor` | Views/MainWindow/ControllerVisualDescriptor.swift | Preview capability/row descriptor for controller mapping canvas | --- @@ -348,20 +344,3 @@ grep -oE '[0-9]+ NSDisplayCycleFlush' /tmp/ck_sample.txt | awk '{sum+=$1} END {p - **Reduce mouse event rate to 60Hz with velocity-adaptive posting** — post at 120Hz during slow/precise movement, drop to 60Hz during fast sweeps where individual frames aren't perceptible. Would halve IPC cost during fast movement. - **Separate NSHostingView for ControllerAnalogOverlay** — isolates the analog overlay's `@State` updates from the main view tree's layout pass. - **CALayer-based analog overlay** — replace SwiftUI analog rendering with Core Graphics/CALayer. Layer property updates don't trigger view tree diffing. - -## Pref-verification gotcha - -ControllerKeys is NOT sandboxed (entitlements set `com.apple.security.app-sandbox = false`). The leftover `~/Library/Containers/KevinTang.XboxControllerMapper/` dir is a red herring — real prefs live at `~/Library/Preferences/KevinTang.XboxControllerMapper.plist` (`defaults read KevinTang.XboxControllerMapper`). - -Do NOT verify a UI-pref change by `defaults write` then relaunching — cfprefsd caching makes the running app read a stale value even when `defaults read` shows the new one. Click the real control instead (synthetic Quartz `CGEventPost` clicks work). Also: `codesign -d --entitlements -` prints the `app-sandbox` key even when its value is `false` — use `--xml … | plutil -p -` for the actual boolean. - -Project-file note: the Xcode project is objectVersion 77 (`PBXFileSystemSynchronizedRootGroup`) — Swift sources are auto-discovered; do NOT run `add-xcode-files.py` here (only frameworks need explicit PBXBuildFile entries). - -## Licensing & release internals - -- **Trial clock** anchored in the login keychain (service `com.controllerkeys.license`, account `trialStart`) so delete+reinstall does NOT reset the 14-day trial. License key + `licensedConfirmed` flag stored the same way; a confirmed license stays valid offline. -- **Gumroad verify:** POSTs to `https://api.gumroad.com/v2/licenses/verify` with `product_id=9AsXgsuZVzxgNYfoVcFW1A==` (percent-encode so the trailing `==` survives), `increment_uses_count=false`; rejects refunded/disputed/chargebacked. The Gumroad product MUST have license keys enabled. -- **Enforcement:** `LicenseManager.enforce { mappingEngine.isEnabled = false }` in `XboxControllerMapperApp.init` (skipped in screenshot mode); fires at startup + on transition to expired. Dev builds compile in `DEV_BYPASS_LICENSE` via `make install`; the release never sets it. -- **Sparkle:** `SUFeedURL = https://raw.githubusercontent.com/NSEvent/xbox-controller-mapper/main/appcast.xml`, `SUPublicEDKey = UE8E+aypYDGJ7X8LznvFn1YBdENZDo5qrsX93K1/1Js=` (private EdDSA key in Kevin's login keychain — back it up). CFBundleVersion (BUILD_NUMBER) MUST increase every release or Sparkle won't detect the update. -- **Notarization re-sign (do not remove):** `Scripts/sign-and-notarize.sh` re-signs Sparkle's bundled `Updater.app`, `Autoupdate`, `XPCServices/*.xpc` inside-out, re-signs the framework, then re-seals the app — Sparkle ships them with its own signature, which Apple notarization rejects. -- **Release pipeline:** `Scripts/release.sh` EdDSA-signs the DMG from a clean staging dir (one-item feed), writes/commits/pushes `appcast.xml`, attaches the DMG to the GitHub release, and auto-bumps the homebrew tap cask (`NSEvent/homebrew-tap`, version + sha256). Public free download gated only by the in-app trial. diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4b826f..7403443a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,72 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.6.0] - 2026-07-13 - -### Added - -- **Oura Ring motion control**: ControllerKeys can now read an Oura smart ring as a controller—tilt your hand to steer the mouse, or route the same motion to scrolling, arrow keys, WASD, the D-pad, or four custom directional actions. A dedicated Ring tab (Settings → Ring) handles pairing plus a full calibration pass: orientation, invert X/Y, sensitivity, horizontal and left-hand boost for wide screens, deadzone, and smoothing, with Scan, Center, Pause Motion, and Forget Pairing controls. The ring auto-recenters when your hand drifts at rest so a stale neutral pose stops manufacturing phantom cursor drift, and the connection recovers on its own after Bluetooth drops or the stream stalls. - -- **Oura Ring gestures**: Finger taps (single, double, triple, and five-tap), tap-and-hold, and directional flicks (up, down, left, right) register as bindable virtual buttons. Out of the box a tap left-clicks, a double-tap re-centers the ring, and a triple-tap toggles motion; remap any of them like a normal controller button. Gestures are recognized on-device by a Core ML classifier trained on labeled ring sessions, tuned to fire taps quickly while rejecting the hand-settle rebound that used to read as a phantom second tap. - -### Changed - -- **First-run onboarding requests Input Monitoring before Accessibility**: Granting Accessibility first could make macOS report input access as already granted without ever registering ControllerKeys in the Input Monitoring list, leaving you to add it by hand. The setup wizard now requests Input Monitoring first—pausing briefly so macOS finishes registering before System Settings opens the list—so the app appears on its own, and the manual-add fallback is spelled out as numbered steps. - -### Fixed - -- **No-controller chooser no longer drops rows after scrolling**: In a short window, scrolling the Buttons tab so the "No controller connected" chooser is partly clipped and then returning to the top could leave some controller-family buttons missing after SwiftUI refreshed the view. The chooser now uses an eager static grid instead of a nested `LazyVGrid`, so the small fixed set of controller buttons stays mounted and redraws reliably. Each pick also labels its console family (PS5, PS4, Switch, Xbox) under the name, so it's clear which layout you're choosing. - -- **Live permission status stays current with more than one view open**: The permissions poller is now reference-counted, so closing one permissions view—like the first-run wizard—no longer stops the status pills in another open view (Settings → General → Permissions) from flipping to "Granted" the moment you toggle a switch in System Settings. - -## [2.5.1] - 2026-07-03 - -### Added - -- **Analog trigger precision control**: Use LT/L2, RT/R2, or either trigger as a continuous precision control for thumbstick and touchpad cursor movement. The deeper you pull, the slower the cursor moves, with sliders for minimum speed, trigger deadzone, and response curve. It is off by default and stacks with existing trigger button mappings instead of replacing them; the Joysticks settings UI now recommends RT/R2 first and warns when the selected trigger also has a button action or overlaps with left-trigger swipe typing. - -## [2.5.0] - 2026-07-02 - -### Added - -- **FPS / pointer-lock relative mouse mode**: Games that capture the mouse—browser FPS games, aim trainers, anything using the Pointer Lock API—need an endless stream of relative mouse motion, but stick aiming previously moved the real cursor to absolute screen positions, so aiming died at the screen edge and the cursor could escape the game onto the desktop. A new mode in Settings → Joysticks posts true relative movement while a game has the mouse captured: full 360° aiming, no edge stops, and the cursor stays pinned inside the game. Auto (the default) detects capture by watching for the game hiding the system cursor and switches back the moment it's released; Always is for dedicated per-app game profiles. One caveat: games that opt into raw input (`unadjustedMovement`) read the physical mouse hardware directly and can't see synthetic input from any macOS software—turn the game's raw-input setting off to aim with ControllerKeys. - -- **Google Slides / PowerPoint community profile**: A presentation-remote profile with a Start Presentation button, so a controller can drive slide decks out of the box. - -### Fixed - -- **Controller switching is harder to break**: Handing control to a second connected controller now waits for the active controller to go quiet and return to neutral first, so a drifting stick or a held button can no longer yank control away mid-input. Haptic sessions are also invalidated on switch, so rumble queued by the previous controller no longer fires on the new one. - -- **8BitDo Micro d-pad routes as a d-pad**: When macOS exposes the Micro's physical d-pad as a Direction Pad (Android mode), it now binds as real d-pad buttons instead of doubling as the left-stick fallback, matching what the mapping canvas shows. - -## [2.4.0] - 2026-06-25 - -### Added - -- **Independent left/right stick tuning**: Each analog stick now has its own sensitivity, acceleration, and deadzone, so you can keep one stick slow for pixel-precise aiming and the other fast to fling the cursor across a wide monitor—even with both sticks set to mouse mode. Previously the two sticks shared a single sensitivity, so tuning one changed the other. Set them independently in Settings → Joysticks. - -- **Per-layer joystick overrides**: Any layer can now override a stick's mode and tuning while it's held, and anything you don't set falls through to your base settings—the same transparency model layers already use for button mappings. Hold a layer trigger to make the left stick fast and release for fine control, without touching the other stick. Configure them in the new Per-Layer Overrides section of the Joysticks tab. - -### Fixed - -- **Joystick deadzone and invert controls follow the selected mode**: The Deadzone slider and Invert Y toggle edited the scroll-mode field on the right stick and the mouse-mode field on the left regardless of which mode was actually selected, so they had no effect once a stick was switched away from its default mode. They now bind to the field the chosen mode actually uses. - -## [2.3.0] - 2026-06-25 - -### Added - -- **⌘K command palette**: A Spotlight-style jump bar — press ⌘K or the new toolbar button — to go straight to any section, controller button, or bound shortcut without walking the nested tab bar. Sections are searchable by what they do (typing "javascript" finds Scripts, "latency" finds Input), with arrow-key, Return, and Escape navigation. - -### Fixed - -- **Touchpad cursor lag and path replay**: Using the DualSense touchpad as a mouse could feel laggy and re-trace the path you just swiped under high-rate input — most noticeable through a Bluetooth-to-USB controller adapter, but reproducible wired too. Touchpad samples are now coalesced into a single net movement per drain, so a burst can no longer back up the input queue and replay the motion. - -- **Expanded automation URL-scheme blocklist**: TriggerKit automation URL handling now also rejects `shortcuts`, `terminal`, `ssh`, `telnet`, `vnc`, `ftp`, `smb`, and `afp` schemes, closing local-execution and sandbox-escape vectors that the earlier `file` and system-scheme block didn't cover. - -- **OBS WebSocket password no longer falls back to plaintext**: When Keychain storage failed, the OBS password used by automation commands was previously written to disk in plaintext as a fallback. It now fails securely and discards the password instead of persisting it unencrypted. - -- **Accessibility labels for tooltip-only buttons**: Controls that previously exposed only a hover tooltip — the ⌘K command palette, Settings, Profiles, trial status, and several mapping panels — now carry VoiceOver accessibility labels. - ## [2.2.1] - 2026-06-19 ### Fixed diff --git a/FEATURES.md b/FEATURES.md index eb274004..39dd55d9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -72,7 +72,6 @@ This document lists all features for verification after refactoring. - [ ] Scroll acceleration - [ ] Scroll boost multiplier - [ ] Focus mode (precision control with modifier key) -- [ ] FPS / pointer-lock relative mouse mode (Auto / Off / Always) — 360° aiming in games that capture the mouse ## DualSense (PS5) Features diff --git a/Makefile b/Makefile index 5b9a8d3d..6d9982d8 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ INFO_PLIST := XboxControllerMapper/XboxControllerMapper/Info.plist HELPER_SRC := Helpers/XboxEliteHelper.swift HELPER_NAME := XboxEliteHelper -.PHONY: build install clean release sign-and-notarize app-path help check-permissions check-version-plist test-regressions test-full test-full-xcodebuild test-clean refactor-gate screenshots demo-gifs sync-website marketing-assets +.PHONY: build install clean release sign-and-notarize app-path help check-permissions check-version-plist test-regressions test-full test-clean refactor-gate screenshots demo-gifs sync-website marketing-assets help: @echo "ControllerKeys - Build Commands" @@ -78,8 +78,7 @@ help: @echo " make release - Full release workflow (sign, notarize, create GitHub release)" @echo " make app-path - Show the built app path" @echo " make test-regressions - Run focused regression suite" - @echo " make test-full - Run full test suite via direct XCTest" - @echo " make test-full-xcodebuild - Run full suite through Xcode's app-host launcher" + @echo " make test-full - Run full test suite" @echo " make test-clean - Remove cached test derived data" @echo " make refactor-gate - Run full suite (gate for refactors)" @echo " make screenshots - Capture marketing screenshots (all controller variants)" @@ -186,9 +185,6 @@ test-regressions: -only-testing:XboxControllerMapperTests/JoystickAndMouseMappingTests/testJoystickMouseMovement test-full: - PROJECT="$(PROJECT)" SCHEME="$(SCHEME)" TEST_DERIVED_DATA="$(TEST_DERIVED_DATA)" ./Scripts/run-direct-xctest.sh - -test-full-xcodebuild: xcodebuild test -project $(PROJECT) -scheme $(SCHEME) -derivedDataPath $(TEST_DERIVED_DATA) -destination 'platform=macOS' test-clean: diff --git a/OuraGestureModel/.gitignore b/OuraGestureModel/.gitignore deleted file mode 100644 index 9404ec0e..00000000 --- a/OuraGestureModel/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -checkpoints/ -exported/ -__pycache__/ diff --git a/OuraGestureModel/README.md b/OuraGestureModel/README.md deleted file mode 100644 index b66cd67b..00000000 --- a/OuraGestureModel/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# OuraGestureModel - -Per-event gesture classifier for the Oura ring input source: given a 0.64s -window of raw motion around an impulse candidate, classify it as `tap`, -`flick-up/down/left/right`, or `noise`. The deterministic tap-sequence -counter stays; the classifier replaces the geometric flick recognizer (which -ground truth showed unfixable: the "snap + return to start" model doesn't -match gravity-projection data) and filters tap ghosts. - -## Pipeline - -``` -Tools/oura-calibration session (gestures.html + motion trace) - → analyze_gestures.py # trial join + gesture-dataset.ndjson - → build_dataset.py # trial labels → per-event windows (events.ndjson) - → train.py # grouped 8-fold CV + final train → checkpoints/oura_gesture.pt - → export.py # → exported/OuraGestureClassifier.mlpackage - → verify_coreml.py # torch vs Core ML parity - → copy .mlpackage into XboxControllerMapper/Resources/ # synced group, no pbxproj edit -``` - -## Window format - -32 timesteps × 5 channels (`x, y, z` in g, `px, py` projected input), -uniformly resampled over [peak−0.26s, peak+0.38s]. Values are RAW — -normalization constants are baked into the model. Class order lives in -`model.CLASSES` and is mirrored by the Swift wrapper. - -## Retraining with more data - -Run another labeling session (Tools/oura-calibration/serve.py → -/gestures.html), analyze it, build its events, then pass every session's -events file to train.py: - -``` -python3 train.py capA/events.ndjson capB/events.ndjson -``` - -More sessions is the main quality lever — v0 trained on one 82-trial session -(137 events; flick directions have only 7-10 examples each). diff --git a/OuraGestureModel/build_dataset.py b/OuraGestureModel/build_dataset.py deleted file mode 100644 index b651eca9..00000000 --- a/OuraGestureModel/build_dataset.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -"""Build a per-event training dataset from a labeled Oura gesture session. - -Input: a capture dir produced by Tools/oura-calibration (gestures.html session -analyzed by analyze_gestures.py — needs gesture-dataset.ndjson and -motion-trace.ndjson). - -Turns trial-level labels into event-level examples: - - jerk-impulse peaks inside tap trials → "tap" (top-N by amplitude for an - N-tap trial), clearly weaker leftovers → "noise" (ring-down ghosts), - in-between leftovers discarded as ambiguous - - the dominant px/py step inside a flick trial → "flick-up/down/left/right" - (one event per trial; other peaks discarded) - - every peak inside noise trials → "noise" - - jerk peaks in the rest phases BETWEEN trials (from the full trace) → - "noise" — hard negatives: hand repositioning right after gestures - -Each event is a fixed 32×5 window ([x, y, z, px, py] resampled to 50 Hz over -peak-0.26s … peak+0.38s). Output: events.ndjson, one JSON object per event -with label, window, group (for grouped CV), and provenance. -""" -import argparse -import bisect -import json -import math -from collections import Counter -from pathlib import Path - - -WINDOW_PRE = 0.26 -WINDOW_POST = 0.24 -WINDOW_STEPS = 32 -PEAK_THRESHOLD = 0.35 -PEAK_MIN_SEPARATION = 0.18 -TAP_MIN_SEPARATION = 0.25 -GHOST_AMPLITUDE_RATIO = 0.6 -MIN_WINDOW_COVERAGE = 0.6 -# Trailing settle echoes after the LAST intended tap: leftover peaks in this -# window used to be discarded as "ambiguous" (amp ≥ 0.6× the weakest tap), so -# the classifier never saw them and called them "tap" at 0.84-0.98 confidence -# live — the single-tap→double-tap misfire (2026-07-06). Prompt count is -# ground truth: when every expected tap was found, anything tap-like right -# after the last one is hand-settle → hard negative. Bounds are the measured -# echo-gap band (0.18-0.30s live) with margin; only applied AFTER the last -# accepted tap so builder-excluded fast chain taps (gaps down to 0.18s, under -# TAP_MIN_SEPARATION) between accepted taps can't be mislabeled as noise. -ECHO_AFTER_LAST_TAP_MIN = 0.10 -ECHO_AFTER_LAST_TAP_MAX = 0.45 - -EXPECTED_TAPS = {"single-tap": 1, "double-tap": 2, "triple-tap": 3, "five-tap": 5, "tap-hold": 1} - - -def dedup_stream(samples): - """samples: [t, ct, x, y, z, px, py] rows → (cts, rows) sorted by ct.""" - rows = sorted(samples, key=lambda s: s[1]) - return [r[1] for r in rows], rows - - -def jerk_peaks(cts, rows, threshold): - """Local maxima of |Δxyz| between consecutive samples (>1ms apart).""" - series = [] - for a, b in zip(rows, rows[1:]): - if b[1] - a[1] < 0.001: - continue - j = math.sqrt((b[2] - a[2]) ** 2 + (b[3] - a[3]) ** 2 + (b[4] - a[4]) ** 2) - series.append((b[1], j)) - peaks = [] - for i in range(1, len(series) - 1): - ct, j = series[i] - if j >= threshold and j >= series[i - 1][1] and j >= series[i + 1][1]: - peaks.append((ct, j)) - # enforce min separation, keeping the larger peak - peaks.sort(key=lambda p: -p[1]) - kept = [] - for ct, j in peaks: - if all(abs(ct - k[0]) >= PEAK_MIN_SEPARATION for k in kept): - kept.append((ct, j)) - return sorted(kept) - - -def extract_window(cts, rows, center_ct): - """Resample [x,y,z,px,py] to WINDOW_STEPS uniform steps around center_ct. - Linear interpolation on ct; edge replication outside coverage. Returns - None when less than MIN_WINDOW_COVERAGE of the span has samples.""" - lo, hi = center_ct - WINDOW_PRE, center_ct + WINDOW_POST - i0 = bisect.bisect_left(cts, lo) - i1 = bisect.bisect_right(cts, hi) - if i1 <= i0: - return None - covered = min(cts[i1 - 1], hi) - max(cts[i0], lo) - if covered < MIN_WINDOW_COVERAGE * (hi - lo): - return None - - window = [] - j = max(i0, 1) - for step in range(WINDOW_STEPS): - target = lo + (hi - lo) * step / (WINDOW_STEPS - 1) - while j < len(cts) - 1 and cts[j] < target: - j += 1 - a, b = rows[j - 1], rows[j] - span = b[1] - a[1] - frac = 0.0 if span <= 0 else min(1.0, max(0.0, (target - a[1]) / span)) - window.append([round(a[k] + (b[k] - a[k]) * frac, 4) for k in (2, 3, 4, 5, 6)]) - return window - - -def flick_event_ct(rows): - """ct of the dominant px/py step (paired samples skipped).""" - best = None - for a, b in zip(rows, rows[1:]): - if b[1] - a[1] < 0.001: - continue - d = math.hypot(b[5] - a[5], b[6] - a[6]) - if best is None or d > best[1]: - best = (b[1], d) - return best - - -def events_from_trial(trial, index): - label = trial["label"] - cts, rows = dedup_stream(trial["samples"]) - if not rows: - return [] - group = f"trial-{index}" - out = [] - - if label in EXPECTED_TAPS: - expected = EXPECTED_TAPS[label] - peaks = jerk_peaks(cts, rows, PEAK_THRESHOLD) - by_amp = sorted(peaks, key=lambda p: -p[1]) - accepted = [] - for ct, amp in by_amp: - if len(accepted) >= expected: - break - if all(abs(ct - a[0]) >= TAP_MIN_SEPARATION for a in accepted): - accepted.append((ct, amp)) - floor = min((amp for _, amp in accepted), default=0) - last_tap_ct = max((ct for ct, _ in accepted), default=None) - all_taps_found = len(accepted) == expected - for ct, amp in peaks: - if any(abs(ct - a[0]) < 1e-9 for a in accepted): - event_label = "tap" - elif amp < floor * GHOST_AMPLITUDE_RATIO: - event_label = "noise" - elif (all_taps_found and last_tap_ct is not None - and ECHO_AFTER_LAST_TAP_MIN <= ct - last_tap_ct <= ECHO_AFTER_LAST_TAP_MAX): - event_label = "noise" # trailing settle echo — see constants above - else: - continue # ambiguous — neither clearly a tap nor clearly a ghost - window = extract_window(cts, rows, ct) - if window: - out.append({"label": event_label, "group": group, "trial_label": label, - "peak_ct": ct, "amp": round(amp, 3), "window": window}) - elif label.startswith("flick-"): - best = flick_event_ct(rows) - if best: - window = extract_window(cts, rows, best[0]) - if window: - out.append({"label": label, "group": group, "trial_label": label, - "peak_ct": best[0], "amp": round(best[1], 3), "window": window}) - else: # noise-still / noise-move - for ct, amp in jerk_peaks(cts, rows, PEAK_THRESHOLD * 0.7): - window = extract_window(cts, rows, ct) - if window: - out.append({"label": "noise", "group": group, "trial_label": label, - "peak_ct": ct, "amp": round(amp, 3), "window": window}) - return out - - -def rest_phase_events(trace_path, trials, ct_offset): - """Jerk peaks between trials (hand repositioning = hard negatives).""" - rows = [] - for line in trace_path.read_text(encoding="utf-8").splitlines(): - rec = json.loads(line) - if rec.get("type") == "sample": - rows.append([rec["t"], rec["ct"], rec["x"], rec["y"], rec["z"], - rec.get("px", 0.0), rec.get("py", 0.0)]) - rows.sort(key=lambda r: r[1]) - cts = [r[1] for r in rows] - - # exclusion zones: every trial window plus margin, in ct time - zones = sorted((t["goT"] - ct_offset - 0.9, t["endT"] - ct_offset + 0.9) for t in trials) - - def in_zone(ct): - i = bisect.bisect_right(zones, (ct, float("inf"))) - 1 - return i >= 0 and zones[i][0] <= ct <= zones[i][1] - - session_lo = zones[0][0] - 5 - session_hi = zones[-1][1] + 5 - out = [] - for ct, amp in jerk_peaks(cts, rows, PEAK_THRESHOLD): - if not (session_lo <= ct <= session_hi) or in_zone(ct): - continue - window = extract_window(cts, rows, ct) - if window: - out.append({"label": "noise", "group": f"rest-{int(ct)}", "trial_label": "rest-phase", - "peak_ct": ct, "amp": round(amp, 3), "window": window}) - return out - - -def main(): - parser = argparse.ArgumentParser(description="Build per-event training data from a labeled session.") - parser.add_argument("capture_dir", type=Path, nargs="?", - default=Path(__file__).resolve().parent.parent / "Tools/oura-calibration/captures/20260705-231028") - parser.add_argument("--out", type=Path, help="Output ndjson (default: /events.ndjson)") - args = parser.parse_args() - - dataset_path = args.capture_dir / "gesture-dataset.ndjson" - trials = [json.loads(l) for l in dataset_path.read_text().splitlines() if l.strip()] - covered = [t for t in trials if t["samples"]] - print(f"{len(covered)} covered trials (of {len(trials)})") - - events = [] - for index, trial in enumerate(covered): - events.extend(events_from_trial(trial, index)) - - trace_path = args.capture_dir / "motion-trace.ndjson" - if trace_path.exists(): - first = covered[0]["samples"][0] - ct_offset = first[0] - first[1] - rest = rest_phase_events(trace_path, covered, ct_offset) - events.extend(rest) - print(f"rest-phase negatives: {len(rest)}") - - out_path = args.out or (args.capture_dir / "events.ndjson") - with out_path.open("w", encoding="utf-8") as handle: - for event in events: - handle.write(json.dumps(event, separators=(",", ":")) + "\n") - - counts = Counter(e["label"] for e in events) - print(f"\n{len(events)} events → {out_path}") - for label, count in sorted(counts.items()): - print(f" {label:<12} {count}") - - -if __name__ == "__main__": - main() diff --git a/OuraGestureModel/evaluate.py b/OuraGestureModel/evaluate.py deleted file mode 100644 index b7381e8d..00000000 --- a/OuraGestureModel/evaluate.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -"""End-to-end evaluation of the full Oura gesture pipeline on every labeled -session — the single command that answers "how well does it work": - - python3 evaluate.py # all sessions, canonical pipeline - python3 evaluate.py --checkpoint checkpoints/candidate.pt - -Replays the exact production pipeline (impulse detection, classification with -tap-lean + heuristic corroboration, sequence counting, hold, flick gates, -cooldowns) sample-by-sample over each session's archived motion trace and -scores against the prompted labels. Exits non-zero if any session scores -below --min-accuracy (default 90%). -""" -import argparse -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(ROOT.parent / "Tools" / "oura-calibration")) - -import torch -from dataclasses import replace - -from model import OuraGestureNet -from replay_ml import MLPipeline -from tune_gestures import (Params, SHIPPED_2026_07_06, load_trace, score, - print_confusion, build_trials, latest_gesture_session, load_events, - split_by_coverage) - -CAPTURES = ROOT.parent / "Tools" / "oura-calibration" / "captures" - - -def evaluate(model, capture, verbose=True): - samples, _, centers = load_trace(capture / "motion-trace.ndjson") - trials, dropped = split_by_coverage( - build_trials(latest_gesture_session(load_events(capture))), samples) - ct_offset = sum(s[6] - s[0] for s in samples[:200]) / 200 - pipe = MLPipeline(model, replace(Params(), **SHIPPED_2026_07_06)) - ci = 0 - for s in samples: - while ci < len(centers) and centers[ci] <= s[0]: - pipe.feed_center(centers[ci]); ci += 1 - pipe.fire_due_timers(s[0]) - pipe.feed_sample(s[6], s[0], s[1], s[2], s[3], s[4], s[5]) - pipe.fire_due_timers(samples[-1][0] + 5) - correct, clean, rows = score(trials, pipe.events, ct_offset) - if verbose: - print(f"\n=== {capture.name}: {correct}/{len(trials)} " - f"({100 * correct / len(trials):.0f}%) noise-clean={clean} ===") - print_confusion(rows, correct, len(trials)) - return correct, len(trials), clean - - -def main(): - parser = argparse.ArgumentParser(description="End-to-end pipeline evaluation over all labeled sessions.") - parser.add_argument("--checkpoint", type=Path, default=ROOT / "checkpoints" / "oura_gesture.pt") - parser.add_argument("--min-accuracy", type=float, default=0.90) - args = parser.parse_args() - - checkpoint = torch.load(args.checkpoint, weights_only=True) - model = OuraGestureNet() - model.load_state_dict(checkpoint["state_dict"]) - model.eval() - - sessions = sorted(p for p in CAPTURES.iterdir() - if (p / "motion-trace.ndjson").exists() and (p / "gesture-dataset.ndjson").exists()) - if not sessions: - raise SystemExit("no labeled sessions with archived traces found") - - failures = 0 - for capture in sessions: - correct, total, clean = evaluate(model, capture) - if correct / total < args.min_accuracy or not clean: - failures += 1 - print(f"\n{len(sessions)} sessions evaluated, {failures} below the {args.min_accuracy:.0%} bar") - sys.exit(1 if failures else 0) - - -if __name__ == "__main__": - main() diff --git a/OuraGestureModel/export.py b/OuraGestureModel/export.py deleted file mode 100644 index 334af05b..00000000 --- a/OuraGestureModel/export.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -"""Export the trained classifier to Core ML. - -Produces exported/OuraGestureClassifier.mlpackage with a single input -"window" (1×32×5 float32 — raw [x,y,z,px,py] rows, normalization is baked in) -and output "logits" (1×6, class order model.CLASSES). - -Copy the result into XboxControllerMapper/XboxControllerMapper/Resources/ — -the synchronized project group picks it up and coremlc compiles it into the -bundle (no pbxproj edit). -""" -import torch -import coremltools as ct - -from model import CLASSES, WINDOW_STEPS, CHANNELS, OuraGestureNet -from train import CHECKPOINT_DIR - -EXPORT_DIR = CHECKPOINT_DIR.parent / "exported" - - -def main(): - checkpoint = torch.load(CHECKPOINT_DIR / "oura_gesture.pt", weights_only=True) - assert checkpoint["classes"] == CLASSES - model = OuraGestureNet() - model.load_state_dict(checkpoint["state_dict"]) - model.eval() - - example = torch.zeros(1, WINDOW_STEPS, CHANNELS) - traced = torch.jit.trace(model, example) - mlmodel = ct.convert( - traced, - inputs=[ct.TensorType(name="window", shape=example.shape, dtype=float)], - outputs=[ct.TensorType(name="logits", dtype=float)], - minimum_deployment_target=ct.target.macOS14, - convert_to="mlprogram", - # FP32: the model is ~5k params, and full precision keeps Core ML - # bit-comparable to the PyTorch checkpoint for parity checks. - compute_precision=ct.precision.FLOAT32, - ) - mlmodel.short_description = ( - "Oura ring gesture event classifier (tap / 4 flick directions / noise) " - f"over 32x5 raw motion windows; classes: {', '.join(CLASSES)}" - ) - EXPORT_DIR.mkdir(exist_ok=True) - out = EXPORT_DIR / "OuraGestureClassifier.mlpackage" - mlmodel.save(str(out)) - print(f"saved → {out}") - - -if __name__ == "__main__": - main() diff --git a/OuraGestureModel/model.py b/OuraGestureModel/model.py deleted file mode 100644 index 9710982e..00000000 --- a/OuraGestureModel/model.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Tiny 1D CNN over 32×5 motion windows → 6 gesture classes. - -Input is RAW sensor values ([x, y, z] in g, [px, py] projected input) — the -per-channel normalization constants are baked into the module as buffers so -the Swift caller never needs to know them. -""" -import torch -import torch.nn as nn - -CLASSES = ["tap", "flick-up", "flick-down", "flick-left", "flick-right", "noise"] -WINDOW_STEPS = 32 -CHANNELS = 5 - - -class OuraGestureNet(nn.Module): - def __init__(self, mean=None, std=None): - super().__init__() - self.register_buffer("input_mean", torch.zeros(CHANNELS) if mean is None else torch.as_tensor(mean, dtype=torch.float32)) - self.register_buffer("input_std", torch.ones(CHANNELS) if std is None else torch.as_tensor(std, dtype=torch.float32)) - self.features = nn.Sequential( - nn.Conv1d(CHANNELS, 32, kernel_size=5, padding=2), - nn.ReLU(), - nn.MaxPool1d(2), - nn.Conv1d(32, 64, kernel_size=3, padding=1), - nn.ReLU(), - nn.MaxPool1d(2), - nn.Conv1d(64, 64, kernel_size=3, padding=1), - nn.ReLU(), - ) - self.head = nn.Linear(64, len(CLASSES)) - - def forward(self, x): - # x: (B, 32, 5) raw → normalize → (B, 5, 32) - x = (x - self.input_mean) / self.input_std - x = x.permute(0, 2, 1) - x = self.features(x) - x = x.mean(dim=2) - return self.head(x) diff --git a/OuraGestureModel/replay_ml.py b/OuraGestureModel/replay_ml.py deleted file mode 100644 index ddc22240..00000000 --- a/OuraGestureModel/replay_ml.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env python3 -"""End-to-end replay of the ML gesture pipeline against a labeled session. - -Simulates exactly what the Swift integration will do, sample by sample: - jerk-impulse candidate detector (mirrors build_dataset extraction) - → pending-window queue (classify at peak+0.38s) - → OuraGestureNet - → tap: tuned OuraTapSequenceRecognizer counting (+ tap-hold recognizer) - flick: direct fire + impulse cooldown - noise: drop -then scores prompted trial windows with the same confusion logic as -tune_gestures.py. - -Honesty note: replaying the session the model TRAINED on validates the -plumbing (candidate recall, window timing, counting, cooldowns), not -generalization — the grouped-CV numbers in train.py are the performance claim. -""" -import bisect -import json -import sys -from pathlib import Path - -import numpy as np -import torch - -ROOT = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(ROOT.parent / "Tools" / "oura-calibration")) - -from model import CLASSES, OuraGestureNet -from build_dataset import extract_window, PEAK_MIN_SEPARATION, PEAK_THRESHOLD, WINDOW_POST -from tune_gestures import (Params, SHIPPED_2026_07_06, TapDetector, TapSequence, TapHold, - load_trace, score, print_confusion, build_trials, latest_gesture_session, - load_events, split_by_coverage) -from dataclasses import replace - -FLICK_COOLDOWN = 0.65 -FLICK_CONFIDENCE_THRESHOLD = 0.5 -FLICK_MID_SEQUENCE_CONFIDENCE = 0.85 -POST_RESOLVE_COOLDOWN = 0.35 -POST_HOLD_COOLDOWN = 0.6 -DETECTION_MARGIN = 0.20 -TAP_LEAN = 0.45 -TAP_CORROBORATION_FLOOR = 0.10 -TAP_CORROBORATION_WINDOW = 0.12 -CENTER_SUPPRESSION = 0.75 - - -class MLPipeline: - def __init__(self, model, params): - self.model = model - self.p = params - self.sequence = TapSequence(params) - self.hold = TapHold(params) - self.tap_detector = TapDetector(params) # heuristic corroborator - self.heuristic_fires = [] - self.rows = [] # [t, ct, x, y, z, px, py] history - self.cts = [] - self.jerk_prev = None # (ct, jerk) of previous jerk-series entry - self.jerk_before = None - self.last_peak_ct = -1e9 - self.pending_peaks = [] # peak cts waiting for their window to complete - self.cooldown_until = 0.0 - self.suppress_until = 0.0 - self.resolution_at = None - self.hold_fed_ct = -1e9 # newest ct the hold recognizer has consumed - self.events = [] # (ct, name, detail) - - def feed_center(self, ct): - self.suppress_until = max(self.suppress_until, ct + CENTER_SUPPRESSION) - - def fire_due_timers(self, now): - if self.resolution_at is not None and now >= self.resolution_at: - if self.pending_peaks: - # a candidate is mid-classification — it may extend the tap - # sequence, so defer resolution until its window completes - self.resolution_at = self.pending_peaks[0] + WINDOW_POST + 0.05 - return - at = self.resolution_at - self.resolution_at = None - resolved = self.sequence.resolve_pending(at) - if resolved is not None: - self.hold.cancel() - self.cooldown_until = max(self.cooldown_until, at + POST_RESOLVE_COOLDOWN) - self.events.append((at, "tap-resolved", str(resolved))) - - def feed_sample(self, t, ct, x, y, z, px, py): - self.rows.append([t, ct, x, y, z, px, py]) - self.cts.append(ct) - - # heuristic tap detector runs in parallel as a corroborator: its fire - # times break ties on borderline ML windows (see _classify) - if ct >= self.suppress_until and self.tap_detector.register((ct, x, y, z)): - self.heuristic_fires.append(ct) - del self.heuristic_fires[:-16] - - # classify any pending peak whose window is now complete - while self.pending_peaks and ct >= self.pending_peaks[0] + WINDOW_POST: - self._classify(self.pending_peaks.pop(0), now=ct) - - # jerk series (skip paired samples <1ms apart), 1-sample-lookahead peak confirm - if len(self.rows) >= 2: - a, b = self.rows[-2], self.rows[-1] - if b[1] - a[1] >= 0.001: - j = ((b[2] - a[2]) ** 2 + (b[3] - a[3]) ** 2 + (b[4] - a[4]) ** 2) ** 0.5 - entry = (b[1], j) - if self.jerk_before and self.jerk_prev: - p_ct, p_j = self.jerk_prev - if (p_j >= PEAK_THRESHOLD and p_j >= self.jerk_before[1] and p_j >= j - and p_ct - self.last_peak_ct >= PEAK_MIN_SEPARATION - and p_ct >= self.suppress_until - and p_ct >= self.cooldown_until): - self.last_peak_ct = p_ct - self.pending_peaks.append(p_ct) - self.jerk_before = self.jerk_prev - self.jerk_prev = entry - - # tap-hold needs the motion stream (same as the heuristic pipeline); - # skip samples the retroactive catch-up already consumed - if ct > self.hold_fed_ct: - self.hold_fed_ct = ct - if self.hold.register_motion((ct, x, y, z)): - self.events.append((ct, "tap-hold", None)) - self.resolution_at = None - self.sequence.reset() - self.cooldown_until = max(self.cooldown_until, ct + POST_HOLD_COOLDOWN) - - def _classify(self, peak_ct, now): - if peak_ct < self.cooldown_until: - return # previous flick's echo — already-queued peaks skip the enqueue check - window = extract_window(self.cts, self.rows, peak_ct) - if window is None: - return - with torch.no_grad(): - logits = self.model(torch.tensor(np.array([window], dtype=np.float32))) - probs = torch.softmax(logits, dim=1)[0] - label = CLASSES[int(probs.argmax())] - ptap = float(probs[0]) - if label == "noise": - if ptap >= TAP_LEAN: - label = "tap" - elif ptap >= TAP_CORROBORATION_FLOOR and any( - abs(f - peak_ct) <= TAP_CORROBORATION_WINDOW for f in self.heuristic_fires): - # two weak signals agreeing: borderline ML window + the shape - # detector fired for the same instant (live: +179 taps/hour) - label = "tap" - confidence = float(probs.max()) - self.events.append((peak_ct, "ml-class", label)) - if label == "tap": - kind, count = self.sequence.register_tap(peak_ct) - if kind == "duplicate": - return - if kind == "pending": - if count == 1: - self._start_hold_retroactively(peak_ct) - else: - self.hold.cancel() - # Anchor resolution at the PEAK, not classification time: a - # second tap's peak must land within sequence_window of the - # first, and DETECTION_MARGIN covers its detector/enqueue lag — - # the pending-peaks deferral then waits for its classification. - # Anchoring at classification time added the ~0.42s window lag - # to every single-tap resolution. - self.resolution_at = max(now + 0.01, - peak_ct + self.p.sequence_window + DETECTION_MARGIN) + self.p.timer_slack - else: - self.hold.cancel() - self.resolution_at = None - self.cooldown_until = max(self.cooldown_until, now + POST_RESOLVE_COOLDOWN) - self.events.append((now, "tap-resolved", str(count))) - elif label.startswith("flick-"): - # Mirror the Swift gates (phantom-flick fix, 2026-07-06): drop - # low-confidence flicks; consume a pending tap only when it falls - # inside the flick's own window (its outbound spike). - if confidence < FLICK_CONFIDENCE_THRESHOLD: - return - if self.sequence.tap_count > 0 and self.sequence.last_tap_time is not None \ - and peak_ct - self.sequence.last_tap_time <= 0.64: - if confidence < FLICK_MID_SEQUENCE_CONFIDENCE: - return # hand-settle phantom — don't eat the pending chain - self.sequence.reset() - self.resolution_at = None - self.hold.cancel() - self.events.append((now, "flick", label.split("-")[1])) - self.cooldown_until = peak_ct + FLICK_COOLDOWN - self.hold.cancel() - # noise → drop - - def _start_hold_retroactively(self, peak_ct): - """Classification arrives ~0.4s after the tap; anchor the hold candidate - back at the peak and replay the buffered samples since, so hold timing - (settle at +0.09, ring-down drift checks) matches the heuristic path. - The Swift integration does the same from its ring buffer.""" - i = bisect.bisect_left(self.cts, peak_ct) - if i >= len(self.rows): - return - row = self.rows[i] - self.hold.register_tap(peak_ct, (row[1], row[2], row[3], row[4])) - for r in self.rows[i + 1:]: - self.hold_fed_ct = max(self.hold_fed_ct, r[1]) - if self.hold.register_motion((r[1], r[2], r[3], r[4])): - self.events.append((r[1], "tap-hold", None)) - self.resolution_at = None - self.sequence.reset() - self.cooldown_until = max(self.cooldown_until, r[1] + POST_HOLD_COOLDOWN) - break - - -def main(): - capture = Path(sys.argv[1]) if len(sys.argv) > 1 else \ - ROOT.parent / "Tools/oura-calibration/captures/20260705-231028" - - checkpoint = torch.load(ROOT / "checkpoints/oura_gesture.pt", weights_only=True) - model = OuraGestureNet() - model.load_state_dict(checkpoint["state_dict"]) - model.eval() - - samples, _, centers = load_trace(capture / "motion-trace.ndjson") - trials, dropped = split_by_coverage( - build_trials(latest_gesture_session(load_events(capture))), samples) - ct_offset = sum(s[6] - s[0] for s in samples[:200]) / 200 - params = replace(Params(), **SHIPPED_2026_07_06) - - pipeline = MLPipeline(model, params) - center_index = 0 - for s in samples: # (ct, x, y, z, px, py, t) - ct = s[0] - while center_index < len(centers) and centers[center_index] <= ct: - pipeline.feed_center(centers[center_index]) - center_index += 1 - pipeline.fire_due_timers(ct) - pipeline.feed_sample(s[6], ct, s[1], s[2], s[3], s[4], s[5]) - pipeline.fire_due_timers(samples[-1][0] + 5.0) - - correct, clean, rows = score(trials, pipeline.events, ct_offset) - tap_classes = ("single-tap", "double-tap", "triple-tap", "five-tap", "tap-hold") - th = sum(sum(1 for p in rows.get(c, []) if p == c) for c in tap_classes) - tt = sum(len(rows.get(c, [])) for c in tap_classes) - flick_classes = tuple(c for c in rows if c.startswith("flick")) - fh = sum(sum(1 for p in rows.get(c, []) if p == c) for c in flick_classes) - ft = sum(len(rows.get(c, [])) for c in flick_classes) - print(f"ML pipeline replay on {len(trials)} covered trials " - f"({len(dropped)} excluded) — TRAINING-SESSION replay, see CV for generalization:") - print(f" overall {correct}/{len(trials)} | taps+hold {th}/{tt} | flicks {fh}/{ft} | noise-clean={clean}\n") - print_confusion(rows, correct, len(trials)) - - -if __name__ == "__main__": - main() diff --git a/OuraGestureModel/retrain_session.sh b/OuraGestureModel/retrain_session.sh deleted file mode 100755 index 3fc92dd1..00000000 --- a/OuraGestureModel/retrain_session.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Full retrain pipeline for a freshly completed labeling session: -# ./retrain_session.sh -# analyze → archive trace → build events → merged CV train → export → -# parity → end-to-end evaluate. Exits non-zero on any failed stage or if -# the new model regresses below 90% on any session. -set -euo pipefail -CAP="$1" -cd "$(dirname "$0")" -CAL="../Tools/oura-calibration" - -python3 - "$CAP" <<'EOF' -import json, sys -from pathlib import Path -cap = Path(sys.argv[1]) -events = [json.loads(l) for l in (cap / "targets.ndjson").read_text().splitlines() if l.strip()] -ts = [float(e["wallTimeUnix"]) for e in events if e.get("type") in ("session:start", "session:end")] -lo, hi = min(ts) - 30, max(ts) + 30 -n = 0 -with (cap / "motion-trace.ndjson").open("w") as out: - for line in open("/tmp/controllerkeys-oura-motion-trace.ndjson"): - try: t = json.loads(line).get("t") - except Exception: continue - if t and lo <= t <= hi: - out.write(line); n += 1 -print(f"archived {n} trace lines") -EOF - -python3 "$CAL/analyze_gestures.py" "$CAP" --trace "$CAP/motion-trace.ndjson" -python3 build_dataset.py "$CAP" -EVENTS=$(ls "$CAL"/captures/*/events.ndjson) -python3 train.py $EVENTS -python3 export.py -python3 verify_coreml.py "$CAP/events.ndjson" -python3 evaluate.py -echo "RETRAIN PIPELINE COMPLETE — copy exported/OuraGestureClassifier.mlpackage to Resources and reinstall" diff --git a/OuraGestureModel/search-results.json b/OuraGestureModel/search-results.json deleted file mode 100644 index 845d3942..00000000 --- a/OuraGestureModel/search-results.json +++ /dev/null @@ -1,101 +0,0 @@ -[ - { - "stage": "cv", - "config": "baseline", - "cv": "566/618", - "acc": 0.9159 - }, - { - "stage": "cv", - "config": "post30", - "cv": "574/620", - "acc": 0.9258 - }, - { - "stage": "cv", - "config": "post24", - "cv": "571/624", - "acc": 0.9151 - }, - { - "stage": "cv", - "config": "wide", - "cv": "568/618", - "acc": 0.9191 - }, - { - "stage": "cv", - "config": "long", - "cv": "562/618", - "acc": 0.9094 - }, - { - "stage": "cv", - "config": "aug", - "cv": "565/618", - "acc": 0.9142 - }, - { - "stage": "cv", - "config": "wide-long-aug", - "cv": "566/618", - "acc": 0.9159 - }, - { - "stage": "cv", - "config": "post30-wide-long", - "cv": "568/620", - "acc": 0.9161 - }, - { - "stage": "cv", - "config": "post24-wide-long-aug", - "cv": "575/624", - "acc": 0.9215 - }, - { - "stage": "replay", - "config": "post30", - "sessions": [ - "77/82", - "200/214" - ], - "clean": true, - "total": 277 - }, - { - "stage": "replay", - "config": "post24-wide-long-aug", - "sessions": [ - "80/82", - "201/214" - ], - "clean": true, - "total": 281 - }, - { - "stage": "replay", - "config": "wide", - "sessions": [ - "76/82", - "201/214" - ], - "clean": true, - "total": 277 - }, - { - "stage": "replay", - "config": "post30-wide-long", - "sessions": [ - "79/82", - "200/214" - ], - "clean": true, - "total": 279 - }, - { - "stage": "winner", - "config": "post24-wide-long-aug", - "total": 281 - } -] \ No newline at end of file diff --git a/OuraGestureModel/search.py b/OuraGestureModel/search.py deleted file mode 100644 index a8eab6af..00000000 --- a/OuraGestureModel/search.py +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env python3 -"""Overnight model search: architecture / window / augmentation / schedule. - -For each config: rebuild per-event windows at the config's span, run grouped -8-fold CV, log to search-results.json. Top configs by CV then get the real -test — full-pipeline replay on both labeled sessions (with pipeline window -constants matched). Writes the final ranking and leaves the best checkpoint -at checkpoints/candidate.pt (canonical oura_gesture.pt is NOT touched). -""" -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(ROOT.parent / "Tools" / "oura-calibration")) - -import numpy as np -import torch -import torch.nn as nn -from dataclasses import replace - -import build_dataset -import model as model_mod -import train as train_mod -import replay_ml -from tune_gestures import (Params, SHIPPED_2026_07_06, load_trace, score, - build_trials, latest_gesture_session, load_events, split_by_coverage) - -CAPTURES = ROOT.parent / "Tools" / "oura-calibration" / "captures" -SESSIONS = ["20260705-231028", "20260706-013512"] -RESULTS = ROOT / "search-results.json" - -CONFIGS = [ - {"name": "baseline", "post": 0.38, "ch": (24, 48, 48), "epochs": 160, "noise": 0.02, "shift": 4}, - {"name": "post30", "post": 0.30, "ch": (24, 48, 48), "epochs": 160, "noise": 0.02, "shift": 4}, - {"name": "post24", "post": 0.24, "ch": (24, 48, 48), "epochs": 160, "noise": 0.02, "shift": 4}, - {"name": "wide", "post": 0.38, "ch": (32, 64, 64), "epochs": 160, "noise": 0.02, "shift": 4}, - {"name": "long", "post": 0.38, "ch": (24, 48, 48), "epochs": 320, "noise": 0.02, "shift": 4}, - {"name": "aug", "post": 0.38, "ch": (24, 48, 48), "epochs": 160, "noise": 0.04, "shift": 6}, - {"name": "wide-long-aug", "post": 0.38, "ch": (32, 64, 64), "epochs": 320, "noise": 0.04, "shift": 6}, - {"name": "post30-wide-long", "post": 0.30, "ch": (32, 64, 64), "epochs": 320, "noise": 0.02, "shift": 4}, - {"name": "post24-wide-long-aug", "post": 0.24, "ch": (32, 64, 64), "epochs": 320, "noise": 0.04, "shift": 6}, -] - - -def make_net_class(channels): - c1, c2, c3 = channels - - class Net(nn.Module): - def __init__(self, mean=None, std=None): - super().__init__() - self.register_buffer("input_mean", torch.zeros(5) if mean is None else torch.as_tensor(mean, dtype=torch.float32)) - self.register_buffer("input_std", torch.ones(5) if std is None else torch.as_tensor(std, dtype=torch.float32)) - self.features = nn.Sequential( - nn.Conv1d(5, c1, 5, padding=2), nn.ReLU(), nn.MaxPool1d(2), - nn.Conv1d(c1, c2, 3, padding=1), nn.ReLU(), nn.MaxPool1d(2), - nn.Conv1d(c2, c3, 3, padding=1), nn.ReLU(), - ) - self.head = nn.Linear(c3, len(model_mod.CLASSES)) - - def forward(self, x): - x = (x - self.input_mean) / self.input_std - x = x.permute(0, 2, 1) - return self.head(self.features(x).mean(dim=2)) - return Net - - -def build_events(post): - build_dataset.WINDOW_POST = post - events = [] - for i, name in enumerate(SESSIONS): - cap = CAPTURES / name - trials = [json.loads(l) for l in (cap / "gesture-dataset.ndjson").read_text().splitlines() if l.strip()] - covered = [t for t in trials if t["samples"]] - for index, trial in enumerate(covered): - for e in build_dataset.events_from_trial(trial, index): - e["group"] = f"s{i}-{e['group']}" - events.append(e) - first = covered[0]["samples"][0] - for e in build_dataset.rest_phase_events(cap / "motion-trace.ndjson", covered, first[0] - first[1]): - e["group"] = f"s{i}-{e['group']}" - events.append(e) - return events - - -def patched_train(events, net_class, epochs, noise, shift, seed): - import random - torch.manual_seed(seed); random.seed(seed); np.random.seed(seed) - mean, std = train_mod.norm_stats(events) - net = net_class(mean, std) - ds = train_mod.EventDataset(events, augment=True) - - orig_getitem = train_mod.EventDataset.__getitem__ - def getitem(self, i): - w = self.windows[i] - s = random.randint(-shift, shift) - if s: - w = np.roll(w, s, axis=0) - if s > 0: w[:s] = w[s] - else: w[s:] = w[s - 1] - w = w * random.uniform(0.85, 1.15) + np.random.normal(0, noise, w.shape).astype(np.float32) - return torch.from_numpy(w.astype(np.float32)), self.labels[i] - train_mod.EventDataset.__getitem__ = getitem - try: - from torch.utils.data import DataLoader - loader = DataLoader(ds, batch_size=32, shuffle=True) - criterion = nn.CrossEntropyLoss(weight=train_mod.class_weights(events)) - opt = torch.optim.Adam(net.parameters(), lr=1e-3) - sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs) - net.train() - for _ in range(epochs): - for x, y in loader: - opt.zero_grad() - criterion(net(x), y).backward() - opt.step() - sched.step() - finally: - train_mod.EventDataset.__getitem__ = orig_getitem - return net - - -def grouped_cv(events, net_class, cfg): - folds = train_mod.grouped_folds(events, 8) - hits = total = 0 - for i, test in enumerate(folds): - train_set = [e for j, f in enumerate(folds) if j != i for e in f] - net = patched_train(train_set, net_class, cfg["epochs"], cfg["noise"], cfg["shift"], seed=7 + i) - preds = train_mod.predict(net, test) - hits += sum(1 for e, p in zip(test, preds) if model_mod.CLASSES[p] == e["label"]) - total += len(test) - return hits, total - - -def replay_eval(net, post): - build_dataset.WINDOW_POST = post - replay_ml.WINDOW_POST = post - results = [] - for name in SESSIONS: - cap = CAPTURES / name - samples, _, centers = load_trace(cap / "motion-trace.ndjson") - trials, _ = split_by_coverage(build_trials(latest_gesture_session(load_events(cap))), samples) - ct_offset = sum(s[6] - s[0] for s in samples[:200]) / 200 - pipe = replay_ml.MLPipeline(net, replace(Params(), **SHIPPED_2026_07_06)) - ci = 0 - for s in samples: - while ci < len(centers) and centers[ci] <= s[0]: - pipe.feed_center(centers[ci]); ci += 1 - pipe.fire_due_timers(s[0]) - pipe.feed_sample(s[6], s[0], s[1], s[2], s[3], s[4], s[5]) - pipe.fire_due_timers(samples[-1][0] + 5) - correct, clean, _ = score(trials, pipe.events, ct_offset) - results.append((correct, len(trials), clean)) - return results - - -def log(entry): - rows = json.loads(RESULTS.read_text()) if RESULTS.exists() else [] - rows.append(entry) - RESULTS.write_text(json.dumps(rows, indent=1)) - print(json.dumps(entry), flush=True) - - -def main(): - cv_scores = {} - for cfg in CONFIGS: - events = build_events(cfg["post"]) - net_class = make_net_class(cfg["ch"]) - hits, total = grouped_cv(events, net_class, cfg) - cv_scores[cfg["name"]] = hits / total - log({"stage": "cv", "config": cfg["name"], "cv": f"{hits}/{total}", "acc": round(hits / total, 4)}) - - top = sorted(CONFIGS, key=lambda c: -cv_scores[c["name"]])[:4] - best = None - for cfg in top: - events = build_events(cfg["post"]) - net = patched_train(events, make_net_class(cfg["ch"]), cfg["epochs"], cfg["noise"], cfg["shift"], seed=7) - results = replay_eval(net, cfg["post"]) - total_correct = sum(r[0] for r in results) - all_clean = all(r[2] for r in results) - log({"stage": "replay", "config": cfg["name"], - "sessions": [f"{c}/{n}" for c, n, _ in results], "clean": all_clean, - "total": total_correct}) - if all_clean and (best is None or total_correct > best[0]): - best = (total_correct, cfg, net) - - if best: - total_correct, cfg, net = best - torch.save({"state_dict": net.state_dict(), "classes": model_mod.CLASSES, - "config": {k: v for k, v in cfg.items() if k != "ch"} | {"ch": list(cfg["ch"])}}, - ROOT / "checkpoints" / "candidate.pt") - log({"stage": "winner", "config": cfg["name"], "total": total_correct}) - - -if __name__ == "__main__": - main() diff --git a/OuraGestureModel/train.py b/OuraGestureModel/train.py deleted file mode 100644 index 3834fab6..00000000 --- a/OuraGestureModel/train.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env python3 -"""Train the Oura gesture classifier with honest grouped cross-validation. - -Events from the same trial never straddle a train/test split (group = trial), -so CV numbers estimate performance on unseen gestures, not memorized windows. - - python3 train.py # 8-fold grouped CV, then final train on all data - python3 train.py events_a.ndjson events_b.ndjson # merge sessions -""" -import argparse -import json -import random -from collections import Counter, defaultdict -from pathlib import Path - -import numpy as np -import torch -import torch.nn as nn -from torch.utils.data import DataLoader, Dataset - -from model import CLASSES, WINDOW_STEPS, OuraGestureNet - -DEFAULT_EVENTS = Path(__file__).resolve().parent.parent / \ - "Tools/oura-calibration/captures/20260705-231028/events.ndjson" -CHECKPOINT_DIR = Path(__file__).resolve().parent / "checkpoints" - -EPOCHS = 320 -BATCH_SIZE = 32 -LR = 1e-3 -SEED = 7 - - -class EventDataset(Dataset): - def __init__(self, events, augment): - self.windows = np.array([e["window"] for e in events], dtype=np.float32) - self.labels = np.array([CLASSES.index(e["label"]) for e in events], dtype=np.int64) - self.augment = augment - - def __len__(self): - return len(self.labels) - - def __getitem__(self, i): - w = self.windows[i] - if self.augment: - shift = random.randint(-6, 6) - if shift: - w = np.roll(w, shift, axis=0) - if shift > 0: - w[:shift] = w[shift] - else: - w[shift:] = w[shift - 1] - w = w * random.uniform(0.85, 1.15) - w = w + np.random.normal(0, 0.04, w.shape).astype(np.float32) - return torch.from_numpy(w.astype(np.float32)), self.labels[i] - - -def load_events(paths): - events = [] - for p, path in enumerate(paths): - for line in Path(path).read_text().splitlines(): - if line.strip(): - e = json.loads(line) - e["group"] = f"s{p}-{e['group']}" - events.append(e) - return events - - -def class_weights(events): - counts = Counter(e["label"] for e in events) - total = sum(counts.values()) - return torch.tensor([total / (len(CLASSES) * counts.get(c, 1)) for c in CLASSES], dtype=torch.float32) - - -def norm_stats(events): - stacked = np.concatenate([np.array(e["window"], dtype=np.float32) for e in events]) - return stacked.mean(axis=0), stacked.std(axis=0) + 1e-6 - - -def train_one(train_events, epochs=EPOCHS, seed=SEED): - torch.manual_seed(seed) - random.seed(seed) - np.random.seed(seed) - mean, std = norm_stats(train_events) - model = OuraGestureNet(mean, std) - loader = DataLoader(EventDataset(train_events, augment=True), - batch_size=BATCH_SIZE, shuffle=True, drop_last=False) - criterion = nn.CrossEntropyLoss(weight=class_weights(train_events)) - optimizer = torch.optim.Adam(model.parameters(), lr=LR) - scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) - model.train() - for _ in range(epochs): - for x, y in loader: - optimizer.zero_grad() - loss = criterion(model(x), y) - loss.backward() - optimizer.step() - scheduler.step() - return model - - -@torch.no_grad() -def predict(model, events): - model.eval() - x = torch.tensor(np.array([e["window"] for e in events], dtype=np.float32)) - return model(x).argmax(dim=1).tolist() - - -def grouped_folds(events, k): - groups = sorted({e["group"] for e in events}) - rng = random.Random(SEED) - rng.shuffle(groups) - assignment = {g: i % k for i, g in enumerate(groups)} - folds = defaultdict(list) - for e in events: - folds[assignment[e["group"]]].append(e) - return [folds[i] for i in range(k)] - - -def cross_validate(events, k): - folds = grouped_folds(events, k) - confusion = defaultdict(Counter) - for i, test in enumerate(folds): - train = [e for j, fold in enumerate(folds) if j != i for e in fold] - model = train_one(train, epochs=EPOCHS, seed=SEED + i) - for e, pred in zip(test, predict(model, test)): - confusion[e["label"]][CLASSES[pred]] += 1 - hits = sum(confusion[c][c] for c in CLASSES) - total = sum(sum(row.values()) for row in confusion.values()) - print(f" fold {i + 1}/{k} done — running accuracy {hits}/{total}") - return confusion - - -def print_confusion(confusion): - width = max(len(c) for c in CLASSES) + 2 - total_hits = total = 0 - for label in CLASSES: - row = confusion.get(label, Counter()) - n = sum(row.values()) - if n == 0: - continue - hits = row.get(label, 0) - total_hits += hits - total += n - parts = ", ".join(f"{p}×{c}" for p, c in row.most_common()) - print(f" {label:<{width}} {hits}/{n} [{parts}]") - if total: - print(f" Overall: {total_hits}/{total} ({100 * total_hits / total:.0f}%)") - - -def main(): - parser = argparse.ArgumentParser(description="Train the Oura gesture classifier.") - parser.add_argument("events", nargs="*", default=[DEFAULT_EVENTS], type=Path) - parser.add_argument("--folds", type=int, default=8) - parser.add_argument("--skip-cv", action="store_true") - args = parser.parse_args() - - events = load_events(args.events) - print(f"{len(events)} events: " + - ", ".join(f"{l}×{c}" for l, c in sorted(Counter(e['label'] for e in events).items()))) - - if not args.skip_cv: - print(f"\n== {args.folds}-fold grouped CV ==") - confusion = cross_validate(events, args.folds) - print_confusion(confusion) - - print("\n== Final train on all events ==") - model = train_one(events) - CHECKPOINT_DIR.mkdir(exist_ok=True) - out = CHECKPOINT_DIR / "oura_gesture.pt" - torch.save({"state_dict": model.state_dict(), "classes": CLASSES}, out) - train_preds = predict(model, events) - hits = sum(1 for e, p in zip(events, train_preds) if CLASSES[p] == e["label"]) - print(f"train-set accuracy {hits}/{len(events)} (sanity, not a performance claim)") - print(f"saved → {out}") - - -if __name__ == "__main__": - main() diff --git a/OuraGestureModel/verify_coreml.py b/OuraGestureModel/verify_coreml.py deleted file mode 100644 index 525e7bcf..00000000 --- a/OuraGestureModel/verify_coreml.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -"""Parity check: Core ML export vs the PyTorch checkpoint on real events.""" -import json -import sys - -import numpy as np -import torch -import coremltools as ct - -from model import CLASSES, OuraGestureNet -from train import CHECKPOINT_DIR, DEFAULT_EVENTS -from export import EXPORT_DIR - - -def main(): - events_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_EVENTS - events = [json.loads(l) for l in open(events_path) if l.strip()] - - checkpoint = torch.load(CHECKPOINT_DIR / "oura_gesture.pt", weights_only=True) - model = OuraGestureNet() - model.load_state_dict(checkpoint["state_dict"]) - model.eval() - - mlmodel = ct.models.MLModel(str(EXPORT_DIR / "OuraGestureClassifier.mlpackage")) - - max_diff = 0.0 - mismatches = 0 - for e in events: - x = np.array([e["window"]], dtype=np.float32) - with torch.no_grad(): - torch_logits = model(torch.from_numpy(x)).numpy()[0] - coreml_logits = np.array(mlmodel.predict({"window": x})["logits"][0]) - max_diff = max(max_diff, float(np.abs(torch_logits - coreml_logits).max())) - if torch_logits.argmax() != coreml_logits.argmax(): - mismatches += 1 - - print(f"{len(events)} events: argmax mismatches {mismatches}, max |logit diff| {max_diff:.5f}") - if mismatches or max_diff > 1e-3: - raise SystemExit("PARITY FAILURE") - print("parity OK") - - -if __name__ == "__main__": - main() diff --git a/README.md b/README.md index f98b3fb7..c1cc7c63 100644 --- a/README.md +++ b/README.md @@ -346,10 +346,6 @@ There are other controller mapping apps for macOS, but none offered everything I Any controller recognized by macOS's GameController framework works out of the box. Unrecognized controllers fall back to the SDL database for automatic button mapping. -### DualSense as a wired USB controller (Pico bridge) - -Want a DualSense to appear as a *wired* USB controller — for the lowest-latency real-time path, or a setup that rejects Bluetooth? A Raspberry Pi Pico 2 W running [DS5Dongle](https://github.com/awalol/DS5Dongle) bridges the controller over Bluetooth and presents it to macOS as wired USB; ControllerKeys then treats it like any wired DualSense, touchpad and all. To also get the controller's **speaker** working as a macOS audio output (the stock dongle firmware can't drive it on macOS), see [ds5dongle-pico2w-macos](https://github.com/NSEvent/ds5dongle-pico2w-macos) — a prebuilt Pico 2 W build plus setup steps. - ## Requirements - macOS 14.6 or later diff --git a/Scripts/release.sh b/Scripts/release.sh index 30073fff..cbdeebf7 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -154,21 +154,6 @@ if [[ -f "$TAP_CASK" ]]; then echo "Tap cask updated to ${MARKETING_VERSION} (sha256 ${DMG_SHA})" fi -WEBSITE_RELEASE_UPDATER="$HOME/projects/kevintang.xyz/scripts/update-controllerkeys-release-version.py" -if [[ -f "$WEBSITE_RELEASE_UPDATER" ]]; then - echo "" - echo "=== Updating ControllerKeys marketing site ===" - RELEASE_PUBLISHED_AT="$(gh release view "$TAG" --json publishedAt -q .publishedAt 2>/dev/null || date -u +%Y-%m-%d)" - python3 "$WEBSITE_RELEASE_UPDATER" \ - --version "$MARKETING_VERSION" \ - --date "$RELEASE_PUBLISHED_AT" \ - --commit \ - --push -else - echo "" - echo "Warning: ControllerKeys marketing updater not found at $WEBSITE_RELEASE_UPDATER" -fi - echo "" echo "=== Release Complete ===" echo "Tag created: $TAG" diff --git a/Scripts/run-direct-xctest.sh b/Scripts/run-direct-xctest.sh deleted file mode 100755 index 9411d676..00000000 --- a/Scripts/run-direct-xctest.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -PROJECT="${PROJECT:-XboxControllerMapper/XboxControllerMapper.xcodeproj}" -SCHEME="${SCHEME:-XboxControllerMapper}" -DERIVED_DATA="${TEST_DERIVED_DATA:-/tmp/xcm-derived-data}" -DESTINATION="${TEST_DESTINATION:-platform=macOS}" -BUILD_CONFIGURATION="${TEST_CONFIGURATION:-Debug}" -PRODUCTS_DIR="$DERIVED_DATA/Build/Products/$BUILD_CONFIGURATION" -APP_PATH="$PRODUCTS_DIR/ControllerKeys.app" -XCTEST_BUNDLE="${XCTEST_BUNDLE:-$APP_PATH/Contents/PlugIns/XboxControllerMapperTests.xctest}" -PACKAGE_FRAMEWORKS="$PRODUCTS_DIR/PackageFrameworks" - -echo "Building tests with xcodebuild build-for-testing" -xcodebuild build-for-testing \ - -project "$PROJECT" \ - -scheme "$SCHEME" \ - -configuration "$BUILD_CONFIGURATION" \ - -derivedDataPath "$DERIVED_DATA" \ - -destination "$DESTINATION" \ - CODE_SIGN_IDENTITY=- \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - -if [[ ! -d "$APP_PATH" ]]; then - echo "Missing app bundle: $APP_PATH" >&2 - exit 1 -fi - -if [[ ! -d "$XCTEST_BUNDLE" ]]; then - echo "Missing XCTest bundle: $XCTEST_BUNDLE" >&2 - exit 1 -fi - -mkdir -p "$PACKAGE_FRAMEWORKS" - -# Direct `xcrun xctest` bypasses Xcode's app-host launcher, so it needs the -# host app's debug dylib and Sparkle in the test bundle rpath search directory. -DEBUG_DYLIB="$PRODUCTS_DIR/ControllerKeys.debug.dylib" -APP_DEBUG_DYLIB="$APP_PATH/Contents/MacOS/ControllerKeys.debug.dylib" -if [[ -f "$DEBUG_DYLIB" ]]; then - cp "$DEBUG_DYLIB" "$PACKAGE_FRAMEWORKS/ControllerKeys.debug.dylib" -elif [[ -f "$APP_DEBUG_DYLIB" ]]; then - cp "$APP_DEBUG_DYLIB" "$PACKAGE_FRAMEWORKS/ControllerKeys.debug.dylib" -else - echo "Missing ControllerKeys.debug.dylib in $PRODUCTS_DIR or $APP_PATH/Contents/MacOS" >&2 - exit 1 -fi - -if [[ -d "$PRODUCTS_DIR/Sparkle.framework" ]]; then - rm -rf "$PACKAGE_FRAMEWORKS/Sparkle.framework" - /usr/bin/ditto "$PRODUCTS_DIR/Sparkle.framework" "$PACKAGE_FRAMEWORKS/Sparkle.framework" -elif [[ -d "$APP_PATH/Contents/Frameworks/Sparkle.framework" ]]; then - rm -rf "$PACKAGE_FRAMEWORKS/Sparkle.framework" - /usr/bin/ditto "$APP_PATH/Contents/Frameworks/Sparkle.framework" "$PACKAGE_FRAMEWORKS/Sparkle.framework" -else - echo "Missing Sparkle.framework in $PRODUCTS_DIR or $APP_PATH/Contents/Frameworks" >&2 - exit 1 -fi - -if [[ -n "${CONTROLLERKEYS_RENDER_SNAPSHOT_DIR:-}" ]]; then - mkdir -p "$CONTROLLERKEYS_RENDER_SNAPSHOT_DIR" -fi - -echo "Running direct XCTest bundle" -xcrun xctest "$XCTEST_BUNDLE" diff --git a/Tools/oura-calibration/PACKETLOGGER-CAPTURE.md b/Tools/oura-calibration/PACKETLOGGER-CAPTURE.md deleted file mode 100644 index 2d07536d..00000000 --- a/Tools/oura-calibration/PACKETLOGGER-CAPTURE.md +++ /dev/null @@ -1,59 +0,0 @@ -# Sniffing the official Oura app's tap-to-tag enable sequence - -The in-app probe (rounds 1-3) found feature 0x07 answers reads and has an -"armed" state (bitmask bits 0x01/0x02) but never pushed tap events. The only -way to learn the *real* enable command is to capture the official Oura app -talking to the ring. That traffic is between the **iPhone and the ring**, so -the capture must happen on the phone, not the Mac. - -## Step 0 — go/no-go (30 seconds, do this first) - -ControllerKeys adopted the ring with its own auth key. The official app and -ControllerKeys can't both hold the ring at once, and if adoption replaced the -official key, the official app may be locked out entirely. - -1. Quit ControllerKeys (or toggle Oura off in its settings) so the ring is free. -2. Open the official Oura app on the phone and confirm the ring **connects and - syncs**. - - Connects → proceed. - - Won't connect → the ring is adopted away; this experiment is impossible - without re-pairing to the official app first. Stop here. - -## Step 1 — arm iOS Bluetooth logging - -1. On the phone, install Apple's **Bluetooth** logging profile: - developer.apple.com/bug-reporting/profiles-and-logs/ → Bluetooth → install, - then Settings → General → VPN & Device Management → approve → reboot. - (The profile makes iOS write a PacketLogger `.pklg` into every sysdiagnose.) - -## Step 2 — reproduce + capture - -1. In the official Oura app, perform the tap-to-tag / tag action (the feature - where you tap the ring to mark a moment — often inside a workout/session). - Do it a few times so the enable sequence + any tap frames are on the wire. -2. Immediately trigger a sysdiagnose: press **Volume-Up + Volume-Down + Side** - for ~1s (a short buzz confirms). It bakes for ~10 min. -3. Retrieve it: Settings → Privacy & Security → Analytics & Improvements → - Analytics Data → `sysdiagnose_…tar.gz` → share → AirDrop to the Mac. - -## Step 3 — decode (hand it to me) - -Drop the sysdiagnose (or just the `.pklg` from inside it) somewhere on the Mac. -The `.pklg` lives at `sysdiagnose_*/bluetooth/*.pklg` inside the tarball. - -``` -python3 decode_pklg.py .pklg -``` - -Look for the write right before tap frames start: a `2F .. 28 07` PUSH from the -ring, or a realtime `06 07 ` with a bit the probe didn't try. That -byte sequence is the answer — it becomes the ring's start command and -hardware-rate tap detection ships. - -## Honest caveat - -Tap-to-tag may be a **stored marker** — the ring records a tagged timestamp in -memory for later cloud sync, with no live BLE push at all. Round 3 (armed -state, zero pushes on knock) is consistent with that. If the capture shows the -enable command but no per-tap frames, that confirms tap-to-tag isn't usable for -realtime input, and the ML pipeline stays the best available path. diff --git a/Tools/oura-calibration/analyze.py b/Tools/oura-calibration/analyze.py deleted file mode 100644 index 293c013d..00000000 --- a/Tools/oura-calibration/analyze.py +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import math -import re -from datetime import datetime, timezone -from pathlib import Path - - -LOG_PATTERN = re.compile( - r"^(?P\S+) motion raw " - r"(?P-?\d+(?:\.\d+)?) (?P-?\d+(?:\.\d+)?) (?P-?\d+(?:\.\d+)?) " - r"centered (?P-?\d+(?:\.\d+)?) (?P-?\d+(?:\.\d+)?) (?P-?\d+(?:\.\d+)?) " - r"input (?P-?\d+(?:\.\d+)?) (?P-?\d+(?:\.\d+)?) " - r"stick (?P-?\d+(?:\.\d+)?) (?P-?\d+(?:\.\d+)?)" -) - - -def parse_iso_seconds(value): - if value.endswith("Z"): - value = value[:-1] + "+00:00" - return datetime.fromisoformat(value).timestamp() - - -def latest_capture_dir(root): - candidates = sorted(path for path in root.glob("*") if (path / "targets.ndjson").exists()) - if not candidates: - raise SystemExit(f"No captures found under {root}") - return candidates[-1] - - -def load_events(capture_dir): - events_path = capture_dir / "targets.ndjson" - if not events_path.exists(): - raise SystemExit(f"Missing {events_path}") - events = [] - for line in events_path.read_text(encoding="utf-8").splitlines(): - if line.strip(): - events.append(json.loads(line)) - return events - - -def event_seconds(event): - if "wallTimeUnix" in event: - return float(event["wallTimeUnix"]) - return float(event["serverReceivedUnix"]) - - -def latest_session_events(events): - start_index = None - for index, event in enumerate(events): - if event.get("type") == "session:start": - start_index = index - if start_index is None: - return events - - for end_index in range(start_index + 1, len(events)): - if events[end_index].get("type") == "session:end": - return events[start_index:end_index + 1] - return events[start_index:] - - -def build_windows(events): - starts = {} - windows = [] - for event in events: - event_type = event.get("type") - if event_type == "target:start": - starts[event["trialIndex"]] = event - elif event_type == "target:end": - start = starts.get(event["trialIndex"]) - if not start: - continue - target = start["target"] - windows.append({ - "trialIndex": start["trialIndex"], - "target": target, - "start": event_seconds(start), - "end": event_seconds(event), - }) - return windows - - -def parse_log(log_path, start_time, end_time): - samples = [] - if not log_path.exists(): - raise SystemExit(f"Missing Oura log: {log_path}") - for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): - match = LOG_PATTERN.match(line) - if not match: - continue - timestamp = parse_iso_seconds(match.group("iso")) - if timestamp < start_time or timestamp > end_time: - continue - value = {key: float(match.group(key)) for key in match.groupdict() if key != "iso"} - samples.append({ - "timestamp": timestamp, - "raw": (value["rawx"], value["rawy"], value["rawz"]), - "centered": (value["centerx"], value["centery"], value["centerz"]), - "input": (value["inputx"], value["inputy"]), - "stick": (value["stickx"], value["sticky"]), - }) - return samples - - -def mean(values): - values = list(values) - return sum(values) / len(values) if values else 0.0 - - -def stdev(values): - if len(values) < 2: - return 0.0 - avg = mean(values) - return math.sqrt(sum((value - avg) ** 2 for value in values) / (len(values) - 1)) - - -def vector_mean(samples, key): - size = len(samples[0][key]) - return tuple(mean(sample[key][index] for sample in samples) for index in range(size)) - - -def vector_stdev(samples, key): - size = len(samples[0][key]) - return tuple(stdev([sample[key][index] for sample in samples]) for index in range(size)) - - -def select_samples(samples, window, trim_start, trim_end): - start = window["start"] + trim_start - end = max(start, window["end"] - trim_end) - return [sample for sample in samples if start <= sample["timestamp"] <= end] - - -def solve_linear_system(matrix, vector): - n = len(vector) - a = [row[:] + [vector[index]] for index, row in enumerate(matrix)] - for column in range(n): - pivot = max(range(column, n), key=lambda row: abs(a[row][column])) - if abs(a[pivot][column]) < 1e-12: - raise ValueError("singular matrix") - a[column], a[pivot] = a[pivot], a[column] - scale = a[column][column] - a[column] = [value / scale for value in a[column]] - for row in range(n): - if row == column: - continue - factor = a[row][column] - a[row] = [a[row][col] - factor * a[column][col] for col in range(n + 1)] - return [a[row][n] for row in range(n)] - - -def least_squares(rows, values, ridge=1e-5): - width = len(rows[0]) - ata = [[0.0 for _ in range(width)] for _ in range(width)] - atb = [0.0 for _ in range(width)] - for row, value in zip(rows, values): - for i in range(width): - atb[i] += row[i] * value - for j in range(width): - ata[i][j] += row[i] * row[j] - for i in range(width): - ata[i][i] += ridge - return solve_linear_system(ata, atb) - - -def correlation(xs, ys): - if len(xs) < 2: - return 0.0 - x_mean = mean(xs) - y_mean = mean(ys) - numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) - denominator = math.sqrt( - sum((x - x_mean) ** 2 for x in xs) * - sum((y - y_mean) ** 2 for y in ys) - ) - return numerator / denominator if denominator else 0.0 - - -def format_vec(values): - return " ".join(f"{value:+.3f}" for value in values) - - -def format_model(coefficients, names): - return " ".join(f"{coef:+.3f}*{name}" for coef, name in zip(coefficients, names)) - - -def analyze(capture_dir, log_path, trim_start, trim_end, all_sessions): - events = load_events(capture_dir) - if not all_sessions: - events = latest_session_events(events) - windows = build_windows(events) - if not windows: - raise SystemExit("No target windows recorded. Start and complete a calibration pass first.") - start_time = min(window["start"] for window in windows) - 2 - end_time = max(window["end"] for window in windows) + 2 - samples = parse_log(log_path, start_time, end_time) - if not samples: - raise SystemExit("No Oura motion samples found in the calibration window.") - - rows_by_trial = [] - for window in windows: - window_samples = select_samples(samples, window, trim_start, trim_end) - if not window_samples: - continue - row = { - "trialIndex": window["trialIndex"], - "target": window["target"], - "count": len(window_samples), - "raw": vector_mean(window_samples, "raw"), - "rawStd": vector_stdev(window_samples, "raw"), - "centered": vector_mean(window_samples, "centered"), - "input": vector_mean(window_samples, "input"), - "stick": vector_mean(window_samples, "stick"), - } - rows_by_trial.append(row) - - print(f"Capture: {capture_dir}") - print(f"Oura log: {log_path}") - print(f"Samples: {len(samples)} total, {sum(row['count'] for row in rows_by_trial)} in trimmed hold windows") - print("") - print("Per target means") - print("target n targetXY raw mean centered mean input mean stick mean") - for row in rows_by_trial: - target = row["target"] - print( - f"{target['label'][:13]:13} " - f"{row['count']:3d} " - f"{target['x']:+.2f},{target['y']:+.2f} " - f"{format_vec(row['raw'])} " - f"{format_vec(row['centered'])} " - f"{format_vec(row['input'])} " - f"{format_vec(row['stick'])}" - ) - - if len(rows_by_trial) < 5: - print("\nNeed at least 5 target windows for regression.") - return - - print("") - print("Raw-axis correlation with intended screen direction") - for key, names in (("raw", ("raw.x", "raw.y", "raw.z")), ("centered", ("center.x", "center.y", "center.z")), ("input", ("input.x", "input.y"))): - for target_axis, label in ((0, "screenX"), (1, "screenY")): - target_values = [row["target"]["x" if target_axis == 0 else "y"] for row in rows_by_trial] - values = [correlation([row[key][index] for row in rows_by_trial], target_values) for index in range(len(rows_by_trial[0][key]))] - print(f"{key:8} -> {label}: " + " ".join(f"{name}={value:+.3f}" for name, value in zip(names, values))) - - print("") - print("Least-squares projection from ring vectors to intended target") - for key, names in (("raw", ("raw.x", "raw.y", "raw.z", "bias")), ("centered", ("center.x", "center.y", "center.z", "bias")), ("input", ("input.x", "input.y", "bias"))): - rows = [list(row[key]) + [1.0] for row in rows_by_trial] - target_x = [row["target"]["x"] for row in rows_by_trial] - target_y = [row["target"]["y"] for row in rows_by_trial] - try: - model_x = least_squares(rows, target_x) - model_y = least_squares(rows, target_y) - except ValueError: - print(f"{key}: singular fit") - continue - print(f"{key:8} screenX = {format_model(model_x, names)}") - print(f"{key:8} screenY = {format_model(model_y, names)}") - - -def main(): - parser = argparse.ArgumentParser(description="Analyze an Oura calibration capture against ControllerKeys-Oura.log.") - parser.add_argument("capture_dir", nargs="?", type=Path, help="Capture directory from serve.py. Defaults to latest capture.") - parser.add_argument("--capture-root", type=Path, default=Path(__file__).resolve().parent / "captures") - parser.add_argument("--log", type=Path, default=Path.home() / "Library/Logs/ControllerKeys-Oura.log") - parser.add_argument("--trim-start", type=float, default=0.55, help="Seconds to ignore after each target appears.") - parser.add_argument("--trim-end", type=float, default=0.15, help="Seconds to ignore before each target ends.") - parser.add_argument("--all-sessions", action="store_true", help="Analyze every session in the capture file.") - args = parser.parse_args() - - capture_dir = args.capture_dir.expanduser() if args.capture_dir else latest_capture_dir(args.capture_root.expanduser()) - analyze(capture_dir, args.log.expanduser(), args.trim_start, args.trim_end, args.all_sessions) - - -if __name__ == "__main__": - main() diff --git a/Tools/oura-calibration/analyze_gestures.py b/Tools/oura-calibration/analyze_gestures.py deleted file mode 100644 index 7e8e3cda..00000000 --- a/Tools/oura-calibration/analyze_gestures.py +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env python3 -"""Join a gesture-labeling session (gestures.html) with the app's full-rate -motion trace, score the current recognizers against the prompted labels, and -export a labeled dataset for classifier training. - -Inputs: - - /targets.ndjson — prompt events from serve.py (trial:go/end etc.) - - the app-side trace — ndjson written by OuraMotionTraceWriter when - `defaults write KevinTang.XboxControllerMapper ouraMotionTraceLogging -bool true` - is set (default /tmp/controllerkeys-oura-motion-trace.ndjson) - -Both sides record wall-clock unix seconds ("t" in the trace, wallTimeUnix in -the prompt events), so the join is a plain time-window slice. Trace "ct" -values (CFAbsoluteTime) are stamped at BLE-frame decode, so the two samples -in one frame share a near-identical ct — sample-rate stats below dedupe those -pairs into frames before measuring spacing. -""" -import argparse -import json -import math -from collections import Counter, defaultdict -from pathlib import Path - - -ROOT = Path(__file__).resolve().parent -DEFAULT_CAPTURE_ROOT = ROOT / "captures" -DEFAULT_TRACE = Path("/tmp/controllerkeys-oura-motion-trace.ndjson") - -PRE_ROLL_SECONDS = 0.35 -POST_ROLL_SECONDS = 0.65 -FRAME_PAIR_EPSILON = 0.002 - -TAP_COUNT_LABELS = {1: "single-tap", 2: "double-tap", 3: "triple-tap", 5: "five-tap"} - - -def latest_capture_dir(root): - candidates = sorted(path for path in root.glob("*") if (path / "targets.ndjson").exists()) - if not candidates: - raise SystemExit(f"No captures found under {root}") - return candidates[-1] - - -def load_events(capture_dir): - events_path = capture_dir / "targets.ndjson" - if not events_path.exists(): - raise SystemExit(f"Missing {events_path}") - events = [] - for line in events_path.read_text(encoding="utf-8").splitlines(): - if line.strip(): - events.append(json.loads(line)) - return events - - -def event_seconds(event): - if "wallTimeUnix" in event: - return float(event["wallTimeUnix"]) - return float(event["serverReceivedUnix"]) - - -def latest_gesture_session(events): - start_index = None - for index, event in enumerate(events): - if event.get("type") == "session:start" and event.get("kind") == "gesture-labeling": - start_index = index - if start_index is None: - raise SystemExit( - "No gesture-labeling session in this capture. Pass the right capture dir " - "(each serve.py launch makes a fresh one) or run gestures.html first." - ) - for end_index in range(start_index + 1, len(events)): - if events[end_index].get("type") == "session:end": - return events[start_index:end_index + 1] - return events[start_index:] - - -def build_trials(session_events): - """Replay the event stream: go→end pairs complete a trial, discards drop - the pending or most recent one. trialIndex values are reused after a - mid-trial discard, so sequential replay is the only safe join.""" - completed = [] - pending = None - for event in session_events: - event_type = event.get("type") - if event_type == "trial:go": - pending = event - elif event_type == "trial:end": - if pending and pending.get("label") == event.get("label"): - completed.append({ - "label": pending["label"], - "expect": pending.get("expect", {}), - "windowSeconds": pending.get("windowSeconds"), - "goT": event_seconds(pending), - "endT": event_seconds(event), - }) - pending = None - elif event_type == "trial:discard": - if event.get("scope") == "current": - pending = None - elif completed: - completed.pop() - return completed - - -def load_trace(trace_path, start_time, end_time): - if not trace_path.exists(): - raise SystemExit( - f"Missing trace {trace_path} — was ouraMotionTraceLogging enabled " - "(and ControllerKeys restarted) before the session?" - ) - samples = [] - trace_events = [] - pad = 5.0 - for line in trace_path.read_text(encoding="utf-8", errors="replace").splitlines(): - line = line.strip() - if not line: - continue - try: - record = json.loads(line) - except json.JSONDecodeError: - continue - t = record.get("t") - if t is None or t < start_time - pad or t > end_time + pad: - continue - if record.get("type") == "sample": - samples.append(record) - elif record.get("type") == "event": - trace_events.append(record) - return samples, trace_events - - -def group_frames(samples): - """Collapse paired-timestamp samples (2 per BLE frame, near-identical ct) - into frames so spacing stats reflect radio cadence, not the decode stamp.""" - frames = [] - for sample in samples: - if frames and abs(sample["ct"] - frames[-1][-1]["ct"]) < FRAME_PAIR_EPSILON: - frames[-1].append(sample) - else: - frames.append([sample]) - return frames - - -def percentile(sorted_values, fraction): - if not sorted_values: - return 0.0 - index = min(len(sorted_values) - 1, int(fraction * len(sorted_values))) - return sorted_values[index] - - -def report_sample_rate(samples): - print("\n== Sample-rate (the make-or-break number for ML viability) ==") - if len(samples) < 10: - print(f" Only {len(samples)} trace samples in the session window — trace flag off, " - "ring disconnected, or clock mismatch. Cannot measure.") - return - frames = group_frames(samples) - duration = samples[-1]["ct"] - samples[0]["ct"] - if duration <= 0: - print(" Degenerate trace timestamps; cannot measure.") - return - samples_per_frame = Counter(len(frame) for frame in frames) - frame_dts = sorted( - frames[i][0]["ct"] - frames[i - 1][0]["ct"] - for i in range(1, len(frames)) - ) - median_dt = percentile(frame_dts, 0.5) - gaps = [dt for dt in frame_dts if median_dt > 0 and dt > 3 * median_dt] - print(f" {len(samples)} samples in {len(frames)} BLE frames over {duration:.1f}s") - print(f" Samples per frame: {dict(sorted(samples_per_frame.items()))}") - print(f" Frame rate: {len(frames) / duration:.1f} Hz → effective sample rate ~{len(samples) / duration:.1f} Hz") - print(f" Inter-frame dt: median {median_dt * 1000:.1f}ms, p95 {percentile(frame_dts, 0.95) * 1000:.1f}ms, max {frame_dts[-1] * 1000:.1f}ms") - if gaps: - print(f" ⚠ {len(gaps)} gaps >3× median ({max(gaps) * 1000:.0f}ms worst) — check BLE link stability") - rate = len(samples) / duration - if rate < 20: - print(f" ⚠ ~{rate:.0f} Hz is thin for tap discrimination (tap transient ≈ 20-50ms); " - "an ML classifier will be working from envelope shape, not the transient itself.") - - -def predicted_outcome(events_in_window): - """Map recognizer trace events inside a trial window to a label-space - prediction. Multiple distinct outcomes are joined with '+', which is - itself a misfire signal.""" - outcomes = [] - for event in events_in_window: - name = event.get("name") - detail = event.get("detail") - if name == "tap-resolved": - count = int(detail) if detail and detail.isdigit() else 0 - outcomes.append(TAP_COUNT_LABELS.get(count, f"{count}-tap")) - elif name == "flick": - outcomes.append(f"flick-{detail}" if detail else "flick") - elif name == "tap-hold": - outcomes.append("tap-hold") - if not outcomes: - return "none" - deduped = list(dict.fromkeys(outcomes)) - return "+".join(deduped) - - -def slice_window(records, start, end, key="t"): - return [record for record in records if start <= record[key] <= end] - - -def report_confusion(trials, trace_events): - print("\n== Recognizer vs prompt (current heuristics) ==") - matrix = defaultdict(Counter) - correct = 0 - for trial in trials: - window_events = slice_window( - trace_events, trial["goT"] - PRE_ROLL_SECONDS, trial["endT"] + POST_ROLL_SECONDS) - recognized = [e for e in window_events if e.get("name") in ("tap-resolved", "flick", "tap-hold")] - prediction = predicted_outcome(recognized) - trial["predicted"] = prediction - expected = "none" if trial["expect"].get("none") else trial["label"] - matrix[expected][prediction] += 1 - if prediction == expected: - correct += 1 - - label_width = max((len(label) for label in matrix), default=10) + 2 - for expected in sorted(matrix): - row = matrix[expected] - total = sum(row.values()) - hits = row.get(expected, 0) - parts = ", ".join(f"{pred}×{count}" for pred, count in row.most_common()) - print(f" {expected:<{label_width}} {hits}/{total} correct [{parts}]") - if trials: - print(f" Overall: {correct}/{len(trials)} ({100 * correct / len(trials):.0f}%)") - - -def export_dataset(trials, samples, trace_events, export_path): - with export_path.open("w", encoding="utf-8") as handle: - for index, trial in enumerate(trials): - start = trial["goT"] - PRE_ROLL_SECONDS - end = trial["endT"] + POST_ROLL_SECONDS - window_samples = slice_window(samples, start, end) - window_events = slice_window(trace_events, start, end) - handle.write(json.dumps({ - "label": trial["label"], - "trial": index, - "goT": trial["goT"], - "endT": trial["endT"], - "windowSeconds": trial["windowSeconds"], - "expect": trial["expect"], - "predicted": trial.get("predicted"), - "samples": [ - [s["t"], s["ct"], s["x"], s["y"], s["z"], s.get("px", 0.0), s.get("py", 0.0)] - for s in window_samples - ], - "events": [ - {"t": e["t"], "name": e.get("name"), "detail": e.get("detail")} - for e in window_events - ], - }, separators=(",", ":")) + "\n") - print(f"\nExported {len(trials)} labeled trials → {export_path}") - - -def main(): - parser = argparse.ArgumentParser(description="Analyze a gesture-labeling capture against the app motion trace.") - parser.add_argument("capture_dir", nargs="?", type=Path, - help="Capture dir from serve.py (default: latest under captures/)") - parser.add_argument("--trace", type=Path, default=DEFAULT_TRACE, - help=f"App motion trace ndjson (default: {DEFAULT_TRACE})") - parser.add_argument("--export", type=Path, - help="Dataset output path (default: /gesture-dataset.ndjson)") - args = parser.parse_args() - - capture_dir = args.capture_dir or latest_capture_dir(DEFAULT_CAPTURE_ROOT) - session_events = latest_gesture_session(load_events(capture_dir)) - trials = build_trials(session_events) - if not trials: - raise SystemExit("Session contains no completed trials.") - - session_start = trials[0]["goT"] - session_end = trials[-1]["endT"] - samples, trace_events = load_trace(args.trace, session_start, session_end) - - # A prompted window with no trace samples is lost data (trace not yet - # enabled, app restarted, …), not a recognition miss — flag and exclude it - # so the confusion matrix only scores trials the recognizers could see. - covered, dropped = [], [] - for trial in trials: - window = slice_window(samples, trial["goT"] - PRE_ROLL_SECONDS, trial["endT"] + POST_ROLL_SECONDS) - (covered if window else dropped).append(trial) - if dropped: - by_class = Counter(trial["label"] for trial in dropped) - print(f"⚠ {len(dropped)}/{len(trials)} trial windows have NO trace samples — excluded from scoring " - f"({', '.join(f'{k}×{v}' for k, v in sorted(by_class.items()))}). " - "Check when the trace flag was enabled relative to session start.") - trials = covered - - print(f"Capture: {capture_dir}") - print(f"Trace: {args.trace}") - print(f"Trials: {len(trials)} with trace coverage " - f"({session_end - session_start:.0f}s span, {len(samples)} trace samples, {len(trace_events)} trace events)") - if not samples: - print("⚠ Trace has NO samples inside the session window. Enable the flag and restart the app:\n" - " defaults write KevinTang.XboxControllerMapper ouraMotionTraceLogging -bool true") - - per_class = Counter(trial["label"] for trial in trials) - print(" Per class: " + ", ".join(f"{label}×{count}" for label, count in sorted(per_class.items()))) - - report_sample_rate(samples) - report_confusion(trials, trace_events) - - export_path = args.export or (capture_dir / "gesture-dataset.ndjson") - export_dataset(trials, samples, trace_events, export_path) - - -if __name__ == "__main__": - main() diff --git a/Tools/oura-calibration/app.js b/Tools/oura-calibration/app.js deleted file mode 100644 index c9a4338e..00000000 --- a/Tools/oura-calibration/app.js +++ /dev/null @@ -1,213 +0,0 @@ -const TARGETS = [ - { id: "center", label: "Center", x: 0, y: 0 }, - { id: "left", label: "Left", x: -0.72, y: 0 }, - { id: "center-a", label: "Center", x: 0, y: 0 }, - { id: "right", label: "Right", x: 0.72, y: 0 }, - { id: "center-b", label: "Center", x: 0, y: 0 }, - { id: "up", label: "Up", x: 0, y: -0.64 }, - { id: "center-c", label: "Center", x: 0, y: 0 }, - { id: "down", label: "Down", x: 0, y: 0.64 }, - { id: "center-d", label: "Center", x: 0, y: 0 }, - { id: "upper-left", label: "Upper left", x: -0.58, y: -0.52 }, - { id: "upper-right", label: "Upper right", x: 0.58, y: -0.52 }, - { id: "lower-right", label: "Lower right", x: 0.58, y: 0.52 }, - { id: "lower-left", label: "Lower left", x: -0.58, y: 0.52 }, - { id: "center-final", label: "Center", x: 0, y: 0 } -]; - -const CONFIG = { - prepareSeconds: 1.2, - holdSeconds: 3.4, - countdownSeconds: 3 -}; - -const sessionId = new Date().toISOString().replace(/[:.]/g, "-"); -const stage = document.getElementById("stage"); -const target = document.getElementById("target"); -const direction = document.getElementById("direction"); -const phase = document.getElementById("phase"); -const status = document.getElementById("status"); -const meterFill = document.getElementById("meterFill"); -const countdown = document.getElementById("countdown"); -const countdownNumber = document.getElementById("countdownNumber"); -const countdownText = document.getElementById("countdownText"); -const startButton = document.getElementById("startButton"); -const fullScreenButton = document.getElementById("fullScreenButton"); -const exportButton = document.getElementById("exportButton"); - -let events = []; -let running = false; -let completed = false; -let audioContext = null; - -function eventBase() { - return { - sessionId, - wallTimeIso: new Date().toISOString(), - wallTimeUnix: Date.now() / 1000, - performanceMs: performance.now(), - viewport: { - width: window.innerWidth, - height: window.innerHeight, - devicePixelRatio: window.devicePixelRatio || 1 - } - }; -} - -async function postEvent(event) { - const payload = { ...eventBase(), ...event }; - events.push(payload); - try { - await fetch("/api/event", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - } catch { - status.textContent = "Offline capture"; - } - return payload; -} - -function setTarget(nextTarget, mode) { - const left = 50 + nextTarget.x * 42; - const top = 50 + nextTarget.y * 42; - target.style.left = `${left}%`; - target.style.top = `${top}%`; - target.classList.toggle("hold", mode === "hold"); - target.classList.toggle("rest", mode !== "hold"); - direction.textContent = nextTarget.label; - phase.textContent = mode === "hold" ? "Hold steady" : "Move to target"; -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function beep(frequency, durationMs) { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - const now = audioContext.currentTime; - const oscillator = audioContext.createOscillator(); - const gain = audioContext.createGain(); - oscillator.frequency.value = frequency; - oscillator.type = "sine"; - gain.gain.setValueAtTime(0.0001, now); - gain.gain.exponentialRampToValueAtTime(0.12, now + 0.015); - gain.gain.exponentialRampToValueAtTime(0.0001, now + durationMs / 1000); - oscillator.connect(gain); - gain.connect(audioContext.destination); - oscillator.start(now); - oscillator.stop(now + durationMs / 1000); -} - -async function countdownRun() { - countdown.classList.remove("hidden"); - countdownText.textContent = "Point at center"; - for (let remaining = CONFIG.countdownSeconds; remaining > 0; remaining -= 1) { - countdownNumber.textContent = String(remaining); - beep(420 + remaining * 60, 90); - await sleep(1000); - } - countdown.classList.add("hidden"); -} - -function updateMeter(done, total) { - meterFill.style.width = `${Math.max(0, Math.min(100, (done / total) * 100))}%`; -} - -async function timedPhase(seconds, onTick) { - const started = performance.now(); - const duration = seconds * 1000; - while (performance.now() - started < duration) { - onTick((performance.now() - started) / duration); - await sleep(80); - } - onTick(1); -} - -async function runCalibration() { - if (running) return; - running = true; - completed = false; - events = []; - exportButton.disabled = true; - startButton.disabled = true; - setTarget(TARGETS[0], "rest"); - - await postEvent({ type: "session:start", config: CONFIG, targets: TARGETS }); - await countdownRun(); - - const totalPhases = TARGETS.length * 2; - let phaseIndex = 0; - for (let trialIndex = 0; trialIndex < TARGETS.length; trialIndex += 1) { - const nextTarget = TARGETS[trialIndex]; - setTarget(nextTarget, "rest"); - status.textContent = `${trialIndex + 1}/${TARGETS.length} prepare`; - await postEvent({ type: "target:prepare", trialIndex, target: nextTarget }); - await timedPhase(CONFIG.prepareSeconds, progress => updateMeter(phaseIndex + progress, totalPhases)); - phaseIndex += 1; - - setTarget(nextTarget, "hold"); - status.textContent = `${trialIndex + 1}/${TARGETS.length} hold`; - beep(820, 120); - await postEvent({ type: "target:start", trialIndex, target: nextTarget }); - await timedPhase(CONFIG.holdSeconds, progress => updateMeter(phaseIndex + progress, totalPhases)); - await postEvent({ type: "target:end", trialIndex, target: nextTarget }); - phaseIndex += 1; - } - - updateMeter(totalPhases, totalPhases); - status.textContent = "Complete"; - phase.textContent = "Capture complete"; - completed = true; - running = false; - startButton.disabled = false; - startButton.textContent = "Run again"; - exportButton.disabled = false; - await postEvent({ type: "session:end" }); - beep(560, 120); - setTimeout(() => beep(760, 120), 150); -} - -function exportEvents() { - const body = JSON.stringify({ sessionId, config: CONFIG, targets: TARGETS, events }, null, 2); - const blob = new Blob([body], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `oura-calibration-${sessionId}.json`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); -} - -startButton.addEventListener("click", runCalibration); -exportButton.addEventListener("click", exportEvents); -fullScreenButton.addEventListener("click", () => { - if (!document.fullscreenElement) { - stage.requestFullscreen?.(); - } else { - document.exitFullscreen?.(); - } -}); - -window.addEventListener("keydown", event => { - if (event.code === "Space") { - event.preventDefault(); - if (!running) runCalibration(); - } - if (event.key.toLowerCase() === "f") { - event.preventDefault(); - fullScreenButton.click(); - } - if (event.key.toLowerCase() === "e" && completed) { - event.preventDefault(); - exportEvents(); - } -}); - -setTarget(TARGETS[0], "rest"); -postEvent({ type: "page:ready", config: CONFIG, targets: TARGETS }); diff --git a/Tools/oura-calibration/decode_pklg.py b/Tools/oura-calibration/decode_pklg.py deleted file mode 100644 index 9c2b83f2..00000000 --- a/Tools/oura-calibration/decode_pklg.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 -"""Decode Oura BLE traffic from a PacketLogger capture (.pklg). - -Given a capture of the OFFICIAL Oura app talking to the ring (captured on the -iPhone via Apple's Bluetooth logging profile → sysdiagnose → the .pklg inside), -this extracts every ATT Write (app → ring) and Notification (ring → app) and -decodes the payload against the known Oura command grammar. The point is to see -the exact enable sequence the official app uses for tap-to-tag (feature 0x07) — -the command rounds 1-3 of the in-app probe never found. - - python3 decode_pklg.py capture.pklg - python3 decode_pklg.py capture.pklg --all # every ATT op, not just Oura-looking - -PacketLogger record: [len u32 BE][ts_sec u32 BE][ts_usec u32 BE][type u8][data] - type 0x02 = ACL sent (host→controller = app→ring writes) - type 0x03 = ACL received (controller→host = ring→app notifications) -ACL: [handle+flags u16 LE][acl_len u16 LE] then L2CAP [len u16 LE][cid u16 LE] - then ATT [opcode u8][payload]. ATT CID = 0x0004. -""" -import argparse -import struct -import sys -from pathlib import Path - -ATT_CID = 0x0004 -ATT_WRITE_REQ = 0x12 -ATT_WRITE_CMD = 0x52 -ATT_HANDLE_VALUE_NOTIFY = 0x1B -ATT_HANDLE_VALUE_IND = 0x1D - -OURA_AUTH = {0x2F: "control", 0x06: "realtime", 0x25: "key-install", 0x23: "read-ack", 0x27: "read-ack", 0x33: "accel-frame"} - - -def decode_oura_payload(data: bytes) -> str: - """Best-effort semantic decode of an Oura command/response payload.""" - if not data: - return "" - head = data[0] - hexs = data.hex(" ") - if head == 0x2F and len(data) >= 3: - sub, feat = data[1], data[2] - verb = {0x01: "nonce-req", 0x02: "enable?", 0x03: "config?", 0x10: "nonce-resp", - 0x11: "auth-submit"}.get(sub, f"sub{sub:#04x}") - reg = {0x20: "enable", 0x22: "cfg-A", 0x26: "cfg-B", 0x28: "PUSH", 0x2b: "nonce", - 0x2c: "nonce", 0x2d: "submit", 0x2e: "status"}.get(feat, f"reg{feat:#04x}") - rest = data[3:].hex(" ") - return f"control 2F {verb} {reg} [{rest}]" if rest else f"control 2F {verb} {reg}" - if head == 0x06 and len(data) >= 3: - bitmask = data[2] - bits = "+".join(n for b, n in ((0x20, "accel"), (0x01, "f01"), (0x02, "f02"), - (0x04, "f04"), (0x08, "f08")) if bitmask & b) or "none" - return f"realtime start bitmask={bitmask:#04x} ({bits})" - if head in (0x23, 0x27) and len(data) >= 3: - return f"read-ack feature={data[1]:#04x} state={data[2]:#04x}" - if head == 0x07 and len(data) >= 3: - return f"feature-07 announce {data[1]:#04x} state={data[2]:#04x} <-- TAP-TO-TAG" - if head == 0x33: - return f"accelerometer frame ({len(data)}B)" - return f"{OURA_AUTH.get(head, 'unknown')}: {hexs}" - - -def looks_like_oura(data: bytes) -> bool: - return bool(data) and data[0] in (0x2F, 0x06, 0x25, 0x23, 0x27, 0x33, 0x07) - - -def parse_att(payload: bytes): - """Return (kind, handle, value) for a write/notify ATT PDU, else None.""" - if len(payload) < 4: - return None - acl_len = struct.unpack_from("I", blob, off)[0] - if length < 9 or off + 4 + length > n: - break - rec = blob[off + 4: off + 4 + length] - ts_sec, ts_usec = struct.unpack_from(">II", rec, 0) - rtype = rec[8] - data = rec[9:] - yield ts_sec + ts_usec / 1e6, rtype, data - off += 4 + length - - -def main(): - parser = argparse.ArgumentParser(description="Decode Oura BLE writes/notifications from a .pklg capture.") - parser.add_argument("pklg", type=Path) - parser.add_argument("--all", action="store_true", help="show all ATT writes/notifies, not just Oura-looking") - args = parser.parse_args() - - blob = args.pklg.read_bytes() - t0 = None - shown = 0 - for ts, rtype, data in iter_records(blob): - if rtype not in (0x02, 0x03): - continue - att = parse_att(data) - if not att: - continue - kind, handle, value = att - if not args.all and not looks_like_oura(value): - continue - if t0 is None: - t0 = ts - direction = "app→ring" if rtype == 0x02 else "ring→app" - print(f"+{ts - t0:8.3f}s {direction} h{handle:#06x} {kind:<6} {decode_oura_payload(value)}") - shown += 1 - - if shown == 0: - print("No ATT writes/notifications found. Was the Bluetooth logging profile active " - "during capture, and did the OFFICIAL app connect the ring?", file=sys.stderr) - sys.exit(2) - print(f"\n{shown} Oura ATT PDUs decoded. Look for a 'PUSH' (2F .. 28 07) or a realtime " - "bitmask with an unfamiliar bit right before tap events appear.", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/Tools/oura-calibration/gestures.html b/Tools/oura-calibration/gestures.html deleted file mode 100644 index 50f2f631..00000000 --- a/Tools/oura-calibration/gestures.html +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - Oura Gesture Labeling - - - -
-
-
Oura gesture labeling
-
Ready
-
-
-

Gesture labeling session

-
    -
  1. Enable the full-rate motion trace, then restart ControllerKeys:
    - defaults write KevinTang.XboxControllerMapper ouraMotionTraceLogging -bool true
  2. -
  3. Confirm the ring is connected and moving the cursor.
  4. -
  5. Make sure Oura gestures aren't mapped to actions that type text — the - prompted taps will fire them. Harmless keys (F18/F19) or unmapped is fine.
  6. -
  7. Perform each prompt only while GO is on screen; keep your hand - relaxed between prompts. Backspace discards a flubbed trial (it gets - re-queued), Esc ends the session early.
  8. -
-
- - - - -
-
-
-
Get ready
-
-
-
-
-

Session complete

-

-

Analyze and export the labeled dataset with:
- python3 Tools/oura-calibration/analyze_gestures.py

-
-
Backspace — discard last trial  ·  Esc — end session
-
-
- - - diff --git a/Tools/oura-calibration/gestures.js b/Tools/oura-calibration/gestures.js deleted file mode 100644 index 044b05e9..00000000 --- a/Tools/oura-calibration/gestures.js +++ /dev/null @@ -1,308 +0,0 @@ -// Gesture-labeling prompt engine for the Oura tap/flick classifier dataset. -// -// Serve with serve.py (same capture pipeline as index.html/app.js); labeled -// trial windows land in the capture dir's targets.ndjson. The app-side -// full-rate motion trace (defaults write KevinTang.XboxControllerMapper -// ouraMotionTraceLogging -bool true) must be enabled so analyze_gestures.py -// can join prompts to accelerometer samples by wall-clock time. - -// v2 (2026-07-06): more flicks prompted as NATURAL desk-posture motions (v1's -// exaggerated prompted flicks scored ≥0.98 model confidence while Kevin's -// casual flicks ranged 0.35-1.00 — distribution mismatch), and three -// deliberate cursor-navigation negative classes (v1 had only 6 aimless -// noise-move trials, so cursor use produced phantom flick classifications). -// v3 (2026-07-06): tap-heavy — multi-tap counts are the weak spot and the -// corpus lacks LIGHT taps (prompted sessions captured only crisp ones; live -// misses are lazy taps below/near the detector floor). Duplicate class -// entries with different hints are legal: trials merge by id for -// scoring/training. Flicks omitted — they test at 94-100% and the old flick -// events stay in the merged training set. -const CLASSES = [ - { id: "single-tap", label: "SINGLE TAP", hint: "One crisp tap", reps: 10, windowSeconds: 2.0, expect: { taps: 1 } }, - { id: "single-tap", label: "LAZY SINGLE TAP", hint: "One light, lazy tap — barely-there, couch-style", reps: 10, windowSeconds: 2.0, expect: { taps: 1 } }, - { id: "double-tap", label: "DOUBLE TAP", hint: "Two quick taps", reps: 12, windowSeconds: 2.5, expect: { taps: 2 } }, - { id: "double-tap", label: "LAZY DOUBLE TAP", hint: "Two light, lazy taps — your natural pace", reps: 8, windowSeconds: 2.5, expect: { taps: 2 } }, - { id: "triple-tap", label: "TRIPLE TAP", hint: "Three quick taps", reps: 12, windowSeconds: 3.0, expect: { taps: 3 } }, - { id: "triple-tap", label: "LAZY TRIPLE TAP", hint: "Three light taps at your natural pace", reps: 6, windowSeconds: 3.0, expect: { taps: 3 } }, - { id: "five-tap", label: "5x TAP", hint: "Five taps at your natural pace", reps: 10, windowSeconds: 4.0, expect: { taps: 5 } }, - { id: "tap-hold", label: "TAP + HOLD", hint: "Tap once, then hold perfectly still", reps: 4, windowSeconds: 2.5, expect: { tapHold: true } }, - { id: "noise-still", label: "HOLD STILL", hint: "Do nothing — hand relaxed", reps: 2, windowSeconds: 5.0, expect: { none: true } }, - { id: "noise-cursor-point", label: "POINT AT THINGS", hint: "Move the cursor deliberately — point at corners, icons, buttons. No gestures.", reps: 6, windowSeconds: 5.0, expect: { none: true } }, - { id: "noise-cursor-fast", label: "FAST CURSOR SWEEPS", hint: "Big quick cursor sweeps across the screen. No gestures.", reps: 4, windowSeconds: 5.0, expect: { none: true } } -]; - -const PREPARE_SECONDS = 1.4; -const REST_SECONDS = 1.2; -const REST_JITTER_SECONDS = 0.4; - -const sessionId = new Date().toISOString().replace(/[:.]/g, "-"); -const stage = document.getElementById("stage"); -const status = document.getElementById("status"); -const setupScreen = document.getElementById("setup"); -const runScreen = document.getElementById("run"); -const doneScreen = document.getElementById("done"); -const phaseBanner = document.getElementById("phaseBanner"); -const promptText = document.getElementById("prompt"); -const hintText = document.getElementById("hint"); -const progressBar = document.getElementById("progressBar"); -const doneSummary = document.getElementById("doneSummary"); -const scaleSelect = document.getElementById("scale"); -const startButton = document.getElementById("startButton"); -const fullScreenButton = document.getElementById("fullScreenButton"); - -let running = false; -let trials = []; -let trialIndex = 0; -let completedCount = 0; -let discardedCount = 0; -let currentTrial = null; -let currentPhase = "idle"; -let phaseTimer = null; -let progressTimer = null; -let audioContext = null; - -function eventBase() { - return { - sessionId, - wallTimeIso: new Date().toISOString(), - wallTimeUnix: Date.now() / 1000, - performanceMs: performance.now(), - viewport: { - width: window.innerWidth, - height: window.innerHeight, - devicePixelRatio: window.devicePixelRatio || 1 - } - }; -} - -async function postEvent(event) { - const payload = { ...eventBase(), ...event }; - try { - await fetch("/api/event", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }); - } catch { - status.textContent = "Offline — events not captured!"; - } - return payload; -} - -function beep(frequency, durationMs) { - try { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - const osc = audioContext.createOscillator(); - const gain = audioContext.createGain(); - osc.frequency.value = frequency; - osc.type = "sine"; - gain.gain.setValueAtTime(0.22, audioContext.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + durationMs / 1000); - osc.connect(gain); - gain.connect(audioContext.destination); - osc.start(); - osc.stop(audioContext.currentTime + durationMs / 1000); - } catch { - // Audio is a nicety; the visual phase cues carry the session. - } -} - -function shuffle(list) { - const out = list.slice(); - for (let i = out.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [out[i], out[j]] = [out[j], out[i]]; - } - return out; -} - -function buildTrialList(scale) { - const list = []; - for (const cls of CLASSES) { - const reps = Math.max(1, Math.round(cls.reps * scale)); - for (let i = 0; i < reps; i++) { - list.push(cls); - } - } - return shuffle(list); -} - -function setPhase(phaseName) { - currentPhase = phaseName; - stage.classList.remove("phase-prepare", "phase-go", "phase-rest"); - if (phaseName !== "idle") { - stage.classList.add(`phase-${phaseName}`); - } -} - -function clearTimers() { - if (phaseTimer) { clearTimeout(phaseTimer); phaseTimer = null; } - if (progressTimer) { clearInterval(progressTimer); progressTimer = null; } - progressBar.style.width = "0%"; -} - -function updateStatus() { - status.textContent = `Trial ${Math.min(trialIndex + 1, trials.length)} / ${trials.length}`; -} - -function trialPayload(cls) { - return { - label: cls.id, - classId: cls.id, - trialIndex, - windowSeconds: cls.windowSeconds, - expect: cls.expect - }; -} - -function startNextTrial() { - if (!running) { return; } - if (trialIndex >= trials.length) { - finishSession("completed"); - return; - } - const cls = trials[trialIndex]; - currentTrial = cls; - updateStatus(); - - setPhase("prepare"); - phaseBanner.textContent = "Next"; - promptText.textContent = cls.label; - hintText.textContent = cls.hint; - postEvent({ type: "trial:prepare", ...trialPayload(cls) }); - - phaseTimer = setTimeout(() => beginGo(cls), PREPARE_SECONDS * 1000); -} - -function beginGo(cls) { - if (!running) { return; } - setPhase("go"); - phaseBanner.textContent = "GO"; - beep(880, 90); - postEvent({ type: "trial:go", ...trialPayload(cls) }); - - const goStartMs = performance.now(); - const windowMs = cls.windowSeconds * 1000; - progressTimer = setInterval(() => { - const fraction = Math.min(1, (performance.now() - goStartMs) / windowMs); - progressBar.style.width = `${(fraction * 100).toFixed(1)}%`; - }, 50); - - phaseTimer = setTimeout(() => endTrial(cls), windowMs); -} - -function endTrial(cls) { - if (!running) { return; } - clearTimers(); - beep(440, 80); - postEvent({ type: "trial:end", ...trialPayload(cls) }); - - setPhase("rest"); - phaseBanner.textContent = "Rest"; - hintText.textContent = "Relax your hand"; - trialIndex += 1; - completedCount += 1; - currentTrial = null; - updateStatus(); - - // Rest padding ≥1.2s deliberately clears the app's 0.75s post-recenter - // tap-suppression window so trials never bleed into each other. - const restMs = (REST_SECONDS + Math.random() * REST_JITTER_SECONDS) * 1000; - phaseTimer = setTimeout(startNextTrial, restMs); -} - -function discardLast() { - if (!running) { return; } - if (currentTrial) { - // Mid-trial: abort this one, re-queue the same class at the end. - clearTimers(); - postEvent({ type: "trial:discard", ...trialPayload(currentTrial), scope: "current" }); - trials.push(currentTrial); - trials.splice(trialIndex, 1); - discardedCount += 1; - currentTrial = null; - setPhase("rest"); - phaseBanner.textContent = "Discarded"; - hintText.textContent = "Trial re-queued"; - phaseTimer = setTimeout(startNextTrial, REST_SECONDS * 1000); - } else if (completedCount > 0) { - // Between trials: retract the one that just ended, re-queue it. - const lastCls = trials[trialIndex - 1]; - postEvent({ - type: "trial:discard", - label: lastCls.id, - classId: lastCls.id, - trialIndex: trialIndex - 1, - scope: "previous" - }); - trials.push(lastCls); - completedCount -= 1; - discardedCount += 1; - phaseBanner.textContent = "Discarded"; - hintText.textContent = "Previous trial re-queued"; - updateStatus(); - } -} - -function finishSession(reason) { - running = false; - clearTimers(); - setPhase("idle"); - postEvent({ type: "session:end", reason, completedCount, discardedCount }); - runScreen.style.display = "none"; - doneScreen.style.display = "block"; - status.textContent = "Done"; - doneSummary.textContent = - `${completedCount} labeled trial${completedCount === 1 ? "" : "s"} captured` + - (discardedCount > 0 ? `, ${discardedCount} discarded` : "") + - (reason === "aborted" ? " (session ended early)" : "") + "."; -} - -function startSession() { - const scale = parseFloat(scaleSelect.value) || 1; - trials = buildTrialList(scale); - trialIndex = 0; - completedCount = 0; - discardedCount = 0; - running = true; - - postEvent({ - type: "session:start", - kind: "gesture-labeling", - scale, - prepareSeconds: PREPARE_SECONDS, - restSeconds: REST_SECONDS, - restJitterSeconds: REST_JITTER_SECONDS, - classes: CLASSES.map((cls) => ({ - id: cls.id, - reps: Math.max(1, Math.round(cls.reps * scale)), - windowSeconds: cls.windowSeconds, - expect: cls.expect - })), - totalTrials: trials.length - }); - - setupScreen.style.display = "none"; - runScreen.style.display = "block"; - startNextTrial(); -} - -startButton.addEventListener("click", startSession); -fullScreenButton.addEventListener("click", () => { - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - document.documentElement.requestFullscreen(); - } -}); - -window.addEventListener("keydown", (event) => { - if (event.key === "Backspace") { - event.preventDefault(); - discardLast(); - } else if (event.key === "Escape" && running) { - finishSession("aborted"); - } -}); diff --git a/Tools/oura-calibration/index.html b/Tools/oura-calibration/index.html deleted file mode 100644 index cd24cf68..00000000 --- a/Tools/oura-calibration/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - Oura Ring Calibration - - - -
-
-
Oura calibration
-
Ready
-
-
- - - -
-
-
-
-
Center
-
Point straight into the screen
-
-
- -
- - - diff --git a/Tools/oura-calibration/serve.py b/Tools/oura-calibration/serve.py deleted file mode 100644 index 417bc965..00000000 --- a/Tools/oura-calibration/serve.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import mimetypes -import os -import threading -import time -import webbrowser -from datetime import datetime, timezone -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from urllib.parse import urlparse - - -ROOT = Path(__file__).resolve().parent -DEFAULT_CAPTURE_ROOT = ROOT / "captures" - - -class CaptureStore: - def __init__(self, capture_root: Path): - self.capture_root = capture_root - self.capture_root.mkdir(parents=True, exist_ok=True) - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - self.capture_dir = self.capture_root / stamp - self.capture_dir.mkdir(parents=True, exist_ok=False) - self.events_path = self.capture_dir / "targets.ndjson" - self.metadata_path = self.capture_dir / "metadata.json" - self.lock = threading.Lock() - self.write_metadata({ - "capture_dir": str(self.capture_dir), - "created_at": datetime.now(timezone.utc).isoformat(), - }) - - def write_metadata(self, metadata): - self.metadata_path.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") - - def append_event(self, event): - server_event = dict(event) - server_event["serverReceivedIso"] = datetime.now(timezone.utc).isoformat() - server_event["serverReceivedUnix"] = time.time() - with self.lock: - with self.events_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(server_event, separators=(",", ":")) + "\n") - return server_event - - -class CalibrationHandler(BaseHTTPRequestHandler): - store = None - - def log_message(self, fmt, *args): - return - - def do_GET(self): - parsed = urlparse(self.path) - if parsed.path == "/api/status": - self.send_json({ - "captureDir": str(self.store.capture_dir), - "eventsPath": str(self.store.events_path), - }) - return - - path = parsed.path - if path in ("", "/"): - path = "/index.html" - requested = (ROOT / path.lstrip("/")).resolve() - if ROOT not in requested.parents and requested != ROOT: - self.send_error(403) - return - if not requested.exists() or not requested.is_file(): - self.send_error(404) - return - - body = requested.read_bytes() - content_type = mimetypes.guess_type(str(requested))[0] or "application/octet-stream" - self.send_response(200) - self.send_header("Content-Type", content_type) - self.send_header("Cache-Control", "no-store") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_POST(self): - parsed = urlparse(self.path) - if parsed.path != "/api/event": - self.send_error(404) - return - length = int(self.headers.get("Content-Length", "0")) - try: - event = json.loads(self.rfile.read(length).decode("utf-8")) - except json.JSONDecodeError: - self.send_error(400) - return - - written = self.store.append_event(event) - if written.get("type") == "session:start": - self.store.write_metadata({ - "capture_dir": str(self.store.capture_dir), - "created_at": datetime.now(timezone.utc).isoformat(), - "session_id": written.get("sessionId"), - "config": written.get("config"), - "targets": written.get("targets"), - }) - self.send_json({"ok": True}) - - def send_json(self, payload, status=200): - body = json.dumps(payload, indent=2).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Cache-Control", "no-store") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - -def bind_server(port, store): - CalibrationHandler.store = store - for candidate in range(port, port + 20): - try: - server = ThreadingHTTPServer(("127.0.0.1", candidate), CalibrationHandler) - return candidate, server - except OSError: - continue - raise SystemExit(f"Could not bind a port from {port} to {port + 19}") - - -def main(): - parser = argparse.ArgumentParser(description="Serve the Oura Ring fullscreen calibration target app.") - parser.add_argument("--port", type=int, default=8765) - parser.add_argument("--capture-root", type=Path, default=DEFAULT_CAPTURE_ROOT) - parser.add_argument("--open", action="store_true", help="Open the calibration page in the default browser.") - args = parser.parse_args() - - store = CaptureStore(args.capture_root.expanduser()) - port, server = bind_server(args.port, store) - url = f"http://127.0.0.1:{port}/" - print(f"Calibration URL: {url}") - print(f"Capture dir: {store.capture_dir}") - print(f"Events file: {store.events_path}") - print("Analyze after a run with:") - print(f" python3 {ROOT / 'analyze.py'} {store.capture_dir}") - if args.open: - webbrowser.open(url) - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nStopped.") - - -if __name__ == "__main__": - main() diff --git a/Tools/oura-calibration/style.css b/Tools/oura-calibration/style.css deleted file mode 100644 index 7254f7c6..00000000 --- a/Tools/oura-calibration/style.css +++ /dev/null @@ -1,302 +0,0 @@ -:root { - --bg: #08110f; - --panel: rgba(9, 19, 17, 0.82); - --ink: #f3f7ef; - --muted: #9cad9e; - --accent: #f2c14e; - --accent-2: #54d6a0; - --line: rgba(255, 255, 255, 0.14); -} - -* { - box-sizing: border-box; -} - -html, -body { - width: 100%; - height: 100%; - margin: 0; - overflow: hidden; - background: var(--bg); - color: var(--ink); - font-family: "Avenir Next", "SF Pro Rounded", ui-rounded, system-ui, sans-serif; -} - -body::before { - content: ""; - position: fixed; - inset: 0; - background: - linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), - radial-gradient(circle at 50% 50%, rgba(84, 214, 160, 0.16), transparent 48%); - background-size: 48px 48px, 48px 48px, 100% 100%; - pointer-events: none; -} - -#stage { - position: relative; - width: 100vw; - height: 100vh; -} - -.hud, -.controls, -.label, -.countdown-card { - border: 1px solid var(--line); - background: var(--panel); - backdrop-filter: blur(14px); - border-radius: 8px; - box-shadow: 0 22px 60px rgba(0, 0, 0, 0.24); -} - -.hud { - position: absolute; - left: 24px; - top: 22px; - z-index: 4; - display: flex; - align-items: center; - gap: 14px; - padding: 12px 14px; -} - -.title { - font-size: 14px; - font-weight: 700; - letter-spacing: 0; - white-space: nowrap; -} - -.status { - min-width: 170px; - font-size: 12px; - color: var(--muted); - white-space: nowrap; -} - -.controls { - position: absolute; - right: 24px; - top: 22px; - z-index: 4; - display: flex; - gap: 8px; - padding: 8px; -} - -button { - appearance: none; - border: 1px solid rgba(255, 255, 255, 0.17); - background: rgba(255, 255, 255, 0.08); - color: var(--ink); - border-radius: 6px; - height: 36px; - padding: 0 13px; - font: inherit; - font-size: 13px; - font-weight: 700; - cursor: pointer; -} - -button.primary { - background: var(--accent); - color: #211706; - border-color: transparent; -} - -button:disabled { - color: rgba(255, 255, 255, 0.34); - cursor: default; -} - -.crosshair { - position: absolute; - left: 50%; - top: 50%; - width: min(86vw, 86vh); - height: min(86vw, 86vh); - transform: translate(-50%, -50%); - border: 1px solid rgba(255, 255, 255, 0.10); - border-radius: 50%; - pointer-events: none; -} - -.crosshair::before, -.crosshair::after { - content: ""; - position: absolute; - background: rgba(255, 255, 255, 0.10); -} - -.crosshair::before { - left: 50%; - top: -7vh; - width: 1px; - height: calc(100% + 14vh); -} - -.crosshair::after { - top: 50%; - left: -7vw; - width: calc(100% + 14vw); - height: 1px; -} - -.target { - position: absolute; - left: 50%; - top: 50%; - width: 72px; - height: 72px; - border-radius: 50%; - transform: translate(-50%, -50%) scale(0.82); - background: var(--accent); - box-shadow: - 0 0 0 12px rgba(242, 193, 78, 0.14), - 0 0 0 28px rgba(242, 193, 78, 0.06), - 0 18px 44px rgba(0, 0, 0, 0.38); - transition: - left 520ms cubic-bezier(.2, .9, .16, 1), - top 520ms cubic-bezier(.2, .9, .16, 1), - transform 180ms ease, - background 180ms ease; -} - -.target::before, -.target::after { - content: ""; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - background: #211706; - border-radius: 2px; -} - -.target::before { - width: 34px; - height: 5px; -} - -.target::after { - width: 5px; - height: 34px; -} - -.target.hold { - background: var(--accent-2); - box-shadow: - 0 0 0 12px rgba(84, 214, 160, 0.16), - 0 0 0 28px rgba(84, 214, 160, 0.07), - 0 18px 44px rgba(0, 0, 0, 0.38); - transform: translate(-50%, -50%) scale(1); -} - -.target.rest { - background: rgba(255, 255, 255, 0.62); - box-shadow: - 0 0 0 12px rgba(255, 255, 255, 0.09), - 0 18px 44px rgba(0, 0, 0, 0.30); -} - -.label { - position: absolute; - left: 50%; - bottom: 12vh; - z-index: 3; - transform: translateX(-50%); - min-width: 320px; - padding: 14px 18px; - text-align: center; -} - -.direction { - font-size: clamp(28px, 5vw, 72px); - font-weight: 800; - line-height: 1; - letter-spacing: 0; -} - -.phase { - margin-top: 8px; - font-size: 13px; - color: var(--muted); -} - -.meter { - position: absolute; - left: 24px; - right: 24px; - bottom: 22px; - z-index: 4; - height: 10px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.10); - overflow: hidden; -} - -.meter > div { - height: 100%; - width: 0%; - background: linear-gradient(90deg, var(--accent), var(--accent-2)); - transition: width 80ms linear; -} - -.countdown { - position: absolute; - inset: 0; - z-index: 5; - display: grid; - place-items: center; - background: rgba(8, 17, 15, 0.70); - backdrop-filter: blur(6px); -} - -.countdown.hidden { - display: none; -} - -.countdown-card { - width: min(520px, calc(100vw - 42px)); - padding: 28px; - text-align: center; - box-shadow: 0 28px 90px rgba(0, 0, 0, 0.34); -} - -.countdown-number { - font-size: 112px; - font-weight: 900; - line-height: 0.95; - color: var(--accent); -} - -.countdown-text { - margin-top: 14px; - font-size: 16px; - color: var(--muted); -} - -@media (max-width: 720px) { - .hud, - .controls { - left: 14px; - right: 14px; - } - - .controls { - top: auto; - bottom: 42px; - } - - .hud { - top: 14px; - } - - .label { - bottom: 18vh; - min-width: min(320px, calc(100vw - 42px)); - } -} diff --git a/Tools/oura-calibration/tune_gestures.py b/Tools/oura-calibration/tune_gestures.py deleted file mode 100644 index 7e0fe3a2..00000000 --- a/Tools/oura-calibration/tune_gestures.py +++ /dev/null @@ -1,655 +0,0 @@ -#!/usr/bin/env python3 -"""Offline replay tuner for the Oura gesture heuristics. - -Faithful Python port of the Swift recognizer pipeline (OuraTapDetector, -OuraTapSequenceRecognizer, OuraTapHoldRecognizer, OuraDirectionalFlickRecognizer, -OuraTapMotionSuppressor + the OuraRingInputService glue), replayed over an -archived motion trace so threshold changes can be scored against the prompted -labels from a gestures.html session without touching the ring. - -Modes: - --verify replay stock params with session-time mappings and diff the - emitted events against the events recorded live in the trace - (port-fidelity check — run this before trusting any sweep) - --sweep N random-restart hill climb, N iterations, production mappings - (default) single replay + confusion matrix at stock params - -Port-fidelity notes (kept in sync with the Swift sources): - - `detectedTap = ts >= suppressTapDetectionUntil && tapDetector.register(...)` - short-circuits: during center suppression the detector's sample history - freezes. Same for the flick recognizer behind the tapMotionSuppressor gate. - - Live, the "center" trace event is written after its sample line but its - suppression applies to that same sample's detection — replay preloads - center times and applies them at ct <= sample.ct. - - Whether a gesture is MAPPED changes recognizer state: a mapped tap-hold - resets the tap sequence (killing the spurious trailing single-tap); mapped - flicks/holds add post-action suppression. Session fidelity uses the - mappings in effect during capture (taps mapped, hold/flicks NOT); sweeps - score with production mappings (everything mapped). -""" -import argparse -import bisect -import json -import math -import random -from dataclasses import dataclass, fields, replace -from pathlib import Path - -from analyze_gestures import ( - build_trials, - latest_gesture_session, - load_events, - predicted_outcome, - slice_window, -) - - -ROOT = Path(__file__).resolve().parent -DEFAULT_CAPTURE = ROOT / "captures" / "20260705-231028" - -PRE_ROLL_SECONDS = 0.35 -POST_ROLL_SECONDS = 0.65 - - -# Params defaults mirror the constants the app SHIPPED WITH during the -# 2026-07-05 labeled session — keep them frozen so `--verify` stays -# reproducible against that archive. The tuned constants shipped to the Swift -# code on 2026-07-06 are below in SHIPPED_2026_07_06; to verify a NEW session -# recorded on the tuned build, replay with `replace(Params(), **SHIPPED_2026_07_06)`. -SHIPPED_2026_07_06 = dict( - det_refractory=0.18, det_quiet_leadin_jerk=1.63, det_peak_jerk=0.44, - det_peak_magnitude=1.064, det_peak_magnitude_delta=0.05, det_confirm_window=0.145, - det_peak_drop=0.13, det_settle_ratio=0.92, - hold_duration=0.6, hold_settle=0.09, hold_min_still=0.35, - hold_max_anchor_drift=0.25, hold_max_sample_step=0.25, - sequence_window=0.75, # raised from 0.65 later on 2026-07-06 — live 3x/5x chains split on ~0.7s gaps -) -# hold params retightened same day: duration 0.36→0.6, stillness 0.44/0.45→0.25 -# (live showed 42% of taps firing as holds — a gently moving hand passed the loose bounds) - - -@dataclass(frozen=True) -class Params: - # OuraTapDetector - det_refractory: float = 0.10 - det_max_dt: float = 0.20 - det_quiet_leadin_jerk: float = 0.70 - det_jerk_override_abs: float = 1.35 - det_jerk_override_ratio: float = 2.0 - det_peak_jerk: float = 0.55 - det_peak_magnitude: float = 1.10 - det_peak_magnitude_delta: float = 0.15 - det_confirm_window: float = 0.10 - det_reversal_dot: float = -0.02 - det_peak_drop: float = 0.08 - det_settle_ratio: float = 0.90 - # OuraRingInputService - accel_tap_refractory: float = 0.16 - center_tap_suppression: float = 0.75 - tap_motion_suppression_extra: float = 0.20 # + sequence_window - post_action_suppression: float = 0.18 - # OuraTapSequenceRecognizer - sequence_window: float = 0.65 - duplicate_window: float = 0.09 - max_tap_count: int = 5 - timer_slack: float = 0.02 - # Echo guard (2026-07-06): at resolution, a trailing tap closer than this - # to its predecessor is dropped as hand-settle unless the preceding gap was - # just as fast (established chain rhythm). Doubles then need a two-beat - # rhythm. 0 = disabled, mirroring the shipped default — Kevin's echo - # misfires only occur with a stale motion center, and his natural doubles - # gap ~0.29s, inside any guard that would catch echoes (gaps 0.18-0.30 on - # the labeled sessions). Set 0.35 to experiment with the guard on. - # Policy constant, not a tuned param — keep out of SEARCH_SPACE. - min_double_gap: float = 0.0 - # OuraTapHoldRecognizer - hold_duration: float = 0.42 - hold_settle: float = 0.16 - hold_min_still: float = 0.22 - hold_max_duration: float = 1.2 - hold_max_anchor_drift: float = 0.34 - hold_max_sample_step: float = 0.28 - # OuraDirectionalFlickRecognizer - flick_return_threshold: float = 0.24 - flick_min_snap_distance: float = 0.48 - flick_min_snap_velocity: float = 2.4 - flick_axis_dominance: float = 1.55 - flick_max_snap_duration: float = 0.32 - flick_max_return_duration: float = 0.45 - flick_cooldown: float = 0.65 - # structural: gate flick registration behind the tap-motion suppressor - # (True matches the shipped code; False quantifies removing the gate) - flick_gate_by_suppression: bool = True - - -SWEEP_RANGES = { - "det_quiet_leadin_jerk": (0.30, 1.50), - "det_jerk_override_abs": (0.80, 2.50), - "det_peak_jerk": (0.30, 1.20), - "det_peak_magnitude": (1.00, 1.40), - "det_peak_magnitude_delta": (0.05, 0.35), - "det_confirm_window": (0.06, 0.20), - "det_peak_drop": (0.02, 0.20), - "det_settle_ratio": (0.60, 1.20), - "det_refractory": (0.06, 0.20), - "accel_tap_refractory": (0.08, 0.30), - "sequence_window": (0.35, 0.90), - "duplicate_window": (0.04, 0.20), - "tap_motion_suppression_extra": (-0.40, 0.40), - "hold_duration": (0.30, 0.70), - "hold_settle": (0.08, 0.30), - "hold_min_still": (0.10, 0.40), - "hold_max_anchor_drift": (0.15, 0.50), - "hold_max_sample_step": (0.15, 0.50), - "flick_return_threshold": (0.10, 0.50), - "flick_min_snap_distance": (0.20, 0.80), - "flick_min_snap_velocity": (0.80, 4.00), - "flick_axis_dominance": (1.10, 2.50), - "flick_max_snap_duration": (0.15, 0.60), - "flick_max_return_duration": (0.20, 1.00), - "flick_cooldown": (0.30, 1.00), - "flick_gate_by_suppression": (False, True), -} - - -@dataclass(frozen=True) -class Mappings: - double_tap: bool = True - triple_tap: bool = True - tap_hold: bool = True - flicks: bool = True - - -SESSION_MAPPINGS = Mappings(tap_hold=False, flicks=False) -PRODUCTION_MAPPINGS = Mappings() - - -def hypot3(x, y, z): - return math.sqrt(x * x + y * y + z * z) - - -class TapDetector: - def __init__(self, p): - self.p = p - self.sample_before_previous = None - self.previous_sample = None - self.pending = None # (prev_sample, sample, delta, peak_magnitude) - self.last_tap_time = 0.0 - - def register(self, s): - # s = (ct, x, y, z) - try: - p = self.p - if self.pending is not None: - if self._confirms(self.pending, s): - self.pending = None - self.last_tap_time = s[0] - return True - if s[0] - self.pending[1][0] > p.det_confirm_window: - self.pending = None - - prev = self.previous_sample - if prev is None: - return False - if s[0] - self.last_tap_time <= p.det_refractory: - return False - - dt = s[0] - prev[0] - if not (0 < dt < p.det_max_dt): - return False - - delta = (s[1] - prev[1], s[2] - prev[2], s[3] - prev[3]) - jerk = hypot3(*delta) - magnitude = hypot3(s[1], s[2], s[3]) - previous_magnitude = hypot3(prev[1], prev[2], prev[3]) - magnitude_delta = abs(magnitude - previous_magnitude) - before = self.sample_before_previous - if before is None: - return False - lead_in_jerk = hypot3(prev[1] - before[1], prev[2] - before[2], prev[3] - before[3]) - quiet_lead_in = (lead_in_jerk < p.det_quiet_leadin_jerk or - jerk > max(p.det_jerk_override_abs, lead_in_jerk * p.det_jerk_override_ratio)) - sharp_peak = (jerk > p.det_peak_jerk and magnitude > p.det_peak_magnitude and - magnitude_delta > p.det_peak_magnitude_delta) - - if quiet_lead_in and sharp_peak: - self.pending = (prev, s, delta, magnitude) - return False - finally: - self.sample_before_previous = self.previous_sample - self.previous_sample = s - - def _confirms(self, pending, s): - p = self.p - prev_sample, cand, delta, peak_magnitude = pending - dt = s[0] - cand[0] - if not (0 < dt <= p.det_confirm_window): - return False - follow = (s[1] - cand[1], s[2] - cand[2], s[3] - cand[3]) - magnitude = hypot3(s[1], s[2], s[3]) - peak_drop = peak_magnitude - magnitude - reversal = (delta[0] * follow[0] + delta[1] * follow[1] + delta[2] * follow[2]) < p.det_reversal_dot - settled = hypot3(s[1] - prev_sample[1], s[2] - prev_sample[2], s[3] - prev_sample[3]) - return reversal and (peak_drop > p.det_peak_drop or settled < hypot3(*delta) * p.det_settle_ratio) - - -class TapSequence: - def __init__(self, p): - self.p = p - self.tap_times = [] - self.last_accepted_tap_time = None - self.last_echo_gap = None - - @property - def tap_count(self): - return len(self.tap_times) - - @property - def last_tap_time(self): - return self.tap_times[-1] if self.tap_times else None - - def reset(self): - self.tap_times = [] - self.last_accepted_tap_time = None - - def register_tap(self, ts): - p = self.p - if self.last_accepted_tap_time is not None and ts - self.last_accepted_tap_time < p.duplicate_window: - return ("duplicate", 0) - if self.tap_times and ts - self.tap_times[-1] <= p.sequence_window: - self.tap_times.append(ts) - else: - self.tap_times = [ts] - self.last_accepted_tap_time = ts - if len(self.tap_times) >= p.max_tap_count: - completed = len(self.tap_times) - self.tap_times = [] - return ("completed", completed) - return ("pending", len(self.tap_times)) - - def resolve_pending(self, ts): - self.last_echo_gap = None - if not self.tap_times or ts - self.tap_times[-1] < self.p.sequence_window: - return None - times = self.tap_times - self.tap_times = [] - guard = getattr(self.p, "min_double_gap", 0.0) - if guard > 0 and len(times) >= 2: - trailing = times[-1] - times[-2] - preceding = times[-2] - times[-3] if len(times) >= 3 else float("inf") - if trailing < guard and preceding >= guard: - times = times[:-1] - self.last_echo_gap = trailing - return len(times) if times else None - - -class TapHold: - def __init__(self, p): - self.p = p - self.candidate = None # [start_time, anchor, anchor_time] - self.previous_sample = None - - def register_tap(self, ts, sample): - if sample is None: - self.candidate = None - self.previous_sample = None - return - self.candidate = [ts, None, None] - self.previous_sample = sample - - def cancel(self): - self.candidate = None - - def register_motion(self, s): - try: - p = self.p - cand = self.candidate - if cand is None: - return False - elapsed = s[0] - cand[0] - if elapsed < 0: - return False - if elapsed > p.hold_max_duration: - self.candidate = None - return False - if elapsed < p.hold_settle: - return False - if cand[1] is None: - cand[1] = s - cand[2] = s[0] - return False - anchor, anchor_time = cand[1], cand[2] - if self._dist(s, anchor) > p.hold_max_anchor_drift: - self.candidate = None - return False - prev = self.previous_sample - if prev is not None and prev[0] >= anchor_time and self._dist(s, prev) > p.hold_max_sample_step: - self.candidate = None - return False - if elapsed < p.hold_duration or s[0] - anchor_time < p.hold_min_still: - return False - self.candidate = None - return True - finally: - self.previous_sample = s - - def _dist(self, a, b): - return hypot3(a[1] - b[1], a[2] - b[2], a[3] - b[3]) - - -class Flick: - def __init__(self, p): - self.p = p - self.previous_point = None - self.previous_timestamp = None - self.candidate = None # (direction, start_time, peak_time, start, peak) - self.cooldown_until = 0.0 - - def register(self, point, ts): - try: - p = self.p - if ts < self.cooldown_until: - return None - if self.candidate is not None: - return self._update(point, ts) - if self.previous_point is None or self.previous_timestamp is None: - return None - dt = ts - self.previous_timestamp - if not (0 < dt <= p.flick_max_snap_duration): - return None - dx = point[0] - self.previous_point[0] - dy = point[1] - self.previous_point[1] - snap = math.hypot(dx, dy) - if snap < p.flick_min_snap_distance or snap / dt < p.flick_min_snap_velocity: - return None - direction = self._dominant(dx, dy) - if direction is None: - return None - self.candidate = (direction, self.previous_timestamp, ts, self.previous_point, point) - return None - finally: - self.previous_point = point - self.previous_timestamp = ts - - def _update(self, point, ts): - p = self.p - direction, start_time, peak_time, start, peak = self.candidate - if ts - peak_time > p.flick_max_return_duration: - self.candidate = None - return None - if math.dist(point, start) > math.dist(peak, start): - peak_time, peak = ts, point - self.candidate = (direction, start_time, peak_time, start, peak) - if math.dist(point, start) > p.flick_return_threshold: - return None - self.candidate = None - self.cooldown_until = ts + p.flick_cooldown - return direction - - def _dominant(self, dx, dy): - ax, ay = abs(dx), abs(dy) - if ax > ay * self.p.flick_axis_dominance: - return "right" if dx > 0 else "left" - if ay > ax * self.p.flick_axis_dominance: - return "up" if dy > 0 else "down" - return None - - -class Service: - """OuraRingInputService glue, virtual-time event-driven.""" - - def __init__(self, params, mappings): - self.p = params - self.m = mappings - self.detector = TapDetector(params) - self.sequence = TapSequence(params) - self.hold = TapHold(params) - self.flick = Flick(params) - self.suppress_until = 0.0 # OuraTapMotionSuppressor - self.suppress_tap_until = 0.0 # center-based detector gate - self.last_accel_tap_time = 0.0 - self.resolution_at = None # scheduled sequence-resolution time - self.events = [] # (ct, name, detail) - - def _suppress_motion(self, ts, duration): - self.suppress_until = max(self.suppress_until, ts + duration) - - def fire_due_timers(self, now): - if self.resolution_at is not None and now >= self.resolution_at: - at = self.resolution_at - self.resolution_at = None - resolved = self.sequence.resolve_pending(at) - if resolved is not None: - self._perform_tap_action(resolved, at) - - def feed_center(self, ct): - self.suppress_tap_until = max(self.suppress_tap_until, ct + self.p.center_tap_suppression) - - def feed_sample(self, ct, x, y, z, px, py): - s = (ct, x, y, z) - detected = ct >= self.suppress_tap_until and self.detector.register(s) - if detected: - self.events.append((ct, "tap-detected", None)) - self._handle_accel_tap(ct, s) - else: - self._handle_hold(s) - self._handle_flick((px, py), ct) - - def _handle_accel_tap(self, ts, sample): - if ts - self.last_accel_tap_time <= self.p.accel_tap_refractory: - return - self.last_accel_tap_time = ts - self._handle_tap_candidate(ts, sample) - - def _handle_tap_candidate(self, ts, sample): - self.events.append((ts, "tap-candidate", "accelerometer spike")) - kind, count = self.sequence.register_tap(ts) - if kind == "duplicate": - return - if kind == "pending": - if count == 1: - self.hold.register_tap(ts, sample) - else: - self.hold.cancel() - self._suppress_motion(ts, self.p.sequence_window + self.p.tap_motion_suppression_extra) - self.resolution_at = ts + self.p.sequence_window + self.p.timer_slack - else: # completed - self.hold.cancel() - self.resolution_at = None - self._suppress_motion(ts, self.p.post_action_suppression) - self._perform_tap_action(count, ts) - - def _perform_tap_action(self, count, now): - self.resolution_at = None - self.hold.cancel() - self._suppress_motion(now, self.p.post_action_suppression) - self.events.append((now, "tap-resolved", str(count))) - - def _handle_hold(self, s): - if not self.hold.register_motion(s): - return - self.events.append((s[0], "tap-hold", None)) - if self.m.tap_hold: - self.resolution_at = None - self.sequence.reset() - self._suppress_motion(s[0], self.p.post_action_suppression) - - def _handle_flick(self, point, ts): - if self.p.flick_gate_by_suppression and ts < self.suppress_until: - return - direction = self.flick.register(point, ts) - if direction is None: - return - self.events.append((ts, "flick", direction)) - if self.m.flicks: - self._suppress_motion(ts, self.p.post_action_suppression) - - -def split_by_coverage(trials, samples): - """A prompted window with no trace samples is lost data, not a recognition - miss — split it out so scoring only sees trials the recognizers could see.""" - times = sorted(s[6] for s in samples) - covered, dropped = [], [] - for trial in trials: - lo, hi = trial["goT"] - PRE_ROLL_SECONDS, trial["endT"] + POST_ROLL_SECONDS - i = bisect.bisect_left(times, lo) - (covered if i < len(times) and times[i] <= hi else dropped).append(trial) - return covered, dropped - - -def load_trace(trace_path): - samples = [] # (ct, x, y, z, px, py, t) - live_events = [] # (ct, name, detail, t) - center_times = [] - for line in trace_path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - record = json.loads(line) - if record.get("type") == "sample": - samples.append((record["ct"], record["x"], record["y"], record["z"], - record.get("px", 0.0), record.get("py", 0.0), record["t"])) - elif record.get("type") == "event": - live_events.append((record["ct"], record["name"], record.get("detail"), record["t"])) - if record["name"] == "center": - center_times.append(record["ct"]) - return samples, live_events, sorted(center_times) - - -def replay(samples, center_times, params, mappings): - service = Service(params, mappings) - center_index = 0 - for s in samples: - ct = s[0] - while center_index < len(center_times) and center_times[center_index] <= ct: - service.feed_center(center_times[center_index]) - center_index += 1 - service.fire_due_timers(ct) - service.feed_sample(ct, s[1], s[2], s[3], s[4], s[5]) - if samples: - service.fire_due_timers(samples[-1][0] + 5.0) - return service.events - - -def score(trials, events, ct_offset): - """events: (ct, name, detail) → score against trial windows (unix time).""" - dicts = [{"t": ct + ct_offset, "name": name, "detail": detail} - for ct, name, detail in events if name in ("tap-resolved", "flick", "tap-hold")] - correct = 0 - noise_clean = True - rows = {} - for trial in trials: - window = slice_window(dicts, trial["goT"] - PRE_ROLL_SECONDS, trial["endT"] + POST_ROLL_SECONDS) - prediction = predicted_outcome(window) - expected = "none" if trial["expect"].get("none") else trial["label"] - rows.setdefault(expected, []).append(prediction) - if prediction == expected: - correct += 1 - elif expected == "none": - noise_clean = False - return correct, noise_clean, rows - - -def print_confusion(rows, total_correct, total): - width = max(len(k) for k in rows) + 2 - for expected in sorted(rows): - preds = rows[expected] - hits = sum(1 for p in preds if p == expected) - counts = {} - for p in preds: - counts[p] = counts.get(p, 0) + 1 - parts = ", ".join(f"{k}×{v}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1])) - print(f" {expected:<{width}} {hits}/{len(preds)} [{parts}]") - print(f" Overall: {total_correct}/{total} ({100 * total_correct / total:.0f}%)") - - -def verify(samples, live_events, center_times, trials, ct_offset): - print("== Port fidelity: replay (stock params, session mappings) vs live events ==") - emitted = replay(samples, center_times, Params(), SESSION_MAPPINGS) - tolerance = 0.06 - for name in ("tap-detected", "tap-candidate", "tap-resolved", "flick", "tap-hold"): - live = [(ct, detail) for ct, n, detail, _ in live_events if n == name] - mine = [(ct, detail) for ct, n, detail in emitted if n == name] - unmatched_live = list(live) - matched = 0 - for ct, detail in mine: - for i, (lct, ldetail) in enumerate(unmatched_live): - if abs(lct - ct) <= tolerance and ldetail == detail: - unmatched_live.pop(i) - matched += 1 - break - print(f" {name:<14} live {len(live):>3} replay {len(mine):>3} matched {matched:>3}" - + ("" if len(live) == len(mine) == matched else " ← MISMATCH")) - correct, noise_clean, rows = score(trials, emitted, ct_offset) - print("\n== Replayed confusion (should ≈ live analyzer output) ==") - print_confusion(rows, correct, len(trials)) - - -def mutate(base, rng, n_params): - updates = {} - for key in rng.sample(list(SWEEP_RANGES), n_params): - lo, hi = SWEEP_RANGES[key] - if isinstance(lo, bool): - updates[key] = rng.random() < 0.5 - else: - updates[key] = round(rng.uniform(lo, hi), 4) - return replace(base, **updates) - - -def sweep(samples, center_times, trials, ct_offset, iterations, seed): - rng = random.Random(seed) - stock = Params() - best = stock - best_correct, noise_clean, _ = score(trials, replay(samples, center_times, stock, PRODUCTION_MAPPINGS), ct_offset) - if not noise_clean: - best_correct = -1 - print(f"Baseline (stock params, production mappings): {best_correct}/{len(trials)}") - for i in range(iterations): - candidate = mutate(best if rng.random() < 0.8 else stock, rng, rng.randint(2, 8)) - correct, clean, _ = score(trials, replay(samples, center_times, candidate, PRODUCTION_MAPPINGS), ct_offset) - if not clean: - continue - if correct > best_correct: - best_correct = correct - best = candidate - print(f" iter {i:>4}: {correct}/{len(trials)} " - + ", ".join(f"{f.name}={getattr(candidate, f.name)}" - for f in fields(Params) if getattr(candidate, f.name) != getattr(stock, f.name))) - print(f"\n== Best config: {best_correct}/{len(trials)} " - f"({100 * best_correct / len(trials):.0f}%) — diffs from stock ==") - for f in fields(Params): - if getattr(best, f.name) != getattr(stock, f.name): - print(f" {f.name}: {getattr(stock, f.name)} → {getattr(best, f.name)}") - correct, _, rows = score(trials, replay(samples, center_times, best, PRODUCTION_MAPPINGS), ct_offset) - print_confusion(rows, correct, len(trials)) - return best - - -def main(): - parser = argparse.ArgumentParser(description="Replay-tune Oura gesture heuristics against a labeled session.") - parser.add_argument("capture_dir", nargs="?", type=Path, default=DEFAULT_CAPTURE) - parser.add_argument("--trace", type=Path, help="Archived trace (default: /motion-trace.ndjson)") - parser.add_argument("--verify", action="store_true", help="Port-fidelity check vs live events") - parser.add_argument("--sweep", type=int, metavar="N", help="Random-search iterations") - parser.add_argument("--seed", type=int, default=7) - args = parser.parse_args() - - trace_path = args.trace or (args.capture_dir / "motion-trace.ndjson") - samples, live_events, center_times = load_trace(trace_path) - trials = build_trials(latest_gesture_session(load_events(args.capture_dir))) - ct_offset = sum(s[6] - s[0] for s in samples[:200]) / min(len(samples), 200) - trials, dropped = split_by_coverage(trials, samples) - print(f"Trace: {trace_path} ({len(samples)} samples, {len(live_events)} live events, " - f"{len(center_times)} centers) — {len(trials)} trials with coverage" - + (f", {len(dropped)} excluded (no trace samples in window)" if dropped else "") + "\n") - - if args.verify: - verify(samples, live_events, center_times, trials, ct_offset) - elif args.sweep: - sweep(samples, center_times, trials, ct_offset, args.sweep, args.seed) - else: - correct, _, rows = score(trials, replay(samples, center_times, Params(), SESSION_MAPPINGS), ct_offset) - print_confusion(rows, correct, len(trials)) - - -if __name__ == "__main__": - main() diff --git a/XboxControllerMapper/XboxControllerMapper/Config.swift b/XboxControllerMapper/XboxControllerMapper/Config.swift index f00e8978..c0b6d7a3 100644 --- a/XboxControllerMapper/XboxControllerMapper/Config.swift +++ b/XboxControllerMapper/XboxControllerMapper/Config.swift @@ -6,12 +6,6 @@ struct Config { // MARK: - Event Tagging static let controllerKeysSyntheticMediaEventUserData: Int64 = 0x434B4D45444941 - // MARK: - Cursor State - /// UserDefaults flag set while on-screen-keyboard navigation mode has hidden the - /// cursor. Read by InputSimulator so pointer-lock auto-detection doesn't mistake - /// the app's own cursor hide for a game's pointer lock. - static let onScreenKeyboardCursorHiddenDefaultsKey = "onScreenKeyboardCursorHidden" - // MARK: - Chord Detection /// Time window for detecting simultaneous button presses as a chord (seconds) static let chordDetectionWindow: TimeInterval = 0.15 diff --git a/XboxControllerMapper/XboxControllerMapper/Info.plist b/XboxControllerMapper/XboxControllerMapper/Info.plist index 8422f86e..2f7187e4 100644 --- a/XboxControllerMapper/XboxControllerMapper/Info.plist +++ b/XboxControllerMapper/XboxControllerMapper/Info.plist @@ -40,9 +40,9 @@ LSUIElement NSBluetoothAlwaysUsageDescription - ControllerKeys needs Bluetooth access to connect to your controller or Oura Ring. + ControllerKeys needs Bluetooth access to connect to your controller. NSBluetoothPeripheralUsageDescription - ControllerKeys needs Bluetooth access to connect to your controller or Oura Ring. + ControllerKeys needs Bluetooth access to connect to your controller. NSMicrophoneUsageDescription ControllerKeys needs microphone access to test the DualSense controller's built-in microphone and display audio input levels. NSLocalNetworkUsageDescription diff --git a/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift b/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift index 2dc2b24e..13101b16 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/CommandWheelAction.swift @@ -124,8 +124,6 @@ struct CommandWheelAction: Codable, Identifiable, Equatable, ExecutableAction { case .openLink: return "globe" case .httpRequest: return "network" case .obsWebSocket: return "video" - case .centerOuraRing: return "scope" - case .toggleOuraMotion: return "cursorarrow.motionlines" } } return "gearshape" diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift index 9b5977ec..c0d42bca 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButton.swift @@ -120,17 +120,968 @@ enum ControllerButton: String, Codable, CaseIterable, Identifiable, Sendable { case gestureSteerLeft // Gyroscope steer left gesture (DualSense only) case gestureSteerRight // Gyroscope steer right gesture (DualSense only) - // Oura Ring virtual inputs - case ouraTap // Finger tap detected by an Oura Ring - case ouraDoubleTap // Two taps detected by an Oura Ring - case ouraTripleTap // Three taps detected by an Oura Ring - case ouraFiveTap // Five taps detected by an Oura Ring - case ouraTapHold // Tap followed by a still hold - case ouraFlickUp // Quick directional flick up - case ouraFlickDown // Quick directional flick down - case ouraFlickLeft // Quick directional flick left - case ouraFlickRight // Quick directional flick right - var id: String { rawValue } + /// Human-readable display name + var displayName: String { + switch self { + case .a: return "A" + case .b: return "B" + case .x: return "X" + case .y: return "Y" + case .leftBumper: return "LB" + case .rightBumper: return "RB" + case .leftTrigger: return "LT" + case .rightTrigger: return "RT" + case .dpadUp: return "D-pad Up" + case .dpadDown: return "D-pad Down" + case .dpadLeft: return "D-pad Left" + case .dpadRight: return "D-pad Right" + case .menu: return "Menu" + case .view: return "View" + case .share: return "Share" + case .xbox: return "Xbox" + case .siri: return "Siri" + case .appleTVRemotePower: return "Power" + case .appleTVRemoteVolumeUp: return "Volume Up" + case .appleTVRemoteVolumeDown: return "Volume Down" + case .appleTVRemoteMute: return "Mute" + case .leftThumbstick: return "Left Stick" + case .rightThumbstick: return "Right Stick" + case .leftStickUp: return "Left Stick Up" + case .leftStickDown: return "Left Stick Down" + case .leftStickLeft: return "Left Stick Left" + case .leftStickRight: return "Left Stick Right" + case .leftStickUpLeft: return "Left Stick Up-Left" + case .leftStickUpRight: return "Left Stick Up-Right" + case .leftStickDownLeft: return "Left Stick Down-Left" + case .leftStickDownRight: return "Left Stick Down-Right" + case .rightStickUp: return "Right Stick Up" + case .rightStickDown: return "Right Stick Down" + case .rightStickLeft: return "Right Stick Left" + case .rightStickRight: return "Right Stick Right" + case .rightStickUpLeft: return "Right Stick Up-Left" + case .rightStickUpRight: return "Right Stick Up-Right" + case .rightStickDownLeft: return "Right Stick Down-Left" + case .rightStickDownRight: return "Right Stick Down-Right" + case .touchpadButton: return "Touchpad Press" + case .touchpadTwoFingerButton: return "Touchpad 2-Finger Press" + case .touchpadTap: return "Touchpad Tap" + case .touchpadTwoFingerTap: return "Touchpad 2-Finger Tap" + case .micMute: return "Mic Mute" + case .leftTouchpadButton: return "Left Pad Press" + case .rightTouchpadButton: return "Right Pad Press" + case .leftTouchpadTap: return "Left Pad Tap" + case .rightTouchpadTap: return "Right Pad Tap" + case .leftTouchpadRegionTopLeftClick: return "Left Top-Left Click" + case .leftTouchpadRegionTopRightClick: return "Left Top-Right Click" + case .leftTouchpadRegionBottomLeftClick: return "Left Bottom-Left Click" + case .leftTouchpadRegionBottomRightClick: return "Left Bottom-Right Click" + case .leftTouchpadRegionTopLeftTouch: return "Left Top-Left Tap" + case .leftTouchpadRegionTopRightTouch: return "Left Top-Right Tap" + case .leftTouchpadRegionBottomLeftTouch: return "Left Bottom-Left Tap" + case .leftTouchpadRegionBottomRightTouch: return "Left Bottom-Right Tap" + case .rightTouchpadRegionTopLeftClick: return "Right Top-Left Click" + case .rightTouchpadRegionTopRightClick: return "Right Top-Right Click" + case .rightTouchpadRegionBottomLeftClick: return "Right Bottom-Left Click" + case .rightTouchpadRegionBottomRightClick: return "Right Bottom-Right Click" + case .rightTouchpadRegionTopLeftTouch: return "Right Top-Left Tap" + case .rightTouchpadRegionTopRightTouch: return "Right Top-Right Tap" + case .rightTouchpadRegionBottomLeftTouch: return "Right Bottom-Left Tap" + case .rightTouchpadRegionBottomRightTouch: return "Right Bottom-Right Tap" + case .touchpadRegionTopLeftClick: return "Top-Left Click" + case .touchpadRegionTopRightClick: return "Top-Right Click" + case .touchpadRegionBottomLeftClick: return "Bottom-Left Click" + case .touchpadRegionBottomRightClick: return "Bottom-Right Click" + case .touchpadRegionTopLeftTouch: return "Top-Left Touch" + case .touchpadRegionTopRightTouch: return "Top-Right Touch" + case .touchpadRegionBottomLeftTouch: return "Bottom-Left Touch" + case .touchpadRegionBottomRightTouch: return "Bottom-Right Touch" + case .leftPaddle: return "Left Paddle" + case .rightPaddle: return "Right Paddle" + case .leftFunction: return "Left Fn" + case .rightFunction: return "Right Fn" + case .xboxPaddle1: return "Upper Left Paddle" + case .xboxPaddle2: return "Upper Right Paddle" + case .xboxPaddle3: return "Lower Left Paddle" + case .xboxPaddle4: return "Lower Right Paddle" + case .gestureTiltBack: return "Tilt Back" + case .gestureTiltForward: return "Tilt Forward" + case .gestureSteerLeft: return "Steer Left" + case .gestureSteerRight: return "Steer Right" + } + } + + /// Display name appropriate for the controller type + func displayName(forDualSense isDualSense: Bool) -> String { + guard isDualSense else { return displayName } + switch self { + case .a: return "Cross" + case .b: return "Circle" + case .x: return "Square" + case .y: return "Triangle" + case .leftBumper: return "L1" + case .rightBumper: return "R1" + case .leftTrigger: return "L2" + case .rightTrigger: return "R2" + case .menu: return "Options" + case .view: return "Create" + case .share: return "Share" // DualSense Edge has this, standard doesn't + case .xbox: return "PS" + case .leftThumbstick: return "L3" + case .rightThumbstick: return "R3" + default: return displayName + } + } + + /// Display name for Nintendo controllers (Joy-Con, Pro Controller) + func displayName(forNintendo isNintendo: Bool) -> String { + guard isNintendo else { return displayName } + switch self { + // macOS maps Nintendo/8BitDo face buttons by LABEL, not position: + // the button printed "A" reports as buttonA (-> .a), etc. So the + // A/B/X/Y labels are already correct as-is. What differs from Xbox is + // the physical ARRANGEMENT of those labels (Nintendo: X north, A east, + // B south, Y west), which the minimap handles by positioning each + // button view — not by relabeling. + case .leftBumper: return "L" + case .rightBumper: return "R" + case .leftTrigger: return "ZL" + case .rightTrigger: return "ZR" + case .menu: return "+" + case .view: return "\u{2212}" // minus sign + case .share: return "Capture" + case .xbox: return "Home" + case .leftThumbstick: return "L Stick" + case .rightThumbstick: return "R Stick" + default: return displayName + } + } + + /// Display name for 8BitDo pads (Zero 2 / Micro / Lite). Face buttons keep + /// their printed A/B/X/Y labels (like Nintendo), the bumpers are printed + /// L/R, but unlike Nintendo the analog/digital triggers are printed L2/R2 + /// (not ZL/ZR), and the guide button is the 8BitDo home button. + func displayName(forEightBitDo isEightBitDo: Bool) -> String { + guard isEightBitDo else { return displayName } + switch self { + case .leftBumper: return "L" + case .rightBumper: return "R" + case .leftTrigger: return "L2" + case .rightTrigger: return "R2" + case .xbox: return "Home" + case .share: return "Star" + default: return displayName + } + } + + /// Display name for Apple TV/Siri Remote controls. + func displayName(forAppleTVRemote isAppleTVRemote: Bool) -> String { + guard isAppleTVRemote else { return displayName } + switch self { + case .touchpadButton: return "Clickpad Click" + case .touchpadTap: return "Clickpad Tap" + case .menu: return "Play/Pause" + case .view: return "Back" + case .xbox: return "TV/Home" + case .siri: return "Siri" + case .appleTVRemotePower: return "Power" + case .appleTVRemoteVolumeUp: return "Volume Up" + case .appleTVRemoteVolumeDown: return "Volume Down" + case .appleTVRemoteMute: return "Mute" + default: return displayName + } + } + + /// Display name that adapts to any controller type + func displayName( + forDualSense isDualSense: Bool, + forNintendo isNintendo: Bool, + forAppleTVRemote isAppleTVRemote: Bool = false, + forEightBitDo isEightBitDo: Bool = false + ) -> String { + if isAppleTVRemote { return displayName(forAppleTVRemote: true) } + if isEightBitDo { return displayName(forEightBitDo: true) } + if isNintendo { return displayName(forNintendo: true) } + return displayName(forDualSense: isDualSense) + } + + /// Short label for compact UI + var shortLabel: String { + switch self { + case .a: return "A" + case .b: return "B" + case .x: return "X" + case .y: return "Y" + case .leftBumper: return "LB" + case .rightBumper: return "RB" + case .leftTrigger: return "LT" + case .rightTrigger: return "RT" + case .dpadUp: return "↑" + case .dpadDown: return "↓" + case .dpadLeft: return "←" + case .dpadRight: return "→" + case .menu: return "≡" + case .view: return "⧉" + case .share: return "⬆" + case .xbox: return "⊗" + case .siri: return "Siri" + case .appleTVRemotePower: return "PWR" + case .appleTVRemoteVolumeUp: return "V+" + case .appleTVRemoteVolumeDown: return "V-" + case .appleTVRemoteMute: return "Mute" + case .leftThumbstick: return "L3" + case .rightThumbstick: return "R3" + case .leftStickUp: return "L↑" + case .leftStickDown: return "L↓" + case .leftStickLeft: return "L←" + case .leftStickRight: return "L→" + case .leftStickUpLeft: return "L↖" + case .leftStickUpRight: return "L↗" + case .leftStickDownLeft: return "L↙" + case .leftStickDownRight: return "L↘" + case .rightStickUp: return "R↑" + case .rightStickDown: return "R↓" + case .rightStickLeft: return "R←" + case .rightStickRight: return "R→" + case .rightStickUpLeft: return "R↖" + case .rightStickUpRight: return "R↗" + case .rightStickDownLeft: return "R↙" + case .rightStickDownRight: return "R↘" + case .touchpadButton: return "TP" + case .touchpadTwoFingerButton: return "2P" + case .touchpadTap: return "1T" + case .touchpadTwoFingerTap: return "2" + case .micMute: return "🎤" + case .leftTouchpadButton: return "LP" + case .rightTouchpadButton: return "RP" + case .leftTouchpadTap: return "LTp" + case .rightTouchpadTap: return "RTp" + case .leftTouchpadRegionTopLeftClick: return "LTL" + case .leftTouchpadRegionTopRightClick: return "LTR" + case .leftTouchpadRegionBottomLeftClick: return "LBL" + case .leftTouchpadRegionBottomRightClick: return "LBR" + case .leftTouchpadRegionTopLeftTouch: return "LTL" + case .leftTouchpadRegionTopRightTouch: return "LTR" + case .leftTouchpadRegionBottomLeftTouch: return "LBL" + case .leftTouchpadRegionBottomRightTouch: return "LBR" + case .rightTouchpadRegionTopLeftClick: return "RTL" + case .rightTouchpadRegionTopRightClick: return "RTR" + case .rightTouchpadRegionBottomLeftClick: return "RBL" + case .rightTouchpadRegionBottomRightClick: return "RBR" + case .rightTouchpadRegionTopLeftTouch: return "RTL" + case .rightTouchpadRegionTopRightTouch: return "RTR" + case .rightTouchpadRegionBottomLeftTouch: return "RBL" + case .rightTouchpadRegionBottomRightTouch: return "RBR" + // Region buttons fall back to text labels here, but they're rendered + // by `ButtonIconView` as a custom 2×2 quadrant indicator that's + // recognizable at a glance (the diagonal-arrow glyphs above looked + // too similar to each other at button-tile size). + case .touchpadRegionTopLeftClick: return "TL" + case .touchpadRegionTopRightClick: return "TR" + case .touchpadRegionBottomLeftClick: return "BL" + case .touchpadRegionBottomRightClick: return "BR" + case .touchpadRegionTopLeftTouch: return "TL" + case .touchpadRegionTopRightTouch: return "TR" + case .touchpadRegionBottomLeftTouch: return "BL" + case .touchpadRegionBottomRightTouch: return "BR" + case .leftPaddle: return "LP" + case .rightPaddle: return "RP" + case .leftFunction: return "LFn" + case .rightFunction: return "RFn" + case .xboxPaddle1: return "P1" + case .xboxPaddle2: return "P2" + case .xboxPaddle3: return "P3" + case .xboxPaddle4: return "P4" + case .gestureTiltBack: return "⤴" + case .gestureTiltForward: return "⤵" + case .gestureSteerLeft: return "↰" + case .gestureSteerRight: return "↱" + } + } + + /// Short label appropriate for the controller type + func shortLabel(forDualSense isDualSense: Bool) -> String { + guard isDualSense else { return shortLabel } + switch self { + case .a: return "✕" + case .b: return "○" + case .x: return "□" + case .y: return "△" + case .leftBumper: return "L1" + case .rightBumper: return "R1" + case .leftTrigger: return "L2" + case .rightTrigger: return "R2" + case .menu: return "☰" + case .view: return "▢" // Create button symbol + case .xbox: return "ⓟ" // PS button + default: return shortLabel + } + } + + /// Short label for Nintendo controllers + func shortLabel(forNintendo isNintendo: Bool) -> String { + guard isNintendo else { return shortLabel } + switch self { + case .leftBumper: return "L" + case .rightBumper: return "R" + case .leftTrigger: return "ZL" + case .rightTrigger: return "ZR" + case .menu: return "+" + case .view: return "\u{2212}" // minus sign + case .share: return "\u{25A1}" // capture button (square) + case .xbox: return "\u{2302}" // home button + default: return shortLabel + } + } + + /// Short label for 8BitDo pads: L/R bumpers, L2/R2 triggers (matches the + /// minimap), everything else falls back to the default labels. + func shortLabel(forEightBitDo isEightBitDo: Bool) -> String { + guard isEightBitDo else { return shortLabel } + switch self { + case .leftBumper: return "L" + case .rightBumper: return "R" + case .leftTrigger: return "L2" + case .rightTrigger: return "R2" + default: return shortLabel + } + } + + /// Short label for Apple TV/Siri Remote controls. + func shortLabel(forAppleTVRemote isAppleTVRemote: Bool) -> String { + guard isAppleTVRemote else { return shortLabel } + switch self { + case .touchpadButton: return "Click" + case .touchpadTap: return "Tap" + case .menu: return "▶" + case .view: return "←" + case .xbox: return "TV" + case .siri: return "Siri" + case .appleTVRemotePower: return "PWR" + case .appleTVRemoteVolumeUp: return "V+" + case .appleTVRemoteVolumeDown: return "V-" + case .appleTVRemoteMute: return "Mute" + default: return shortLabel + } + } + + /// Short label that adapts to any controller type + func shortLabel( + forDualSense isDualSense: Bool, + forNintendo isNintendo: Bool, + forAppleTVRemote isAppleTVRemote: Bool = false + ) -> String { + if isAppleTVRemote { return shortLabel(forAppleTVRemote: true) } + if isNintendo { return shortLabel(forNintendo: true) } + return shortLabel(forDualSense: isDualSense) + } + + /// SF Symbol name if available + var systemImageName: String? { + systemImageName(forDualSense: false) + } + + /// SF Symbol name appropriate for the controller type + func systemImageName(forDualSense isDualSense: Bool) -> String? { + return isDualSense ? dualSenseSystemImageName : xboxSystemImageName + } + + private var dualSenseSystemImageName: String? { + switch self { + case .dpadUp: return "arrowtriangle.up.fill" + case .dpadDown: return "arrowtriangle.down.fill" + case .dpadLeft: return "arrowtriangle.left.fill" + case .dpadRight: return "arrowtriangle.right.fill" + case .leftStickUp, .rightStickUp: return "arrow.up.circle" + case .leftStickDown, .rightStickDown: return "arrow.down.circle" + case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" + case .leftStickRight, .rightStickRight: return "arrow.right.circle" + case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" + case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" + case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" + case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" + case .menu: return "line.3.horizontal" + case .view: return "square.and.arrow.up" // Create/Share button (upload icon) + case .share: return "square.and.arrow.up" + case .xbox: return "playstation.logo" // PS button + case .leftThumbstick: return "l3.button.angledbottom.horizontal.left" + case .rightThumbstick: return "r3.button.angledbottom.horizontal.right" + case .touchpadButton: return "hand.point.up.left" + case .touchpadTwoFingerButton: return nil // Use text label "2P" + case .touchpadTap: return "hand.tap" + case .touchpadTwoFingerTap: return "hand.tap" + case .micMute: return "mic.slash" + case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" + case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" + case .touchpadRegionTopLeftClick, .touchpadRegionTopLeftTouch, + .touchpadRegionTopRightClick, .touchpadRegionTopRightTouch, + .touchpadRegionBottomLeftClick, .touchpadRegionBottomLeftTouch, + .touchpadRegionBottomRightClick, .touchpadRegionBottomRightTouch: + // Custom 2×2 quadrant indicator drawn by ButtonIconView; no + // SF Symbol used for these. + return nil + case .leftPaddle: return "l.button.roundedbottom.horizontal" + case .rightPaddle: return "r.button.roundedbottom.horizontal" + case .leftFunction: return "button.horizontal.top.press" + case .rightFunction: return "button.horizontal.top.press" + // Face buttons use text symbols for DualSense, not SF Symbols + case .a, .b, .x, .y: return nil + default: return nil + } + } + + private var xboxSystemImageName: String? { + switch self { + case .dpadUp: return "arrowtriangle.up.fill" + case .dpadDown: return "arrowtriangle.down.fill" + case .dpadLeft: return "arrowtriangle.left.fill" + case .dpadRight: return "arrowtriangle.right.fill" + case .leftStickUp, .rightStickUp: return "arrow.up.circle" + case .leftStickDown, .rightStickDown: return "arrow.down.circle" + case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" + case .leftStickRight, .rightStickRight: return "arrow.right.circle" + case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" + case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" + case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" + case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" + case .menu: return "line.3.horizontal" + case .view: return "rectangle.on.rectangle" + case .share: return "square.and.arrow.up" + case .xbox: return "xbox.logo" + case .siri: return "mic.fill" + case .appleTVRemotePower: return "power" + case .appleTVRemoteVolumeUp: return "speaker.wave.3.fill" + case .appleTVRemoteVolumeDown: return "speaker.wave.1.fill" + case .appleTVRemoteMute: return "speaker.slash.fill" + case .leftThumbstick: return "l.circle" + case .rightThumbstick: return "r.circle" + case .a: return "a.circle" + case .b: return "b.circle" + case .x: return "x.circle" + case .y: return "y.circle" + case .touchpadButton: return "hand.point.up.left" + case .touchpadTap: return "hand.tap" + case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" + case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" + case .micMute: return "mic.slash" + case .xboxPaddle1: return "l.button.roundedbottom.horizontal" + case .xboxPaddle2: return "r.button.roundedbottom.horizontal" + case .xboxPaddle3: return "l.button.roundedbottom.horizontal.fill" + case .xboxPaddle4: return "r.button.roundedbottom.horizontal.fill" + default: return nil + } + } + + /// Category for grouping in UI + var category: ButtonCategory { + switch self { + case .a, .b, .x, .y: + return .face + case .leftBumper, .rightBumper: + return .bumper + case .leftTrigger, .rightTrigger: + return .trigger + case .dpadUp, .dpadDown, .dpadLeft, .dpadRight: + return .dpad + case .menu, .view, .share, .xbox, .siri, + .appleTVRemotePower, .appleTVRemoteVolumeUp, + .appleTVRemoteVolumeDown, .appleTVRemoteMute: + return .special + case .leftThumbstick, .rightThumbstick: + return .thumbstick + case .leftStickUp, .leftStickDown, .leftStickLeft, .leftStickRight, + .leftStickUpLeft, .leftStickUpRight, .leftStickDownLeft, .leftStickDownRight, + .rightStickUp, .rightStickDown, .rightStickLeft, .rightStickRight, + .rightStickUpLeft, .rightStickUpRight, .rightStickDownLeft, .rightStickDownRight: + return .thumbstick + case .touchpadButton, .touchpadTwoFingerButton, .touchpadTap, .touchpadTwoFingerTap, .micMute, + .leftTouchpadButton, .rightTouchpadButton, .leftTouchpadTap, .rightTouchpadTap, + .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, + .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, + .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, + .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch, + .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, + .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick, + .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, + .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch, + .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, + .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick, + .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, + .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch: + return .touchpad // DualSense-specific buttons + case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: + return .paddle // DualSense Edge-specific buttons + case .xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4: + return .paddle // Xbox Elite-specific buttons + case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: + return .touchpad // DualSense-specific virtual buttons + } + } + + /// Whether this button is only available on PlayStation controllers (DualSense or DualShock) + var isPlayStationOnly: Bool { + switch self { + case .touchpadButton, .touchpadTwoFingerButton, .touchpadTap, .touchpadTwoFingerTap, + .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, + .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick, + .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, + .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch: + return true // Available on both DualSense and DualShock + case .micMute: + return true // DualSense only + case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: + return true // Edge-only, but still PlayStation family + case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: + return true // DualSense gyroscope only + default: + return false + } + } + + /// Whether this button is only available on DualSense controllers (not DualShock) + var isDualSenseOnly: Bool { + switch self { + case .micMute: + return true // DualShock 4 doesn't have mic mute + case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: + return true // Edge-only + case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: + return true // DualSense gyroscope only + default: + return false + } + } + + /// Whether this button is only available on Steam Controllers. + var isSteamControllerOnly: Bool { + switch self { + case .leftTouchpadButton, .rightTouchpadButton, .leftTouchpadTap, .rightTouchpadTap, + .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, + .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, + .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, + .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch, + .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, + .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick, + .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, + .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: + return true + default: + return false + } + } + + /// Whether this button is only available on Apple TV/Siri Remote devices. + var isAppleTVRemoteOnly: Bool { + switch self { + case .siri, .appleTVRemotePower, .appleTVRemoteVolumeUp, .appleTVRemoteVolumeDown, .appleTVRemoteMute: + return true + default: + return false + } + } + + /// Whether this button represents one of the eight touchpad quadrant + /// variants (4 regions × {touch, click}). Used by the mapping engine for + /// dispatch and by UI to know how to render the region group. + var isTouchpadQuadrant: Bool { + return touchpadRegion != nil + } + + /// Returns the (region, trigger) pair this button represents, or nil if + /// it's not one of the quadrant variants. + var touchpadRegion: TouchpadRegion? { + switch self { + case .touchpadRegionTopLeftClick, .touchpadRegionTopLeftTouch, + .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopLeftTouch, + .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopLeftTouch: + return .topLeft + case .touchpadRegionTopRightClick, .touchpadRegionTopRightTouch, + .leftTouchpadRegionTopRightClick, .leftTouchpadRegionTopRightTouch, + .rightTouchpadRegionTopRightClick, .rightTouchpadRegionTopRightTouch: + return .topRight + case .touchpadRegionBottomLeftClick, .touchpadRegionBottomLeftTouch, + .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomLeftTouch, + .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomLeftTouch: + return .bottomLeft + case .touchpadRegionBottomRightClick, .touchpadRegionBottomRightTouch, + .leftTouchpadRegionBottomRightClick, .leftTouchpadRegionBottomRightTouch, + .rightTouchpadRegionBottomRightClick, .rightTouchpadRegionBottomRightTouch: + return .bottomRight + default: return nil + } + } + + var steamTouchpadSide: SteamTouchpadSide? { + switch self { + case .leftTouchpadButton, .leftTouchpadTap, + .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, + .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, + .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, + .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch: + return .left + case .rightTouchpadButton, .rightTouchpadTap, + .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, + .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick, + .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, + .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: + return .right + default: + return nil + } + } + + /// For chord/sequence matching only: which canonical "whole-pad" button + /// does this button alias for? `.touchpadRegion*Click` aliases for + /// `.touchpadButton`; `.touchpadRegion*Touch` aliases for `.touchpadTap`. + /// All other buttons return nil (no alias). + /// + /// This is the bridge that lets a chord like `[.touchpadButton, .a]` match + /// when the user clicks any quadrant in `.quadrants` mode without firing + /// a duplicate `.touchpadButton` press. The aliasing is *only* consulted + /// during chord/sequence detection; individual button mappings remain + /// distinct. + var chordSequenceAlias: ControllerButton? { + switch self { + case .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, + .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick: + return .touchpadButton + case .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, + .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch: + return .touchpadTap + case .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, + .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick: + return .leftTouchpadButton + case .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, + .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch: + return .leftTouchpadTap + case .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, + .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick: + return .rightTouchpadButton + case .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, + .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: + return .rightTouchpadTap + case .xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4: + return logicalEquivalent + default: + return nil + } + } + + var logicalEquivalent: ControllerButton? { + switch self { + case .xboxPaddle1: + return .leftPaddle + case .xboxPaddle2: + return .rightPaddle + case .xboxPaddle3: + return .leftFunction + case .xboxPaddle4: + return .rightFunction + default: + return nil + } + } + + var physicalEquivalentButtons: [ControllerButton] { + switch self { + case .leftPaddle: + return [.xboxPaddle1] + case .rightPaddle: + return [.xboxPaddle2] + case .leftFunction: + return [.xboxPaddle3] + case .rightFunction: + return [.xboxPaddle4] + default: + return [] + } + } + + /// Returns the trigger mode this quadrant button represents, or nil if + /// it's not a quadrant variant. `.both` is never returned — it's an + /// authoring concept, not a button identity. + var touchpadQuadrantTrigger: TouchpadTriggerMode? { + switch self { + case .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, + .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick, + .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, + .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, + .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, + .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick: + return .click + case .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, + .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch, + .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, + .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch, + .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, + .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: + return .touch + default: + return nil + } + } + + /// Maps a `(region, trigger)` pair to the corresponding ControllerButton + /// case. `trigger` must be `.click` or `.touch` — `.both` is not a single + /// button (it's an authoring concept that fans out to both variants). + static func from(region: TouchpadRegion, trigger: TouchpadTriggerMode) -> ControllerButton? { + switch (region, trigger) { + case (.topLeft, .click): return .touchpadRegionTopLeftClick + case (.topRight, .click): return .touchpadRegionTopRightClick + case (.bottomLeft, .click): return .touchpadRegionBottomLeftClick + case (.bottomRight, .click): return .touchpadRegionBottomRightClick + case (.topLeft, .touch): return .touchpadRegionTopLeftTouch + case (.topRight, .touch): return .touchpadRegionTopRightTouch + case (.bottomLeft, .touch): return .touchpadRegionBottomLeftTouch + case (.bottomRight, .touch): return .touchpadRegionBottomRightTouch + case (_, .both): return nil + } + } + + static func from( + steamTouchpadSide side: SteamTouchpadSide, + region: TouchpadRegion, + trigger: TouchpadTriggerMode + ) -> ControllerButton? { + switch (side, region, trigger) { + case (.left, .topLeft, .click): return .leftTouchpadRegionTopLeftClick + case (.left, .topRight, .click): return .leftTouchpadRegionTopRightClick + case (.left, .bottomLeft, .click): return .leftTouchpadRegionBottomLeftClick + case (.left, .bottomRight, .click): return .leftTouchpadRegionBottomRightClick + case (.left, .topLeft, .touch): return .leftTouchpadRegionTopLeftTouch + case (.left, .topRight, .touch): return .leftTouchpadRegionTopRightTouch + case (.left, .bottomLeft, .touch): return .leftTouchpadRegionBottomLeftTouch + case (.left, .bottomRight, .touch): return .leftTouchpadRegionBottomRightTouch + case (.right, .topLeft, .click): return .rightTouchpadRegionTopLeftClick + case (.right, .topRight, .click): return .rightTouchpadRegionTopRightClick + case (.right, .bottomLeft, .click): return .rightTouchpadRegionBottomLeftClick + case (.right, .bottomRight, .click): return .rightTouchpadRegionBottomRightClick + case (.right, .topLeft, .touch): return .rightTouchpadRegionTopLeftTouch + case (.right, .topRight, .touch): return .rightTouchpadRegionTopRightTouch + case (.right, .bottomLeft, .touch): return .rightTouchpadRegionBottomLeftTouch + case (.right, .bottomRight, .touch): return .rightTouchpadRegionBottomRightTouch + case (_, _, .both): return nil + } + } + + static func steamTouchpadRegionButtons(side: SteamTouchpadSide) -> [ControllerButton] { + TouchpadRegion.allCases.flatMap { region in + [ + from(steamTouchpadSide: side, region: region, trigger: .click), + from(steamTouchpadSide: side, region: region, trigger: .touch), + ].compactMap { $0 } + } + } + + /// Whether this button is one of the virtual joystick direction bindings. + var isJoystickDirection: Bool { + joystickDirection != nil + } + + /// The physical stick side represented by this virtual joystick direction. + var joystickSide: JoystickSide? { + switch self { + case .leftStickUp, .leftStickDown, .leftStickLeft, .leftStickRight, + .leftStickUpLeft, .leftStickUpRight, .leftStickDownLeft, .leftStickDownRight: + return .left + case .rightStickUp, .rightStickDown, .rightStickLeft, .rightStickRight, + .rightStickUpLeft, .rightStickUpRight, .rightStickDownLeft, .rightStickDownRight: + return .right + default: + return nil + } + } + + /// The joystick direction represented by this virtual button. + var joystickDirection: JoystickDirection? { + switch self { + case .leftStickUp, .rightStickUp: return .up + case .leftStickDown, .rightStickDown: return .down + case .leftStickLeft, .rightStickLeft: return .left + case .leftStickRight, .rightStickRight: return .right + case .leftStickUpLeft, .rightStickUpLeft: return .upLeft + case .leftStickUpRight, .rightStickUpRight: return .upRight + case .leftStickDownLeft, .rightStickDownLeft: return .downLeft + case .leftStickDownRight, .rightStickDownRight: return .downRight + default: return nil + } + } + + static func joystickDirectionButton(side: JoystickSide, direction: JoystickDirection) -> ControllerButton { + switch (side, direction) { + case (.left, .up): return .leftStickUp + case (.left, .down): return .leftStickDown + case (.left, .left): return .leftStickLeft + case (.left, .right): return .leftStickRight + case (.left, .upLeft): return .leftStickUpLeft + case (.left, .upRight): return .leftStickUpRight + case (.left, .downLeft): return .leftStickDownLeft + case (.left, .downRight): return .leftStickDownRight + case (.right, .up): return .rightStickUp + case (.right, .down): return .rightStickDown + case (.right, .left): return .rightStickLeft + case (.right, .right): return .rightStickRight + case (.right, .upLeft): return .rightStickUpLeft + case (.right, .upRight): return .rightStickUpRight + case (.right, .downLeft): return .rightStickDownLeft + case (.right, .downRight): return .rightStickDownRight + } + } + + static func joystickDirectionButtons(side: JoystickSide) -> [ControllerButton] { + [.up, .left, .right, .down] + .map { joystickDirectionButton(side: side, direction: $0) } + } + + /// Whether this button is a virtual gesture button (not a physical button) + var isGestureButton: Bool { + switch self { + case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: + return true + default: + return false + } + } + + /// Whether this button is only available on DualSense Edge controllers + var isDualSenseEdgeOnly: Bool { + switch self { + case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: + return true + default: + return false + } + } + + /// Whether this button is only available on Xbox Elite controllers + var isXboxEliteOnly: Bool { + switch self { + case .xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4: + return true + default: + return false + } + } + + /// Buttons available for Xbox controllers (excludes Elite-only paddles) + static var xboxButtons: [ControllerButton] { + allCases.filter { !$0.isPlayStationOnly && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly } + } + + /// Buttons available only on Xbox Elite controllers + static var xboxEliteButtons: [ControllerButton] { + [.xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4] + } + + /// Buttons available for DualShock 4 controllers (has touchpad, no mic mute or paddles) + /// Note: DualShock 4's physical Share button maps to .view (buttonOptions), not .share + static var dualShockButtons: [ControllerButton] { + allCases.filter { !$0.isDualSenseOnly && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly && $0 != .share } + } + + /// Buttons available for DualSense controllers (excludes Share which doesn't exist on standard DualSense) + static var dualSenseButtons: [ControllerButton] { + allCases.filter { $0 != .share && !$0.isDualSenseEdgeOnly && !$0.isGestureButton && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly } + } + + /// Buttons available for Nintendo controllers (Joy-Con, Pro Controller) + /// Same as Xbox — no touchpad, mic, paddles, or gyro gestures + static var nintendoButtons: [ControllerButton] { + allCases.filter { !$0.isPlayStationOnly && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly } + } + + /// Buttons available for Apple TV/Siri Remote devices. + static var appleTVRemoteButtons: [ControllerButton] { + [ + .appleTVRemotePower, + .dpadUp, .dpadDown, .dpadLeft, .dpadRight, + .touchpadButton, .touchpadTap, .view, .menu, .xbox, .siri, + .appleTVRemoteVolumeUp, .appleTVRemoteVolumeDown, .appleTVRemoteMute + ] + } + + /// SF Symbol name for Nintendo controllers + func systemImageName(forNintendo isNintendo: Bool) -> String? { + guard isNintendo else { return systemImageName } + switch self { + case .xbox: return "house" // Home button + default: return systemImageName // Reuse Xbox SF Symbols for everything else + } + } + + /// SF Symbol name for Apple TV/Siri Remote controls. + func systemImageName(forAppleTVRemote isAppleTVRemote: Bool) -> String? { + guard isAppleTVRemote else { return systemImageName } + switch self { + case .touchpadButton: + return "hand.point.up.left" + case .touchpadTap: + return "hand.tap" + case .menu: + return "playpause.fill" + case .view: + return "chevron.left" + case .xbox: + return "tv.fill" + case .siri: + return "mic.fill" + case .appleTVRemotePower: + return "power" + case .appleTVRemoteVolumeUp: + return "plus" + case .appleTVRemoteVolumeDown: + return "minus" + case .appleTVRemoteMute: + return "speaker.slash.fill" + case .dpadUp: + return "chevron.up" + case .dpadDown: + return "chevron.down" + case .dpadLeft: + return "chevron.left" + case .dpadRight: + return "chevron.right" + default: + return nil + } + } +} + +enum ButtonCategory: String, CaseIterable { + case face + case bumper + case trigger + case dpad + case special + case thumbstick + case touchpad + case paddle // DualSense Edge back paddles and function buttons + + var displayName: String { + switch self { + case .face: return "Face Buttons" + case .bumper: return "Bumpers" + case .trigger: return "Triggers" + case .dpad: return "D-Pad" + case .special: return "Special" + case .thumbstick: return "Thumbsticks" + case .touchpad: return "Touchpad" + case .paddle: return "Paddles" + } + } + + /// Sort order for chord display: bumpers, triggers, sticks, face, special/touchpad/paddle, dpad + var chordDisplayOrder: Int { + switch self { + case .bumper: return 0 + case .trigger: return 1 + case .thumbstick: return 2 + case .face: return 3 + case .special: return 4 + case .touchpad: return 5 + case .paddle: return 6 + case .dpad: return 7 + } + } } diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonAvailability.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonAvailability.swift deleted file mode 100644 index 93835261..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonAvailability.swift +++ /dev/null @@ -1,410 +0,0 @@ -import Foundation - -extension ControllerButton { - /// Whether this button is only available on PlayStation controllers (DualSense or DualShock) - var isPlayStationOnly: Bool { - switch self { - case .touchpadButton, .touchpadTwoFingerButton, .touchpadTap, .touchpadTwoFingerTap, - .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, - .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick, - .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, - .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch: - return true // Available on both DualSense and DualShock - case .micMute: - return true // DualSense only - case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: - return true // Edge-only, but still PlayStation family - case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: - return true // DualSense gyroscope only - default: - return false - } - } - - /// Whether this button is only available on DualSense controllers (not DualShock) - var isDualSenseOnly: Bool { - switch self { - case .micMute: - return true // DualShock 4 doesn't have mic mute - case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: - return true // Edge-only - case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: - return true // DualSense gyroscope only - default: - return false - } - } - - /// Whether this button is only available on Steam Controllers. - var isSteamControllerOnly: Bool { - switch self { - case .leftTouchpadButton, .rightTouchpadButton, .leftTouchpadTap, .rightTouchpadTap, - .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, - .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, - .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, - .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch, - .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, - .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick, - .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, - .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: - return true - default: - return false - } - } - - /// Whether this button is only available on Apple TV/Siri Remote devices. - var isAppleTVRemoteOnly: Bool { - switch self { - case .siri, .appleTVRemotePower, .appleTVRemoteVolumeUp, .appleTVRemoteVolumeDown, .appleTVRemoteMute: - return true - default: - return false - } - } - - var isOuraRingOnly: Bool { - switch self { - case .ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, - .ouraTapHold, .ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight: - return true - default: - return false - } - } - - /// Whether this button represents one of the eight touchpad quadrant - /// variants (4 regions × {touch, click}). Used by the mapping engine for - /// dispatch and by UI to know how to render the region group. - var isTouchpadQuadrant: Bool { - return touchpadRegion != nil - } - - /// Returns the (region, trigger) pair this button represents, or nil if - /// it's not one of the quadrant variants. - var touchpadRegion: TouchpadRegion? { - switch self { - case .touchpadRegionTopLeftClick, .touchpadRegionTopLeftTouch, - .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopLeftTouch, - .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopLeftTouch: - return .topLeft - case .touchpadRegionTopRightClick, .touchpadRegionTopRightTouch, - .leftTouchpadRegionTopRightClick, .leftTouchpadRegionTopRightTouch, - .rightTouchpadRegionTopRightClick, .rightTouchpadRegionTopRightTouch: - return .topRight - case .touchpadRegionBottomLeftClick, .touchpadRegionBottomLeftTouch, - .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomLeftTouch, - .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomLeftTouch: - return .bottomLeft - case .touchpadRegionBottomRightClick, .touchpadRegionBottomRightTouch, - .leftTouchpadRegionBottomRightClick, .leftTouchpadRegionBottomRightTouch, - .rightTouchpadRegionBottomRightClick, .rightTouchpadRegionBottomRightTouch: - return .bottomRight - default: return nil - } - } - - var steamTouchpadSide: SteamTouchpadSide? { - switch self { - case .leftTouchpadButton, .leftTouchpadTap, - .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, - .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, - .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, - .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch: - return .left - case .rightTouchpadButton, .rightTouchpadTap, - .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, - .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick, - .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, - .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: - return .right - default: - return nil - } - } - - /// For chord/sequence matching only: which canonical "whole-pad" button - /// does this button alias for? `.touchpadRegion*Click` aliases for - /// `.touchpadButton`; `.touchpadRegion*Touch` aliases for `.touchpadTap`. - /// All other buttons return nil (no alias). - /// - /// This is the bridge that lets a chord like `[.touchpadButton, .a]` match - /// when the user clicks any quadrant in `.quadrants` mode without firing - /// a duplicate `.touchpadButton` press. The aliasing is *only* consulted - /// during chord/sequence detection; individual button mappings remain - /// distinct. - var chordSequenceAlias: ControllerButton? { - switch self { - case .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, - .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick: - return .touchpadButton - case .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, - .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch: - return .touchpadTap - case .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, - .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick: - return .leftTouchpadButton - case .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, - .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch: - return .leftTouchpadTap - case .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, - .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick: - return .rightTouchpadButton - case .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, - .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: - return .rightTouchpadTap - case .xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4: - return logicalEquivalent - default: - return nil - } - } - - var logicalEquivalent: ControllerButton? { - switch self { - case .xboxPaddle1: - return .leftPaddle - case .xboxPaddle2: - return .rightPaddle - case .xboxPaddle3: - return .leftFunction - case .xboxPaddle4: - return .rightFunction - default: - return nil - } - } - - var physicalEquivalentButtons: [ControllerButton] { - switch self { - case .leftPaddle: - return [.xboxPaddle1] - case .rightPaddle: - return [.xboxPaddle2] - case .leftFunction: - return [.xboxPaddle3] - case .rightFunction: - return [.xboxPaddle4] - default: - return [] - } - } - - /// Returns the trigger mode this quadrant button represents, or nil if - /// it's not a quadrant variant. `.both` is never returned — it's an - /// authoring concept, not a button identity. - var touchpadQuadrantTrigger: TouchpadTriggerMode? { - switch self { - case .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, - .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick, - .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, - .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, - .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, - .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick: - return .click - case .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, - .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch, - .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, - .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch, - .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, - .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch: - return .touch - default: - return nil - } - } - - /// Maps a `(region, trigger)` pair to the corresponding ControllerButton - /// case. `trigger` must be `.click` or `.touch` — `.both` is not a single - /// button (it's an authoring concept that fans out to both variants). - static func from(region: TouchpadRegion, trigger: TouchpadTriggerMode) -> ControllerButton? { - switch (region, trigger) { - case (.topLeft, .click): return .touchpadRegionTopLeftClick - case (.topRight, .click): return .touchpadRegionTopRightClick - case (.bottomLeft, .click): return .touchpadRegionBottomLeftClick - case (.bottomRight, .click): return .touchpadRegionBottomRightClick - case (.topLeft, .touch): return .touchpadRegionTopLeftTouch - case (.topRight, .touch): return .touchpadRegionTopRightTouch - case (.bottomLeft, .touch): return .touchpadRegionBottomLeftTouch - case (.bottomRight, .touch): return .touchpadRegionBottomRightTouch - case (_, .both): return nil - } - } - - static func from( - steamTouchpadSide side: SteamTouchpadSide, - region: TouchpadRegion, - trigger: TouchpadTriggerMode - ) -> ControllerButton? { - switch (side, region, trigger) { - case (.left, .topLeft, .click): return .leftTouchpadRegionTopLeftClick - case (.left, .topRight, .click): return .leftTouchpadRegionTopRightClick - case (.left, .bottomLeft, .click): return .leftTouchpadRegionBottomLeftClick - case (.left, .bottomRight, .click): return .leftTouchpadRegionBottomRightClick - case (.left, .topLeft, .touch): return .leftTouchpadRegionTopLeftTouch - case (.left, .topRight, .touch): return .leftTouchpadRegionTopRightTouch - case (.left, .bottomLeft, .touch): return .leftTouchpadRegionBottomLeftTouch - case (.left, .bottomRight, .touch): return .leftTouchpadRegionBottomRightTouch - case (.right, .topLeft, .click): return .rightTouchpadRegionTopLeftClick - case (.right, .topRight, .click): return .rightTouchpadRegionTopRightClick - case (.right, .bottomLeft, .click): return .rightTouchpadRegionBottomLeftClick - case (.right, .bottomRight, .click): return .rightTouchpadRegionBottomRightClick - case (.right, .topLeft, .touch): return .rightTouchpadRegionTopLeftTouch - case (.right, .topRight, .touch): return .rightTouchpadRegionTopRightTouch - case (.right, .bottomLeft, .touch): return .rightTouchpadRegionBottomLeftTouch - case (.right, .bottomRight, .touch): return .rightTouchpadRegionBottomRightTouch - case (_, _, .both): return nil - } - } - - static func steamTouchpadRegionButtons(side: SteamTouchpadSide) -> [ControllerButton] { - TouchpadRegion.allCases.flatMap { region in - [ - from(steamTouchpadSide: side, region: region, trigger: .click), - from(steamTouchpadSide: side, region: region, trigger: .touch), - ].compactMap { $0 } - } - } - - /// Whether this button is one of the virtual joystick direction bindings. - var isJoystickDirection: Bool { - joystickDirection != nil - } - - /// The physical stick side represented by this virtual joystick direction. - var joystickSide: JoystickSide? { - switch self { - case .leftStickUp, .leftStickDown, .leftStickLeft, .leftStickRight, - .leftStickUpLeft, .leftStickUpRight, .leftStickDownLeft, .leftStickDownRight: - return .left - case .rightStickUp, .rightStickDown, .rightStickLeft, .rightStickRight, - .rightStickUpLeft, .rightStickUpRight, .rightStickDownLeft, .rightStickDownRight: - return .right - default: - return nil - } - } - - /// The joystick direction represented by this virtual button. - var joystickDirection: JoystickDirection? { - switch self { - case .leftStickUp, .rightStickUp: return .up - case .leftStickDown, .rightStickDown: return .down - case .leftStickLeft, .rightStickLeft: return .left - case .leftStickRight, .rightStickRight: return .right - case .leftStickUpLeft, .rightStickUpLeft: return .upLeft - case .leftStickUpRight, .rightStickUpRight: return .upRight - case .leftStickDownLeft, .rightStickDownLeft: return .downLeft - case .leftStickDownRight, .rightStickDownRight: return .downRight - default: return nil - } - } - - static func joystickDirectionButton(side: JoystickSide, direction: JoystickDirection) -> ControllerButton { - switch (side, direction) { - case (.left, .up): return .leftStickUp - case (.left, .down): return .leftStickDown - case (.left, .left): return .leftStickLeft - case (.left, .right): return .leftStickRight - case (.left, .upLeft): return .leftStickUpLeft - case (.left, .upRight): return .leftStickUpRight - case (.left, .downLeft): return .leftStickDownLeft - case (.left, .downRight): return .leftStickDownRight - case (.right, .up): return .rightStickUp - case (.right, .down): return .rightStickDown - case (.right, .left): return .rightStickLeft - case (.right, .right): return .rightStickRight - case (.right, .upLeft): return .rightStickUpLeft - case (.right, .upRight): return .rightStickUpRight - case (.right, .downLeft): return .rightStickDownLeft - case (.right, .downRight): return .rightStickDownRight - } - } - - static func joystickDirectionButtons(side: JoystickSide) -> [ControllerButton] { - [.up, .left, .right, .down] - .map { joystickDirectionButton(side: side, direction: $0) } - } - - /// Whether this button is a virtual gesture button (not a physical button) - var isGestureButton: Bool { - switch self { - case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: - return true - default: - return false - } - } - - /// Whether this button is only available on DualSense Edge controllers - var isDualSenseEdgeOnly: Bool { - switch self { - case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: - return true - default: - return false - } - } - - /// Whether this button is only available on Xbox Elite controllers - var isXboxEliteOnly: Bool { - switch self { - case .xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4: - return true - default: - return false - } - } - - /// Buttons available for Xbox controllers (excludes Elite-only paddles) - static var xboxButtons: [ControllerButton] { - allCases.filter { !$0.isPlayStationOnly && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly && !$0.isOuraRingOnly } - } - - /// Buttons available only on Xbox Elite controllers - static var xboxEliteButtons: [ControllerButton] { - [.xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4] - } - - /// Buttons available for DualShock 4 controllers (has touchpad, no mic mute or paddles) - /// Note: DualShock 4's physical Share button maps to .view (buttonOptions), not .share - static var dualShockButtons: [ControllerButton] { - allCases.filter { !$0.isDualSenseOnly && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly && !$0.isOuraRingOnly && $0 != .share } - } - - /// Buttons available for DualSense controllers (excludes Share which doesn't exist on standard DualSense) - static var dualSenseButtons: [ControllerButton] { - allCases.filter { $0 != .share && !$0.isDualSenseEdgeOnly && !$0.isGestureButton && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly && !$0.isOuraRingOnly } - } - - /// Buttons available for Nintendo controllers (Joy-Con, Pro Controller) - /// Same as Xbox — no touchpad, mic, paddles, or gyro gestures - static var nintendoButtons: [ControllerButton] { - allCases.filter { !$0.isPlayStationOnly && !$0.isXboxEliteOnly && !$0.isSteamControllerOnly && !$0.isAppleTVRemoteOnly && !$0.isOuraRingOnly } - } - - /// Buttons available for Apple TV/Siri Remote devices. - static var appleTVRemoteButtons: [ControllerButton] { - [ - .appleTVRemotePower, - .dpadUp, .dpadDown, .dpadLeft, .dpadRight, - .touchpadButton, .touchpadTap, .view, .menu, .xbox, .siri, - .appleTVRemoteVolumeUp, .appleTVRemoteVolumeDown, .appleTVRemoteMute - ] - } - - static var ouraRingButtons: [ControllerButton] { - [ - .ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, .ouraTapHold, - .ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight - ] - } - - static var ouraRingTapButtons: [ControllerButton] { - [.ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, .ouraTapHold] - } - - static var ouraRingFlickButtons: [ControllerButton] { - [.ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight] - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonCategory.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonCategory.swift deleted file mode 100644 index 0b456aaa..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonCategory.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation - -extension ControllerButton { - /// Category for grouping in UI - var category: ButtonCategory { - switch self { - case .a, .b, .x, .y: - return .face - case .leftBumper, .rightBumper: - return .bumper - case .leftTrigger, .rightTrigger: - return .trigger - case .dpadUp, .dpadDown, .dpadLeft, .dpadRight: - return .dpad - case .menu, .view, .share, .xbox, .siri, - .appleTVRemotePower, .appleTVRemoteVolumeUp, - .appleTVRemoteVolumeDown, .appleTVRemoteMute: - return .special - case .leftThumbstick, .rightThumbstick: - return .thumbstick - case .leftStickUp, .leftStickDown, .leftStickLeft, .leftStickRight, - .leftStickUpLeft, .leftStickUpRight, .leftStickDownLeft, .leftStickDownRight, - .rightStickUp, .rightStickDown, .rightStickLeft, .rightStickRight, - .rightStickUpLeft, .rightStickUpRight, .rightStickDownLeft, .rightStickDownRight: - return .thumbstick - case .touchpadButton, .touchpadTwoFingerButton, .touchpadTap, .touchpadTwoFingerTap, .micMute, - .leftTouchpadButton, .rightTouchpadButton, .leftTouchpadTap, .rightTouchpadTap, - .leftTouchpadRegionTopLeftClick, .leftTouchpadRegionTopRightClick, - .leftTouchpadRegionBottomLeftClick, .leftTouchpadRegionBottomRightClick, - .leftTouchpadRegionTopLeftTouch, .leftTouchpadRegionTopRightTouch, - .leftTouchpadRegionBottomLeftTouch, .leftTouchpadRegionBottomRightTouch, - .rightTouchpadRegionTopLeftClick, .rightTouchpadRegionTopRightClick, - .rightTouchpadRegionBottomLeftClick, .rightTouchpadRegionBottomRightClick, - .rightTouchpadRegionTopLeftTouch, .rightTouchpadRegionTopRightTouch, - .rightTouchpadRegionBottomLeftTouch, .rightTouchpadRegionBottomRightTouch, - .touchpadRegionTopLeftClick, .touchpadRegionTopRightClick, - .touchpadRegionBottomLeftClick, .touchpadRegionBottomRightClick, - .touchpadRegionTopLeftTouch, .touchpadRegionTopRightTouch, - .touchpadRegionBottomLeftTouch, .touchpadRegionBottomRightTouch: - return .touchpad - case .leftPaddle, .rightPaddle, .leftFunction, .rightFunction: - return .paddle - case .xboxPaddle1, .xboxPaddle2, .xboxPaddle3, .xboxPaddle4: - return .paddle - case .gestureTiltBack, .gestureTiltForward, .gestureSteerLeft, .gestureSteerRight: - return .touchpad - case .ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, - .ouraTapHold, .ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight: - return .special - } - } -} - -enum ButtonCategory: String, CaseIterable { - case face - case bumper - case trigger - case dpad - case special - case thumbstick - case touchpad - case paddle - - var displayName: String { - switch self { - case .face: return "Face Buttons" - case .bumper: return "Bumpers" - case .trigger: return "Triggers" - case .dpad: return "D-Pad" - case .special: return "Special" - case .thumbstick: return "Thumbsticks" - case .touchpad: return "Touchpad" - case .paddle: return "Paddles" - } - } - - /// Sort order for chord display: bumpers, triggers, sticks, face, special/touchpad/paddle, dpad - var chordDisplayOrder: Int { - switch self { - case .bumper: return 0 - case .trigger: return 1 - case .thumbstick: return 2 - case .face: return 3 - case .special: return 4 - case .touchpad: return 5 - case .paddle: return 6 - case .dpad: return 7 - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonPresentation.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonPresentation.swift deleted file mode 100644 index 225f0160..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonPresentation.swift +++ /dev/null @@ -1,376 +0,0 @@ -import Foundation - -extension ControllerButton { - /// Human-readable display name - var displayName: String { - switch self { - case .a: return "A" - case .b: return "B" - case .x: return "X" - case .y: return "Y" - case .leftBumper: return "LB" - case .rightBumper: return "RB" - case .leftTrigger: return "LT" - case .rightTrigger: return "RT" - case .dpadUp: return "D-pad Up" - case .dpadDown: return "D-pad Down" - case .dpadLeft: return "D-pad Left" - case .dpadRight: return "D-pad Right" - case .menu: return "Menu" - case .view: return "View" - case .share: return "Share" - case .xbox: return "Xbox" - case .siri: return "Siri" - case .appleTVRemotePower: return "Power" - case .appleTVRemoteVolumeUp: return "Volume Up" - case .appleTVRemoteVolumeDown: return "Volume Down" - case .appleTVRemoteMute: return "Mute" - case .leftThumbstick: return "Left Stick" - case .rightThumbstick: return "Right Stick" - case .leftStickUp: return "Left Stick Up" - case .leftStickDown: return "Left Stick Down" - case .leftStickLeft: return "Left Stick Left" - case .leftStickRight: return "Left Stick Right" - case .leftStickUpLeft: return "Left Stick Up-Left" - case .leftStickUpRight: return "Left Stick Up-Right" - case .leftStickDownLeft: return "Left Stick Down-Left" - case .leftStickDownRight: return "Left Stick Down-Right" - case .rightStickUp: return "Right Stick Up" - case .rightStickDown: return "Right Stick Down" - case .rightStickLeft: return "Right Stick Left" - case .rightStickRight: return "Right Stick Right" - case .rightStickUpLeft: return "Right Stick Up-Left" - case .rightStickUpRight: return "Right Stick Up-Right" - case .rightStickDownLeft: return "Right Stick Down-Left" - case .rightStickDownRight: return "Right Stick Down-Right" - case .touchpadButton: return "Touchpad Press" - case .touchpadTwoFingerButton: return "Touchpad 2-Finger Press" - case .touchpadTap: return "Touchpad Tap" - case .touchpadTwoFingerTap: return "Touchpad 2-Finger Tap" - case .micMute: return "Mic Mute" - case .leftTouchpadButton: return "Left Pad Press" - case .rightTouchpadButton: return "Right Pad Press" - case .leftTouchpadTap: return "Left Pad Tap" - case .rightTouchpadTap: return "Right Pad Tap" - case .leftTouchpadRegionTopLeftClick: return "Left Top-Left Click" - case .leftTouchpadRegionTopRightClick: return "Left Top-Right Click" - case .leftTouchpadRegionBottomLeftClick: return "Left Bottom-Left Click" - case .leftTouchpadRegionBottomRightClick: return "Left Bottom-Right Click" - case .leftTouchpadRegionTopLeftTouch: return "Left Top-Left Tap" - case .leftTouchpadRegionTopRightTouch: return "Left Top-Right Tap" - case .leftTouchpadRegionBottomLeftTouch: return "Left Bottom-Left Tap" - case .leftTouchpadRegionBottomRightTouch: return "Left Bottom-Right Tap" - case .rightTouchpadRegionTopLeftClick: return "Right Top-Left Click" - case .rightTouchpadRegionTopRightClick: return "Right Top-Right Click" - case .rightTouchpadRegionBottomLeftClick: return "Right Bottom-Left Click" - case .rightTouchpadRegionBottomRightClick: return "Right Bottom-Right Click" - case .rightTouchpadRegionTopLeftTouch: return "Right Top-Left Tap" - case .rightTouchpadRegionTopRightTouch: return "Right Top-Right Tap" - case .rightTouchpadRegionBottomLeftTouch: return "Right Bottom-Left Tap" - case .rightTouchpadRegionBottomRightTouch: return "Right Bottom-Right Tap" - case .touchpadRegionTopLeftClick: return "Top-Left Click" - case .touchpadRegionTopRightClick: return "Top-Right Click" - case .touchpadRegionBottomLeftClick: return "Bottom-Left Click" - case .touchpadRegionBottomRightClick: return "Bottom-Right Click" - case .touchpadRegionTopLeftTouch: return "Top-Left Touch" - case .touchpadRegionTopRightTouch: return "Top-Right Touch" - case .touchpadRegionBottomLeftTouch: return "Bottom-Left Touch" - case .touchpadRegionBottomRightTouch: return "Bottom-Right Touch" - case .leftPaddle: return "Left Paddle" - case .rightPaddle: return "Right Paddle" - case .leftFunction: return "Left Fn" - case .rightFunction: return "Right Fn" - case .xboxPaddle1: return "Upper Left Paddle" - case .xboxPaddle2: return "Upper Right Paddle" - case .xboxPaddle3: return "Lower Left Paddle" - case .xboxPaddle4: return "Lower Right Paddle" - case .gestureTiltBack: return "Tilt Back" - case .gestureTiltForward: return "Tilt Forward" - case .gestureSteerLeft: return "Steer Left" - case .gestureSteerRight: return "Steer Right" - case .ouraTap: return "Oura Tap" - case .ouraDoubleTap: return "Oura Double Tap" - case .ouraTripleTap: return "Oura Triple Tap" - case .ouraFiveTap: return "Oura 5x Tap" - case .ouraTapHold: return "Oura Tap Hold" - case .ouraFlickUp: return "Oura Flick Up" - case .ouraFlickDown: return "Oura Flick Down" - case .ouraFlickLeft: return "Oura Flick Left" - case .ouraFlickRight: return "Oura Flick Right" - } - } - - /// Display name appropriate for the controller type - func displayName(forDualSense isDualSense: Bool) -> String { - guard isDualSense else { return displayName } - switch self { - case .a: return "Cross" - case .b: return "Circle" - case .x: return "Square" - case .y: return "Triangle" - case .leftBumper: return "L1" - case .rightBumper: return "R1" - case .leftTrigger: return "L2" - case .rightTrigger: return "R2" - case .menu: return "Options" - case .view: return "Create" - case .share: return "Share" // DualSense Edge has this, standard doesn't - case .xbox: return "PS" - case .leftThumbstick: return "L3" - case .rightThumbstick: return "R3" - default: return displayName - } - } - - /// Display name for Nintendo controllers (Joy-Con, Pro Controller) - func displayName(forNintendo isNintendo: Bool) -> String { - guard isNintendo else { return displayName } - switch self { - // macOS maps Nintendo/8BitDo face buttons by LABEL, not position: - // the button printed "A" reports as buttonA (-> .a), etc. So the - // A/B/X/Y labels are already correct as-is. What differs from Xbox is - // the physical ARRANGEMENT of those labels (Nintendo: X north, A east, - // B south, Y west), which the minimap handles by positioning each - // button view — not by relabeling. - case .leftBumper: return "L" - case .rightBumper: return "R" - case .leftTrigger: return "ZL" - case .rightTrigger: return "ZR" - case .menu: return "+" - case .view: return "\u{2212}" // minus sign - case .share: return "Capture" - case .xbox: return "Home" - case .leftThumbstick: return "L Stick" - case .rightThumbstick: return "R Stick" - default: return displayName - } - } - - /// Display name for 8BitDo pads (Zero 2 / Micro / Lite). Face buttons keep - /// their printed A/B/X/Y labels (like Nintendo), the bumpers are printed - /// L/R, but unlike Nintendo the analog/digital triggers are printed L2/R2 - /// (not ZL/ZR), and the guide button is the 8BitDo home button. - func displayName(forEightBitDo isEightBitDo: Bool) -> String { - guard isEightBitDo else { return displayName } - switch self { - case .leftBumper: return "L" - case .rightBumper: return "R" - case .leftTrigger: return "L2" - case .rightTrigger: return "R2" - case .xbox: return "Home" - case .share: return "Star" - default: return displayName - } - } - - /// Display name for Apple TV/Siri Remote controls. - func displayName(forAppleTVRemote isAppleTVRemote: Bool) -> String { - guard isAppleTVRemote else { return displayName } - switch self { - case .touchpadButton: return "Clickpad Click" - case .touchpadTap: return "Clickpad Tap" - case .menu: return "Play/Pause" - case .view: return "Back" - case .xbox: return "TV/Home" - case .siri: return "Siri" - case .appleTVRemotePower: return "Power" - case .appleTVRemoteVolumeUp: return "Volume Up" - case .appleTVRemoteVolumeDown: return "Volume Down" - case .appleTVRemoteMute: return "Mute" - default: return displayName - } - } - - /// Display name that adapts to any controller type - func displayName( - forDualSense isDualSense: Bool, - forNintendo isNintendo: Bool, - forAppleTVRemote isAppleTVRemote: Bool = false, - forEightBitDo isEightBitDo: Bool = false - ) -> String { - if isAppleTVRemote { return displayName(forAppleTVRemote: true) } - if isEightBitDo { return displayName(forEightBitDo: true) } - if isNintendo { return displayName(forNintendo: true) } - return displayName(forDualSense: isDualSense) - } - - /// Short label for compact UI - var shortLabel: String { - switch self { - case .a: return "A" - case .b: return "B" - case .x: return "X" - case .y: return "Y" - case .leftBumper: return "LB" - case .rightBumper: return "RB" - case .leftTrigger: return "LT" - case .rightTrigger: return "RT" - case .dpadUp: return "↑" - case .dpadDown: return "↓" - case .dpadLeft: return "←" - case .dpadRight: return "→" - case .menu: return "≡" - case .view: return "⧉" - case .share: return "⬆" - case .xbox: return "⊗" - case .siri: return "Siri" - case .appleTVRemotePower: return "PWR" - case .appleTVRemoteVolumeUp: return "V+" - case .appleTVRemoteVolumeDown: return "V-" - case .appleTVRemoteMute: return "Mute" - case .leftThumbstick: return "L3" - case .rightThumbstick: return "R3" - case .leftStickUp: return "L↑" - case .leftStickDown: return "L↓" - case .leftStickLeft: return "L←" - case .leftStickRight: return "L→" - case .leftStickUpLeft: return "L↖" - case .leftStickUpRight: return "L↗" - case .leftStickDownLeft: return "L↙" - case .leftStickDownRight: return "L↘" - case .rightStickUp: return "R↑" - case .rightStickDown: return "R↓" - case .rightStickLeft: return "R←" - case .rightStickRight: return "R→" - case .rightStickUpLeft: return "R↖" - case .rightStickUpRight: return "R↗" - case .rightStickDownLeft: return "R↙" - case .rightStickDownRight: return "R↘" - case .touchpadButton: return "TP" - case .touchpadTwoFingerButton: return "2P" - case .touchpadTap: return "1T" - case .touchpadTwoFingerTap: return "2" - case .micMute: return "🎤" - case .leftTouchpadButton: return "LP" - case .rightTouchpadButton: return "RP" - case .leftTouchpadTap: return "LTp" - case .rightTouchpadTap: return "RTp" - case .leftTouchpadRegionTopLeftClick: return "LTL" - case .leftTouchpadRegionTopRightClick: return "LTR" - case .leftTouchpadRegionBottomLeftClick: return "LBL" - case .leftTouchpadRegionBottomRightClick: return "LBR" - case .leftTouchpadRegionTopLeftTouch: return "LTL" - case .leftTouchpadRegionTopRightTouch: return "LTR" - case .leftTouchpadRegionBottomLeftTouch: return "LBL" - case .leftTouchpadRegionBottomRightTouch: return "LBR" - case .rightTouchpadRegionTopLeftClick: return "RTL" - case .rightTouchpadRegionTopRightClick: return "RTR" - case .rightTouchpadRegionBottomLeftClick: return "RBL" - case .rightTouchpadRegionBottomRightClick: return "RBR" - case .rightTouchpadRegionTopLeftTouch: return "RTL" - case .rightTouchpadRegionTopRightTouch: return "RTR" - case .rightTouchpadRegionBottomLeftTouch: return "RBL" - case .rightTouchpadRegionBottomRightTouch: return "RBR" - // Region buttons fall back to text labels here, but they're rendered - // by `ButtonIconView` as a custom 2×2 quadrant indicator that's - // recognizable at a glance (the diagonal-arrow glyphs above looked - // too similar to each other at button-tile size). - case .touchpadRegionTopLeftClick: return "TL" - case .touchpadRegionTopRightClick: return "TR" - case .touchpadRegionBottomLeftClick: return "BL" - case .touchpadRegionBottomRightClick: return "BR" - case .touchpadRegionTopLeftTouch: return "TL" - case .touchpadRegionTopRightTouch: return "TR" - case .touchpadRegionBottomLeftTouch: return "BL" - case .touchpadRegionBottomRightTouch: return "BR" - case .leftPaddle: return "LP" - case .rightPaddle: return "RP" - case .leftFunction: return "LFn" - case .rightFunction: return "RFn" - case .xboxPaddle1: return "P1" - case .xboxPaddle2: return "P2" - case .xboxPaddle3: return "P3" - case .xboxPaddle4: return "P4" - case .gestureTiltBack: return "⤴" - case .gestureTiltForward: return "⤵" - case .gestureSteerLeft: return "↰" - case .gestureSteerRight: return "↱" - case .ouraTap: return "Tap" - case .ouraDoubleTap: return "2x" - case .ouraTripleTap: return "3x" - case .ouraFiveTap: return "5x" - case .ouraTapHold: return "Hold" - case .ouraFlickUp: return "↑" - case .ouraFlickDown: return "↓" - case .ouraFlickLeft: return "←" - case .ouraFlickRight: return "→" - } - } - - /// Short label appropriate for the controller type - func shortLabel(forDualSense isDualSense: Bool) -> String { - guard isDualSense else { return shortLabel } - switch self { - case .a: return "✕" - case .b: return "○" - case .x: return "□" - case .y: return "△" - case .leftBumper: return "L1" - case .rightBumper: return "R1" - case .leftTrigger: return "L2" - case .rightTrigger: return "R2" - case .menu: return "☰" - case .view: return "▢" // Create button symbol - case .xbox: return "ⓟ" // PS button - default: return shortLabel - } - } - - /// Short label for Nintendo controllers - func shortLabel(forNintendo isNintendo: Bool) -> String { - guard isNintendo else { return shortLabel } - switch self { - case .leftBumper: return "L" - case .rightBumper: return "R" - case .leftTrigger: return "ZL" - case .rightTrigger: return "ZR" - case .menu: return "+" - case .view: return "\u{2212}" // minus sign - case .share: return "\u{25A1}" // capture button (square) - case .xbox: return "\u{2302}" // home button - default: return shortLabel - } - } - - /// Short label for 8BitDo pads: L/R bumpers, L2/R2 triggers (matches the - /// minimap), everything else falls back to the default labels. - func shortLabel(forEightBitDo isEightBitDo: Bool) -> String { - guard isEightBitDo else { return shortLabel } - switch self { - case .leftBumper: return "L" - case .rightBumper: return "R" - case .leftTrigger: return "L2" - case .rightTrigger: return "R2" - default: return shortLabel - } - } - - /// Short label for Apple TV/Siri Remote controls. - func shortLabel(forAppleTVRemote isAppleTVRemote: Bool) -> String { - guard isAppleTVRemote else { return shortLabel } - switch self { - case .touchpadButton: return "Click" - case .touchpadTap: return "Tap" - case .menu: return "▶" - case .view: return "←" - case .xbox: return "TV" - case .siri: return "Siri" - case .appleTVRemotePower: return "PWR" - case .appleTVRemoteVolumeUp: return "V+" - case .appleTVRemoteVolumeDown: return "V-" - case .appleTVRemoteMute: return "Mute" - default: return shortLabel - } - } - - /// Short label that adapts to any controller type - func shortLabel( - forDualSense isDualSense: Bool, - forNintendo isNintendo: Bool, - forAppleTVRemote isAppleTVRemote: Bool = false - ) -> String { - if isAppleTVRemote { return shortLabel(forAppleTVRemote: true) } - if isNintendo { return shortLabel(forNintendo: true) } - return shortLabel(forDualSense: isDualSense) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonSymbols.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonSymbols.swift deleted file mode 100644 index e8d8fb98..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerButtonSymbols.swift +++ /dev/null @@ -1,172 +0,0 @@ -import Foundation - -extension ControllerButton { - /// SF Symbol name if available - var systemImageName: String? { - systemImageName(forDualSense: false) - } - - /// SF Symbol name appropriate for the controller type - func systemImageName(forDualSense isDualSense: Bool) -> String? { - isDualSense ? dualSenseSystemImageName : xboxSystemImageName - } - - /// SF Symbol name for Nintendo controllers - func systemImageName(forNintendo isNintendo: Bool) -> String? { - guard isNintendo else { return systemImageName } - switch self { - case .xbox: return "house" // Home button - default: return systemImageName // Reuse Xbox SF Symbols for everything else - } - } - - /// SF Symbol name for Apple TV/Siri Remote controls. - func systemImageName(forAppleTVRemote isAppleTVRemote: Bool) -> String? { - guard isAppleTVRemote else { return systemImageName } - switch self { - case .touchpadButton: - return "hand.point.up.left" - case .touchpadTap: - return "hand.tap" - case .menu: - return "playpause.fill" - case .view: - return "chevron.left" - case .xbox: - return "tv.fill" - case .siri: - return "mic.fill" - case .appleTVRemotePower: - return "power" - case .appleTVRemoteVolumeUp: - return "plus" - case .appleTVRemoteVolumeDown: - return "minus" - case .appleTVRemoteMute: - return "speaker.slash.fill" - case .ouraTap: - return "hand.tap" - case .ouraDoubleTap: - return "2.circle" - case .ouraTripleTap: - return "3.circle" - case .ouraFiveTap: - return "5.circle" - case .ouraTapHold: - return "hand.raised" - case .ouraFlickUp: - return "arrow.up" - case .ouraFlickDown: - return "arrow.down" - case .ouraFlickLeft: - return "arrow.left" - case .ouraFlickRight: - return "arrow.right" - case .dpadUp: - return "chevron.up" - case .dpadDown: - return "chevron.down" - case .dpadLeft: - return "chevron.left" - case .dpadRight: - return "chevron.right" - default: - return nil - } - } - - private var dualSenseSystemImageName: String? { - switch self { - case .dpadUp: return "arrowtriangle.up.fill" - case .dpadDown: return "arrowtriangle.down.fill" - case .dpadLeft: return "arrowtriangle.left.fill" - case .dpadRight: return "arrowtriangle.right.fill" - case .leftStickUp, .rightStickUp: return "arrow.up.circle" - case .leftStickDown, .rightStickDown: return "arrow.down.circle" - case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" - case .leftStickRight, .rightStickRight: return "arrow.right.circle" - case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" - case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" - case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" - case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" - case .menu: return "line.3.horizontal" - case .view: return "square.and.arrow.up" // Create/Share button (upload icon) - case .share: return "square.and.arrow.up" - case .xbox: return "playstation.logo" // PS button - case .leftThumbstick: return "l3.button.angledbottom.horizontal.left" - case .rightThumbstick: return "r3.button.angledbottom.horizontal.right" - case .touchpadButton: return "hand.point.up.left" - case .touchpadTwoFingerButton: return nil // Use text label "2P" - case .touchpadTap: return "hand.tap" - case .touchpadTwoFingerTap: return "hand.tap" - case .micMute: return "mic.slash" - case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" - case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" - case .touchpadRegionTopLeftClick, .touchpadRegionTopLeftTouch, - .touchpadRegionTopRightClick, .touchpadRegionTopRightTouch, - .touchpadRegionBottomLeftClick, .touchpadRegionBottomLeftTouch, - .touchpadRegionBottomRightClick, .touchpadRegionBottomRightTouch: - // Custom 2x2 quadrant indicator drawn by ButtonIconView; no - // SF Symbol used for these. - return nil - case .leftPaddle: return "l.button.roundedbottom.horizontal" - case .rightPaddle: return "r.button.roundedbottom.horizontal" - case .leftFunction: return "button.horizontal.top.press" - case .rightFunction: return "button.horizontal.top.press" - // Face buttons use text symbols for DualSense, not SF Symbols - case .a, .b, .x, .y: return nil - default: return nil - } - } - - private var xboxSystemImageName: String? { - switch self { - case .dpadUp: return "arrowtriangle.up.fill" - case .dpadDown: return "arrowtriangle.down.fill" - case .dpadLeft: return "arrowtriangle.left.fill" - case .dpadRight: return "arrowtriangle.right.fill" - case .leftStickUp, .rightStickUp: return "arrow.up.circle" - case .leftStickDown, .rightStickDown: return "arrow.down.circle" - case .leftStickLeft, .rightStickLeft: return "arrow.left.circle" - case .leftStickRight, .rightStickRight: return "arrow.right.circle" - case .leftStickUpLeft, .rightStickUpLeft: return "arrow.up.left.circle" - case .leftStickUpRight, .rightStickUpRight: return "arrow.up.right.circle" - case .leftStickDownLeft, .rightStickDownLeft: return "arrow.down.left.circle" - case .leftStickDownRight, .rightStickDownRight: return "arrow.down.right.circle" - case .menu: return "line.3.horizontal" - case .view: return "rectangle.on.rectangle" - case .share: return "square.and.arrow.up" - case .xbox: return "xbox.logo" - case .siri: return "mic.fill" - case .appleTVRemotePower: return "power" - case .appleTVRemoteVolumeUp: return "speaker.wave.3.fill" - case .appleTVRemoteVolumeDown: return "speaker.wave.1.fill" - case .appleTVRemoteMute: return "speaker.slash.fill" - case .leftThumbstick: return "l.circle" - case .rightThumbstick: return "r.circle" - case .a: return "a.circle" - case .b: return "b.circle" - case .x: return "x.circle" - case .y: return "y.circle" - case .touchpadButton: return "hand.point.up.left" - case .touchpadTap: return "hand.tap" - case .leftTouchpadButton, .rightTouchpadButton: return "hand.point.up.left" - case .leftTouchpadTap, .rightTouchpadTap: return "hand.tap" - case .micMute: return "mic.slash" - case .ouraTap: return "hand.tap" - case .ouraDoubleTap: return "2.circle" - case .ouraTripleTap: return "3.circle" - case .ouraFiveTap: return "5.circle" - case .ouraTapHold: return "hand.raised" - case .ouraFlickUp: return "arrow.up" - case .ouraFlickDown: return "arrow.down" - case .ouraFlickLeft: return "arrow.left" - case .ouraFlickRight: return "arrow.right" - case .xboxPaddle1: return "l.button.roundedbottom.horizontal" - case .xboxPaddle2: return "r.button.roundedbottom.horizontal" - case .xboxPaddle3: return "l.button.roundedbottom.horizontal.fill" - case .xboxPaddle4: return "r.button.roundedbottom.horizontal.fill" - default: return nil - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerPairingGuide.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerPairingGuide.swift index 3493747f..8cda2b57 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerPairingGuide.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/ControllerPairingGuide.swift @@ -261,20 +261,6 @@ extension ControllerPreviewLayout { // Volume Up (+) + Back button. pairingButtons: [.appleTVRemoteVolumeUp, .view] ) - case .ouraRing: - return ControllerPairingGuide( - title: loc("Connect Your Oura Ring"), - tagline: loc("Oura Ring 3 or 4 with realtime accelerometer input"), - systemImage: systemImage, - bluetoothSteps: [ - loc("Open **ControllerKeys Settings → Joystick → Oura Ring** and enable **Use Oura Ring**."), - loc("Keep the ring nearby and unlocked from the official Oura app if needed."), - loc("If this is a reset ring, leave **Adopt reset ring** enabled so ControllerKeys can install its local auth key."), - loc("Wait for the Oura status to show **Authenticated**; the Buttons tab then shows the Oura Ring layout.") - ], - tip: loc("Use **Reset Center** while pointing your finger at the screen to recalibrate the motion plane."), - pairingButtons: [.ouraTap, .ouraTapHold, .ouraFlickLeft, .ouraFlickRight] - ) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Models/ControllerPreviewLayout.swift b/XboxControllerMapper/XboxControllerMapper/Models/ControllerPreviewLayout.swift index e06ab409..63e08921 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/ControllerPreviewLayout.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/ControllerPreviewLayout.swift @@ -14,14 +14,9 @@ enum ControllerPreviewLayout: String, Codable, CaseIterable, Identifiable { case eightBitDoLite2 case eightBitDoLiteSE case appleTVRemote - case ouraRing var id: String { rawValue } - static var concreteLayouts: [ControllerPreviewLayout] { - allCases.filter { $0 != .active } - } - var displayName: String { switch self { case .active: return "Active Controller" @@ -37,26 +32,6 @@ enum ControllerPreviewLayout: String, Codable, CaseIterable, Identifiable { case .eightBitDoLite2: return "8BitDo Lite 2" case .eightBitDoLiteSE: return "8BitDo Lite SE" case .appleTVRemote: return "Apple TV Remote" - case .ouraRing: return "Oura Ring" - } - } - - var platformLabel: String { - switch self { - case .active: return "Connected device" - case .xbox: return "Xbox Series X|S / One" - case .xboxElite: return "Xbox Elite Series 2" - case .dualSense: return "PS5" - case .dualSenseEdge: return "PS5 (Edge)" - case .dualShock: return "PS4" - case .nintendo: return "Switch Pro / Joy-Con" - case .steam: return "Steam Controller" - case .eightBitDoZero2: return "Keychain Bluetooth pad" - case .eightBitDoMicro: return "Micro Bluetooth pad" - case .eightBitDoLite2: return "Switch / D-input modes" - case .eightBitDoLiteSE: return "Switch / D-input modes" - case .appleTVRemote: return "Apple TV Siri Remote" - case .ouraRing: return "Oura Ring 3 / 4" } } @@ -70,7 +45,81 @@ enum ControllerPreviewLayout: String, Codable, CaseIterable, Identifiable { case .eightBitDoZero2, .eightBitDoMicro: return "gamecontroller.circle" case .eightBitDoLite2, .eightBitDoLiteSE: return "gamecontroller.circle.fill" case .appleTVRemote: return "appletvremote.gen3" - case .ouraRing: return "circle.dashed" + } + } + + func isPlayStation(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsPlayStation + case .dualSense, .dualSenseEdge, .dualShock: return true + default: return false + } + } + + func isDualSense(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsDualSense + case .dualSense, .dualSenseEdge: return true + default: return false + } + } + + func isDualSenseEdge(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsDualSenseEdge + case .dualSenseEdge: return true + default: return false + } + } + + func isDualShock(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsDualShock + case .dualShock: return true + default: return false + } + } + + func isXboxElite(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsXboxElite + case .xboxElite: return true + default: return false + } + } + + func isSteamController(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsSteamController + case .steam: return true + default: return false + } + } + + func isNintendo(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsNintendo + case .nintendo: return true + default: return false + } + } + + func eightBitDoModel(using service: ControllerService) -> EightBitDoMinimapModel? { + switch self { + case .active: return service.threadSafeEightBitDoMinimapModel + case .eightBitDoZero2: return .zero2 + case .eightBitDoMicro: return .micro + case .eightBitDoLite2: return .lite2 + case .eightBitDoLiteSE: return .liteSE + default: return nil + } + } + + func isAppleTVRemote(using service: ControllerService) -> Bool { + switch self { + case .active: return service.threadSafeIsAppleTVRemote + case .appleTVRemote: return true + default: return false } } } diff --git a/XboxControllerMapper/XboxControllerMapper/Models/JoystickSettings.swift b/XboxControllerMapper/XboxControllerMapper/Models/JoystickSettings.swift index 42dae21e..6c013905 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/JoystickSettings.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/JoystickSettings.swift @@ -42,194 +42,32 @@ enum StickMode: String, Codable, CaseIterable { } } -/// Which analog trigger, if any, scales cursor speed as a continuous precision control. -enum AnalogPrecisionTriggerMode: String, Codable, CaseIterable { - case off = "off" - case left = "left" - case right = "right" - case either = "either" - - static var allCases: [AnalogPrecisionTriggerMode] { - [.off, .right, .left, .either] - } - - var displayName: String { - switch self { - case .off: return "Off" - case .left: return "L2 / LT" - case .right: return "R2 / RT" - case .either: return "Either" - } - } - - func rawValue(leftTrigger: Double, rightTrigger: Double) -> Double { - switch self { - case .off: - return 0 - case .left: - return leftTrigger - case .right: - return rightTrigger - case .either: - return max(leftTrigger, rightTrigger) - } - } -} - -enum OuraMotionOrientation: String, Codable, CaseIterable, Identifiable { - case screenPlane = "screenPlane" - case fingerToScreen = "fingerToScreen" - case legacyXY = "legacyXY" - - var id: String { rawValue } - - var displayName: String { - switch self { - case .screenPlane: return "Point + Twist" - case .fingerToScreen: return "Finger Forward" - case .legacyXY: return "Legacy X/Y" - } - } -} - -enum OuraMotionOutputMode: String, Codable, CaseIterable, Identifiable { - case mouse - case scroll - case arrowKeys - case wasdKeys - case custom - case dpad - case off - - var id: String { rawValue } - - var displayName: String { - switch self { - case .mouse: return "Mouse" - case .scroll: return "Scroll" - case .arrowKeys: return "Arrow Keys" - case .wasdKeys: return "WASD" - case .custom: return "Custom" - case .dpad: return "D-Pad" - case .off: return "Off" - } - } - - var systemImageName: String { - switch self { - case .mouse: return "cursorarrow.motionlines" - case .scroll: return "arrow.up.and.down" - case .arrowKeys: return "arrow.up.and.down.and.arrow.left.and.right" - case .wasdKeys: return "keyboard" - case .custom: return "square.grid.2x2" - case .dpad: return "dpad" - case .off: return "pause.circle" - } - } - - var detailText: String { - switch self { - case .mouse: return "Ring tilt moves the cursor." - case .scroll: return "Ring tilt scrolls the foreground app." - case .arrowKeys: return "Ring tilt holds the arrow keys." - case .wasdKeys: return "Ring tilt holds W/A/S/D." - case .custom: return "Ring tilt triggers four configurable direction actions." - case .dpad: return "Ring tilt presses D-pad directions." - case .off: return "Ring motion is paused; taps and flicks still work." - } - } - - var exposesTiltDirections: Bool { - switch self { - case .arrowKeys, .wasdKeys, .custom, .dpad: - return true - case .mouse, .scroll, .off: - return false - } - } -} +/// Settings for joystick behavior +struct JoystickSettings: Codable, Equatable { + static let defaultCustomSliceSize = 0.75 + static let defaultCustomDeadzone = 0.22 + static let defaultTouchpadDeadzone = 0.005 -struct OuraMotionSettings: Codable, Equatable { - static let `default` = OuraMotionSettings() - - var enabled: Bool = false - var motionOutputEnabled: Bool = true - var outputMode: OuraMotionOutputMode? - var targetStick: JoystickSide = .left - var orientation: OuraMotionOrientation = .screenPlane - var sensitivity: Double = 0.0479656339031339 - var horizontalBoost: Double = 1.0 - var leftTiltBoost: Double = 1.0 - var deadzone: Double = 0.3505420470505618 - var smoothing: Double = 0.7516045616005551 - var invertX: Bool = false - var invertY: Bool = false - var adoptResetRing: Bool = true - var diagnosticsEnabled: Bool = true - - func isValid() -> Bool { - (0.0...1.0).contains(sensitivity) && - (1.0...4.0).contains(horizontalBoost) && - (1.0...4.0).contains(leftTiltBoost) && - (0.0...0.95).contains(deadzone) && - (0.0...1.0).contains(smoothing) - } + /// Mouse movement sensitivity (0.0 - 1.0, where 0.5 is default) + var mouseSensitivity: Double = 0.5 - enum CodingKeys: String, CodingKey { - case enabled - case motionOutputEnabled - case outputMode - case targetStick - case orientation - case sensitivity - case horizontalBoost - case leftTiltBoost - case deadzone - case smoothing - case invertX - case invertY - case adoptResetRing - case diagnosticsEnabled - } + /// Scroll speed sensitivity (0.0 - 1.0, where 0.5 is default) + var scrollSensitivity: Double = 0.5 - init() {} + /// Deadzone for mouse movement (0.0 - 1.0) + var mouseDeadzone: Double = 0.15 - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - enabled = try container.decode(.enabled, default: false) - motionOutputEnabled = try container.decode(.motionOutputEnabled, default: true) - outputMode = try container.decodeLenient(.outputMode) - targetStick = try container.decodeLenient(.targetStick, default: JoystickSide.left) - orientation = try container.decode(.orientation, default: .screenPlane) - sensitivity = try container.decode(.sensitivity, default: 0.0479656339031339, clampedTo: 0.0...1.0) - horizontalBoost = try container.decode(.horizontalBoost, default: 1.0, clampedTo: 1.0...4.0) - leftTiltBoost = try container.decode(.leftTiltBoost, default: 1.0, clampedTo: 1.0...4.0) - deadzone = try container.decode(.deadzone, default: 0.3505420470505618, clampedTo: 0.0...0.95) - smoothing = try container.decode(.smoothing, default: 0.7516045616005551, clampedTo: 0.0...1.0) - invertX = try container.decode(.invertX, default: false) - invertY = try container.decode(.invertY, default: false) - adoptResetRing = try container.decode(.adoptResetRing, default: true) - diagnosticsEnabled = try container.decode(.diagnosticsEnabled, default: true) - } -} + /// Deadzone for scrolling (0.0 - 1.0) + var scrollDeadzone: Double = 0.15 -/// Settings for joystick behavior. -/// -/// Per-stick response (mode, sensitivity, acceleration, deadzone, invert, custom -/// slices) lives in `leftStick` / `rightStick` (`StickTuning`), so the two sticks -/// are independently tunable even when both drive the mouse. Everything else here -/// is genuinely global (touchpad, focus mode, gyro aiming, motion gestures, -/// scroll double-tap boost). -struct JoystickSettings: Codable, Equatable { - static let defaultCustomSliceSize = 0.75 - static let defaultCustomDeadzone = 0.22 - static let defaultTouchpadDeadzone = 0.005 + /// Invert vertical mouse movement + var invertMouseY: Bool = false - /// Per-stick tuning for the left analog stick (defaults to mouse). - var leftStick: StickTuning = .leftDefault + /// Invert vertical scroll direction + var invertScrollY: Bool = false - /// Per-stick tuning for the right analog stick (defaults to scroll). - var rightStick: StickTuning = .rightDefault + /// Acceleration curve for mouse movement (0.0 = linear, 1.0 = max acceleration) + var mouseAcceleration: Double = 0.5 /// Touchpad sensitivity (0.0 - 1.0) var touchpadSensitivity: Double = 0.5 @@ -276,6 +114,9 @@ struct JoystickSettings: Codable, Equatable { /// Applies to DualSense, DualSense Edge, and DualShock 4 (same touchpad pipeline). var disableTouchpadAsMouse: Bool = false + /// Acceleration curve for scrolling (0.0 = linear, 1.0 = max acceleration) + var scrollAcceleration: Double = 0.5 + /// Multiplier applied to scroll speed after a double-tap up/down var scrollBoostMultiplier: Double = 2.0 @@ -285,17 +126,29 @@ struct JoystickSettings: Codable, Equatable { /// Modifier that triggers focus mode (slower mouse speed) var focusModeModifier: ModifierFlags = .command - /// Analog trigger that proportionally slows mouse/touchpad cursor movement. - var analogPrecisionTriggerMode: AnalogPrecisionTriggerMode = .off + /// Mode for left stick (default: mouse) + var leftStickMode: StickMode = .mouse + + /// Mode for right stick (default: scroll) + var rightStickMode: StickMode = .scroll + + /// Width of the custom left-stick horizontal direction slices. + var leftStickCustomHorizontalSliceSize: Double = 0.75 + + /// Width of the custom left-stick vertical direction slices. + var leftStickCustomVerticalSliceSize: Double = 0.75 + + /// Center deadzone for custom left-stick direction slices. + var leftStickCustomDeadzone: Double = 0.22 - /// Cursor-speed floor at a full trigger pull (0.05 - 1.0). - var analogPrecisionMinimumSpeed: Double = 0.15 + /// Width of the custom right-stick horizontal direction slices. + var rightStickCustomHorizontalSliceSize: Double = 0.75 - /// Trigger value ignored before precision scaling begins (0.0 - 0.95). - var analogPrecisionDeadzone: Double = 0.05 + /// Width of the custom right-stick vertical direction slices. + var rightStickCustomVerticalSliceSize: Double = 0.75 - /// Response curve from trigger depth to speed reduction (0 = linear, 1 = cubic). - var analogPrecisionCurve: Double = 0.35 + /// Center deadzone for custom right-stick direction slices. + var rightStickCustomDeadzone: Double = 0.22 /// Whether gyroscope aiming is enabled during focus mode. var gyroAimingEnabled: Bool = false @@ -306,121 +159,21 @@ struct JoystickSettings: Codable, Equatable { /// Deadzone for gyroscope aiming (0.0 - 1.0, rad/s threshold) var gyroAimingDeadzone: Double = 0.3 - /// How mouse movement is posted when a game captures the mouse (pointer lock). - /// `.auto` switches to relative delta-only events while the system cursor is - /// hidden, so FPS aiming never stops at screen edges. - var pointerLockMouseMode: PointerLockMouseMode = .auto - /// Motion gesture sensitivity (0.0 = hard to trigger, 1.0 = very sensitive) var gestureSensitivity: Double = 0.5 /// Motion gesture cooldown (0.0 = fast repeat, 1.0 = slow repeat) var gestureCooldown: Double = 0.5 - /// Oura Ring accelerometer/tap input routed into the regular stick/button mapping pipeline. - var ouraMotion: OuraMotionSettings = .default - static let `default` = JoystickSettings() - /// Tuning for the given side. - func stick(_ side: JoystickSide) -> StickTuning { - side == .left ? leftStick : rightStick - } - - var inferredOuraMotionOutputMode: OuraMotionOutputMode { - guard ouraMotion.motionOutputEnabled else { return .off } - switch stick(ouraMotion.targetStick).mode { - case .scroll: - return .scroll - case .none: - return .off - case .mouse: - return .mouse - case .wasdKeys: - return .wasdKeys - case .arrowKeys: - return .arrowKeys - case .custom: - return .custom - case .dpad: - return .dpad - } - } - - var ouraMotionOutputMode: OuraMotionOutputMode { - guard ouraMotion.motionOutputEnabled else { return .off } - if let outputMode = ouraMotion.outputMode, - ouraMotionOutputModeMatchesCurrentRouting(outputMode) { - return outputMode - } - return inferredOuraMotionOutputMode - } - - func ouraMotionOutputModeMatchesCurrentRouting(_ mode: OuraMotionOutputMode) -> Bool { - guard ouraMotion.motionOutputEnabled else { return false } - let stickMode = stick(ouraMotion.targetStick).mode - switch mode { - case .mouse: - return stickMode == .mouse - case .scroll: - return stickMode == .scroll - case .arrowKeys: - return stickMode == .arrowKeys - case .wasdKeys: - return stickMode == .wasdKeys - case .custom: - return stickMode == .custom - case .dpad: - return stickMode == .dpad - case .off: - return stickMode == .none - } - } - - mutating func setOuraMotionOutputMode(_ mode: OuraMotionOutputMode) { - switch mode { - case .mouse: - ouraMotion.motionOutputEnabled = true - ouraMotion.outputMode = .mouse - ouraMotion.targetStick = .left - leftStick.mode = .mouse - case .scroll: - ouraMotion.motionOutputEnabled = true - ouraMotion.outputMode = .scroll - ouraMotion.targetStick = .right - rightStick.mode = .scroll - case .arrowKeys: - ouraMotion.motionOutputEnabled = true - ouraMotion.outputMode = .arrowKeys - ouraMotion.targetStick = .left - leftStick.mode = .arrowKeys - case .wasdKeys: - ouraMotion.motionOutputEnabled = true - ouraMotion.outputMode = .wasdKeys - ouraMotion.targetStick = .left - leftStick.mode = .wasdKeys - case .custom: - ouraMotion.motionOutputEnabled = true - ouraMotion.outputMode = .custom - ouraMotion.targetStick = .left - leftStick.mode = .custom - case .dpad: - ouraMotion.motionOutputEnabled = true - ouraMotion.outputMode = .dpad - ouraMotion.targetStick = .left - leftStick.mode = .dpad - case .off: - ouraMotion.motionOutputEnabled = false - } - } - /// Converts 0-1 gyro aiming sensitivity to pixel-scale multiplier (cubic curve) var gyroAimingMultiplier: Double { return 1.0 + pow(gyroAimingSensitivity, 3.0) * 20.0 } func chordSequenceJoystickDirectionButtons(side: JoystickSide) -> [ControllerButton] { - let mode = stick(side).mode + let mode = side == .left ? leftStickMode : rightStickMode guard mode.exposesJoystickDirections else { return [] } return ControllerButton.joystickDirectionButtons(side: side) } @@ -460,8 +213,11 @@ struct JoystickSettings: Codable, Equatable { /// Validates settings ranges func isValid() -> Bool { let range = 0.0...1.0 - return leftStick.isValid() && - rightStick.isValid() && + return range.contains(mouseSensitivity) && + range.contains(scrollSensitivity) && + (0.0...0.99).contains(mouseDeadzone) && + (0.0...0.99).contains(scrollDeadzone) && + range.contains(mouseAcceleration) && range.contains(touchpadSensitivity) && range.contains(touchpadAcceleration) && (0.0...0.03).contains(touchpadDeadzone) && @@ -469,40 +225,34 @@ struct JoystickSettings: Codable, Equatable { range.contains(touchpadPanSensitivity) && range.contains(appleTVRemoteCircularScrollSensitivity) && (0.5...5.0).contains(touchpadZoomToPanRatio) && + range.contains(scrollAcceleration) && (1.0...4.0).contains(scrollBoostMultiplier) && range.contains(focusModeSensitivity) && - (0.05...1.0).contains(analogPrecisionMinimumSpeed) && - (0.0...0.95).contains(analogPrecisionDeadzone) && - range.contains(analogPrecisionCurve) && + range.contains(leftStickCustomHorizontalSliceSize) && + range.contains(leftStickCustomVerticalSliceSize) && + range.contains(leftStickCustomDeadzone) && + range.contains(rightStickCustomHorizontalSliceSize) && + range.contains(rightStickCustomVerticalSliceSize) && + range.contains(rightStickCustomDeadzone) && range.contains(gyroAimingSensitivity) && range.contains(gyroAimingDeadzone) && range.contains(gestureSensitivity) && - range.contains(gestureCooldown) && - ouraMotion.isValid() + range.contains(gestureCooldown) + } + + /// Converts 0-1 sensitivity to actual multiplier for mouse + var mouseMultiplier: Double { + return calculateMouseMultiplier(sensitivity: mouseSensitivity) } /// Converts 0-1 focus sensitivity to actual multiplier for mouse var focusMultiplier: Double { - return JoystickCurves.mouseMultiplier(sensitivity: focusModeSensitivity) + return calculateMouseMultiplier(sensitivity: focusModeSensitivity) } - /// Multiplier applied to cursor motion based on analog trigger depth. - /// Returns 1.0 when disabled or below deadzone, and approaches - /// `analogPrecisionMinimumSpeed` at full trigger pull. - func analogPrecisionMultiplier(leftTrigger: Double, rightTrigger: Double) -> Double { - guard analogPrecisionTriggerMode != .off else { return 1.0 } - let rawTrigger = max(0.0, min(1.0, analogPrecisionTriggerMode.rawValue( - leftTrigger: leftTrigger, - rightTrigger: rightTrigger - ))) - let deadzone = max(0.0, min(0.95, analogPrecisionDeadzone)) - guard rawTrigger > deadzone else { return 1.0 } - - let minimumSpeed = max(0.05, min(1.0, analogPrecisionMinimumSpeed)) - let normalized = (rawTrigger - deadzone) / (1.0 - deadzone) - let exponent = 1.0 + max(0.0, min(1.0, analogPrecisionCurve)) * 2.0 - let shaped = pow(normalized, exponent) - return 1.0 - shaped * (1.0 - minimumSpeed) + private func calculateMouseMultiplier(sensitivity: Double) -> Double { + // Map 0-1 to 2-120 range (exponential for better feel) + return 2.0 + pow(sensitivity, 3.0) * 118.0 } func calibratedTouchpadValue(_ raw: Double, boost: Double) -> Double { @@ -525,21 +275,42 @@ struct JoystickSettings: Codable, Equatable { return 0.25 + effectiveTouchpadSensitivity * 1.5 } + /// Converts 0-1 sensitivity to actual multiplier for scroll + var scrollMultiplier: Double { + // Map 0-1 to 1-30 range + return 1.0 + pow(scrollSensitivity, 1.5) * 29.0 + } + + /// Converts 0-1 acceleration to exponent value + var mouseAccelerationExponent: Double { + // Map 0-1 to 1.0-3.0 (1.0 = linear, higher = more acceleration) + return 1.0 + mouseAcceleration * 2.0 + } + /// Converts 0-1 acceleration to exponent value var touchpadAccelerationExponent: Double { // Map 0-1 to 1.0-3.0 (1.0 = linear, higher = more acceleration) return 1.0 + effectiveTouchpadAcceleration * 2.0 } + + /// Converts 0-1 acceleration to exponent value + var scrollAccelerationExponent: Double { + // Map 0-1 to 1.0-2.5 + return 1.0 + scrollAcceleration * 1.5 + } } -// MARK: - Custom Codable (per-stick migration + global field defaults) +// MARK: - Custom Codable (handles new fields with defaults) extension JoystickSettings { enum CodingKeys: String, CodingKey { - // New per-stick representation. - case leftStick - case rightStick - // Global fields. + case mouseSensitivity + case scrollSensitivity + case mouseDeadzone + case scrollDeadzone + case invertMouseY + case invertScrollY + case mouseAcceleration case touchpadSensitivity case touchpadAcceleration case touchpadDeadzone @@ -553,31 +324,10 @@ extension JoystickSettings { case touchpadZoomToPanRatio case touchpadUseNativeZoom case disableTouchpadAsMouse + case scrollAcceleration case scrollBoostMultiplier case focusModeSensitivity case focusModeModifier - case analogPrecisionTriggerMode - case analogPrecisionMinimumSpeed - case analogPrecisionDeadzone - case analogPrecisionCurve - case gyroAimingEnabled - case gyroAimingSensitivity - case gyroAimingDeadzone - case pointerLockMouseMode - case gestureSensitivity - case gestureCooldown - case ouraMotion - // Legacy function-keyed per-stick fields — decoded only when the new - // `leftStick`/`rightStick` keys are absent, and re-encoded for downgrade - // safety (see encode). - case mouseSensitivity - case scrollSensitivity - case mouseDeadzone - case scrollDeadzone - case invertMouseY - case invertScrollY - case mouseAcceleration - case scrollAcceleration case leftStickMode case rightStickMode case leftStickCustomHorizontalSliceSize @@ -594,12 +344,23 @@ extension JoystickSettings { case leftStickVerticalAxisRange // Legacy axis UI, migrated as vertical slice size. case rightStickHorizontalAxisRange // Legacy axis UI, migrated as horizontal slice size. case rightStickVerticalAxisRange // Legacy axis UI, migrated as vertical slice size. + case gyroAimingEnabled + case gyroAimingSensitivity + case gyroAimingDeadzone + case gestureSensitivity + case gestureCooldown } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let unit = 0.0...1.0 - + mouseSensitivity = try container.decode(.mouseSensitivity, default: 0.5, clampedTo: unit) + scrollSensitivity = try container.decode(.scrollSensitivity, default: 0.5, clampedTo: unit) + mouseDeadzone = try container.decode(.mouseDeadzone, default: 0.15, clampedTo: 0.0...0.99) + scrollDeadzone = try container.decode(.scrollDeadzone, default: 0.15, clampedTo: 0.0...0.99) + invertMouseY = try container.decode(.invertMouseY, default: false) + invertScrollY = try container.decode(.invertScrollY, default: false) + mouseAcceleration = try container.decode(.mouseAcceleration, default: 0.5, clampedTo: unit) touchpadSensitivity = try container.decode(.touchpadSensitivity, default: 0.5, clampedTo: unit) touchpadAcceleration = try container.decode(.touchpadAcceleration, default: 0.5, clampedTo: unit) touchpadDeadzone = try container.decode(.touchpadDeadzone, default: Self.defaultTouchpadDeadzone, clampedTo: 0.0...0.03) @@ -613,51 +374,90 @@ extension JoystickSettings { clampedTo: unit ) touchpadInvertScrollX = try container.decode(.touchpadInvertScrollX, default: false) - let legacyInvertScrollY = try container.decode(.invertScrollY, default: false) - touchpadInvertScrollY = try container.decode(.touchpadInvertScrollY, default: legacyInvertScrollY) + touchpadInvertScrollY = try container.decode(.touchpadInvertScrollY, default: invertScrollY) touchpadZoomToPanRatio = try container.decode(.touchpadZoomToPanRatio, default: 1.95, clampedTo: 0.5...5.0) touchpadUseNativeZoom = try container.decode(.touchpadUseNativeZoom, default: true) disableTouchpadAsMouse = try container.decode(.disableTouchpadAsMouse, default: false) + scrollAcceleration = try container.decode(.scrollAcceleration, default: 0.5, clampedTo: unit) scrollBoostMultiplier = try container.decode(.scrollBoostMultiplier, default: 2.0, clampedTo: 1.0...4.0) focusModeSensitivity = try container.decode(.focusModeSensitivity, default: 0.2, clampedTo: unit) focusModeModifier = try container.decode(.focusModeModifier, default: .command) - analogPrecisionTriggerMode = try container.decodeLenient( - .analogPrecisionTriggerMode, - default: AnalogPrecisionTriggerMode.off - ) - analogPrecisionMinimumSpeed = try container.decode( - .analogPrecisionMinimumSpeed, - default: 0.15, - clampedTo: 0.05...1.0 - ) - analogPrecisionDeadzone = try container.decode(.analogPrecisionDeadzone, default: 0.05, clampedTo: 0.0...0.95) - analogPrecisionCurve = try container.decode(.analogPrecisionCurve, default: 0.35, clampedTo: unit) + // Lenient: a StickMode case added by a newer build must degrade to the + // default on a downgrade rather than throwing out the whole config. + leftStickMode = try container.decodeLenient(.leftStickMode, default: .mouse) + rightStickMode = try container.decodeLenient(.rightStickMode, default: .scroll) + let leftLegacySharedSize = Self.sliceSize( + fromLegacyDeadzone: try container.decodeIfPresent(Double.self, forKey: .leftStickCustomSliceDeadzone) + ) + let rightLegacySharedSize = Self.sliceSize( + fromLegacyDeadzone: try container.decodeIfPresent(Double.self, forKey: .rightStickCustomSliceDeadzone) + ) + let leftLegacyHorizontalSize = try container.decode( + .leftStickHorizontalAxisRange, + default: leftLegacySharedSize, + clampedTo: unit + ) + let leftLegacyVerticalSize = try container.decode( + .leftStickVerticalAxisRange, + default: leftLegacySharedSize, + clampedTo: unit + ) + let rightLegacyHorizontalSize = try container.decode( + .rightStickHorizontalAxisRange, + default: rightLegacySharedSize, + clampedTo: unit + ) + let rightLegacyVerticalSize = try container.decode( + .rightStickVerticalAxisRange, + default: rightLegacySharedSize, + clampedTo: unit + ) + leftStickCustomHorizontalSliceSize = try container.decode( + .leftStickCustomHorizontalSliceSize, + default: leftLegacyHorizontalSize, + clampedTo: unit + ) + leftStickCustomVerticalSliceSize = try container.decode( + .leftStickCustomVerticalSliceSize, + default: leftLegacyVerticalSize, + clampedTo: unit + ) + leftStickCustomDeadzone = try container.decode( + .leftStickCustomDeadzone, + default: container.contains(.mouseDeadzone) ? mouseDeadzone : Self.defaultCustomDeadzone, + clampedTo: unit + ) + rightStickCustomHorizontalSliceSize = try container.decode( + .rightStickCustomHorizontalSliceSize, + default: rightLegacyHorizontalSize, + clampedTo: unit + ) + rightStickCustomVerticalSliceSize = try container.decode( + .rightStickCustomVerticalSliceSize, + default: rightLegacyVerticalSize, + clampedTo: unit + ) + rightStickCustomDeadzone = try container.decode( + .rightStickCustomDeadzone, + default: container.contains(.scrollDeadzone) ? scrollDeadzone : Self.defaultCustomDeadzone, + clampedTo: unit + ) gyroAimingEnabled = try container.decode(.gyroAimingEnabled, default: false) gyroAimingSensitivity = try container.decode(.gyroAimingSensitivity, default: 0.3, clampedTo: unit) gyroAimingDeadzone = try container.decode(.gyroAimingDeadzone, default: 0.3, clampedTo: unit) - // Lenient: a mode added by a newer build degrades to .auto on downgrade. - pointerLockMouseMode = try container.decodeLenient(.pointerLockMouseMode, default: PointerLockMouseMode.auto) gestureSensitivity = try container.decode(.gestureSensitivity, default: 0.5, clampedTo: unit) gestureCooldown = try container.decode(.gestureCooldown, default: 0.5, clampedTo: unit) - ouraMotion = try container.decode(.ouraMotion, default: .default) - - // Per-stick tuning: prefer the new nested representation; otherwise migrate - // from the legacy function-keyed flat fields, fanning the shared mouse/scroll - // values into BOTH sticks so old configs keep identical behavior while - // becoming independently editable. - let migrated = try Self.migrateLegacyStickTunings(from: container) - leftStick = try container.decodeIfPresent(StickTuning.self, forKey: .leftStick) ?? migrated.left - rightStick = try container.decodeIfPresent(StickTuning.self, forKey: .rightStick) ?? migrated.right } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - - // New canonical per-stick representation. - try container.encode(leftStick, forKey: .leftStick) - try container.encode(rightStick, forKey: .rightStick) - - // Global fields. + try container.encode(mouseSensitivity, forKey: .mouseSensitivity) + try container.encode(scrollSensitivity, forKey: .scrollSensitivity) + try container.encode(mouseDeadzone, forKey: .mouseDeadzone) + try container.encode(scrollDeadzone, forKey: .scrollDeadzone) + try container.encode(invertMouseY, forKey: .invertMouseY) + try container.encode(invertScrollY, forKey: .invertScrollY) + try container.encode(mouseAcceleration, forKey: .mouseAcceleration) try container.encode(touchpadSensitivity, forKey: .touchpadSensitivity) try container.encode(touchpadAcceleration, forKey: .touchpadAcceleration) try container.encode(touchpadDeadzone, forKey: .touchpadDeadzone) @@ -671,118 +471,23 @@ extension JoystickSettings { try container.encode(touchpadZoomToPanRatio, forKey: .touchpadZoomToPanRatio) try container.encode(touchpadUseNativeZoom, forKey: .touchpadUseNativeZoom) try container.encode(disableTouchpadAsMouse, forKey: .disableTouchpadAsMouse) + try container.encode(scrollAcceleration, forKey: .scrollAcceleration) try container.encode(scrollBoostMultiplier, forKey: .scrollBoostMultiplier) try container.encode(focusModeSensitivity, forKey: .focusModeSensitivity) try container.encode(focusModeModifier, forKey: .focusModeModifier) - try container.encode(analogPrecisionTriggerMode, forKey: .analogPrecisionTriggerMode) - try container.encode(analogPrecisionMinimumSpeed, forKey: .analogPrecisionMinimumSpeed) - try container.encode(analogPrecisionDeadzone, forKey: .analogPrecisionDeadzone) - try container.encode(analogPrecisionCurve, forKey: .analogPrecisionCurve) + try container.encode(leftStickMode, forKey: .leftStickMode) + try container.encode(rightStickMode, forKey: .rightStickMode) + try container.encode(leftStickCustomHorizontalSliceSize, forKey: .leftStickCustomHorizontalSliceSize) + try container.encode(leftStickCustomVerticalSliceSize, forKey: .leftStickCustomVerticalSliceSize) + try container.encode(leftStickCustomDeadzone, forKey: .leftStickCustomDeadzone) + try container.encode(rightStickCustomHorizontalSliceSize, forKey: .rightStickCustomHorizontalSliceSize) + try container.encode(rightStickCustomVerticalSliceSize, forKey: .rightStickCustomVerticalSliceSize) + try container.encode(rightStickCustomDeadzone, forKey: .rightStickCustomDeadzone) try container.encode(gyroAimingEnabled, forKey: .gyroAimingEnabled) try container.encode(gyroAimingSensitivity, forKey: .gyroAimingSensitivity) try container.encode(gyroAimingDeadzone, forKey: .gyroAimingDeadzone) - try container.encode(pointerLockMouseMode, forKey: .pointerLockMouseMode) try container.encode(gestureSensitivity, forKey: .gestureSensitivity) try container.encode(gestureCooldown, forKey: .gestureCooldown) - try container.encode(ouraMotion, forKey: .ouraMotion) - - // Legacy compatibility: keep the old flat fields populated so a profile - // saved/exported by this build still loads sane values on a pre-per-stick - // build (left = conventional mouse stick, right = conventional scroll stick). - // A newer build ignores these and reads leftStick/rightStick instead. - try container.encode(leftStick.mouseSensitivity, forKey: .mouseSensitivity) - try container.encode(leftStick.mouseAcceleration, forKey: .mouseAcceleration) - try container.encode(leftStick.mouseDeadzone, forKey: .mouseDeadzone) - try container.encode(leftStick.invertMouseY, forKey: .invertMouseY) - try container.encode(rightStick.scrollSensitivity, forKey: .scrollSensitivity) - try container.encode(rightStick.scrollAcceleration, forKey: .scrollAcceleration) - try container.encode(rightStick.scrollDeadzone, forKey: .scrollDeadzone) - try container.encode(rightStick.invertScrollY, forKey: .invertScrollY) - try container.encode(leftStick.mode, forKey: .leftStickMode) - try container.encode(rightStick.mode, forKey: .rightStickMode) - try container.encode(leftStick.customHorizontalSliceSize, forKey: .leftStickCustomHorizontalSliceSize) - try container.encode(leftStick.customVerticalSliceSize, forKey: .leftStickCustomVerticalSliceSize) - try container.encode(leftStick.customDeadzone, forKey: .leftStickCustomDeadzone) - try container.encode(rightStick.customHorizontalSliceSize, forKey: .rightStickCustomHorizontalSliceSize) - try container.encode(rightStick.customVerticalSliceSize, forKey: .rightStickCustomVerticalSliceSize) - try container.encode(rightStick.customDeadzone, forKey: .rightStickCustomDeadzone) - } - - /// Rebuilds per-stick tuning from the legacy function-keyed fields. Both sticks - /// inherit the same legacy mouse* and scroll* values (which were shared before), - /// plus their own mode and custom slices. - private static func migrateLegacyStickTunings( - from container: KeyedDecodingContainer - ) throws -> (left: StickTuning, right: StickTuning) { - let unit = 0.0...1.0 - let mouseSensitivity = try container.decode(.mouseSensitivity, default: 0.5, clampedTo: unit) - let scrollSensitivity = try container.decode(.scrollSensitivity, default: 0.5, clampedTo: unit) - let mouseDeadzone = try container.decode(.mouseDeadzone, default: 0.15, clampedTo: 0.0...0.99) - let scrollDeadzone = try container.decode(.scrollDeadzone, default: 0.15, clampedTo: 0.0...0.99) - let invertMouseY = try container.decode(.invertMouseY, default: false) - let invertScrollY = try container.decode(.invertScrollY, default: false) - let mouseAcceleration = try container.decode(.mouseAcceleration, default: 0.5, clampedTo: unit) - let scrollAcceleration = try container.decode(.scrollAcceleration, default: 0.5, clampedTo: unit) - // Lenient: a StickMode case added by a newer build degrades to the default - // on downgrade rather than throwing out the whole config. - let leftMode = try container.decodeLenient(.leftStickMode, default: StickMode.mouse) - let rightMode = try container.decodeLenient(.rightStickMode, default: StickMode.scroll) - - let leftLegacySharedSize = sliceSize( - fromLegacyDeadzone: try container.decodeIfPresent(Double.self, forKey: .leftStickCustomSliceDeadzone) - ) - let rightLegacySharedSize = sliceSize( - fromLegacyDeadzone: try container.decodeIfPresent(Double.self, forKey: .rightStickCustomSliceDeadzone) - ) - let leftLegacyHorizontalSize = try container.decode(.leftStickHorizontalAxisRange, default: leftLegacySharedSize, clampedTo: unit) - let leftLegacyVerticalSize = try container.decode(.leftStickVerticalAxisRange, default: leftLegacySharedSize, clampedTo: unit) - let rightLegacyHorizontalSize = try container.decode(.rightStickHorizontalAxisRange, default: rightLegacySharedSize, clampedTo: unit) - let rightLegacyVerticalSize = try container.decode(.rightStickVerticalAxisRange, default: rightLegacySharedSize, clampedTo: unit) - - let leftCustomHorizontal = try container.decode(.leftStickCustomHorizontalSliceSize, default: leftLegacyHorizontalSize, clampedTo: unit) - let leftCustomVertical = try container.decode(.leftStickCustomVerticalSliceSize, default: leftLegacyVerticalSize, clampedTo: unit) - let leftCustomDeadzone = try container.decode( - .leftStickCustomDeadzone, - default: container.contains(.mouseDeadzone) ? mouseDeadzone : Self.defaultCustomDeadzone, - clampedTo: unit - ) - let rightCustomHorizontal = try container.decode(.rightStickCustomHorizontalSliceSize, default: rightLegacyHorizontalSize, clampedTo: unit) - let rightCustomVertical = try container.decode(.rightStickCustomVerticalSliceSize, default: rightLegacyVerticalSize, clampedTo: unit) - let rightCustomDeadzone = try container.decode( - .rightStickCustomDeadzone, - default: container.contains(.scrollDeadzone) ? scrollDeadzone : Self.defaultCustomDeadzone, - clampedTo: unit - ) - - let left = StickTuning( - mode: leftMode, - mouseSensitivity: mouseSensitivity, - mouseAcceleration: mouseAcceleration, - mouseDeadzone: mouseDeadzone, - invertMouseY: invertMouseY, - scrollSensitivity: scrollSensitivity, - scrollAcceleration: scrollAcceleration, - scrollDeadzone: scrollDeadzone, - invertScrollY: invertScrollY, - customHorizontalSliceSize: leftCustomHorizontal, - customVerticalSliceSize: leftCustomVertical, - customDeadzone: leftCustomDeadzone - ) - let right = StickTuning( - mode: rightMode, - mouseSensitivity: mouseSensitivity, - mouseAcceleration: mouseAcceleration, - mouseDeadzone: mouseDeadzone, - invertMouseY: invertMouseY, - scrollSensitivity: scrollSensitivity, - scrollAcceleration: scrollAcceleration, - scrollDeadzone: scrollDeadzone, - invertScrollY: invertScrollY, - customHorizontalSliceSize: rightCustomHorizontal, - customVerticalSliceSize: rightCustomVertical, - customDeadzone: rightCustomDeadzone - ) - return (left, right) } private static func sliceSize(fromLegacyDeadzone deadzone: Double?) -> Double { diff --git a/XboxControllerMapper/XboxControllerMapper/Models/Layer.swift b/XboxControllerMapper/XboxControllerMapper/Models/Layer.swift index 5e1dddd3..773ef4a6 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/Layer.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/Layer.swift @@ -50,13 +50,11 @@ struct Layer: Codable, Identifiable, Equatable { /// Optional LED settings applied when this layer is active (nil = inherit profile settings) var dualSenseLEDSettings: DualSenseLEDSettings? - /// Per-stick tuning overrides applied when this layer is active. - /// nil = inherit the profile-level `JoystickSettings.leftStick` / `rightStick`. - /// A non-nil override only changes the fields it explicitly sets (mode, - /// sensitivity, acceleration, deadzone, …); unset fields fall through to the - /// base stick — same transparency model as button mappings. - var leftStickTuning: StickTuningOverride? - var rightStickTuning: StickTuningOverride? + /// Per-stick-mode overrides applied when this layer is active. + /// nil = inherit the profile-level `JoystickSettings.leftStickMode` / `rightStickMode`. + /// Other joystick tuning (sensitivity, deadzone, acceleration) stays profile-level. + var leftStickModeOverride: StickMode? + var rightStickModeOverride: StickMode? init( id: UUID = UUID(), @@ -64,25 +62,22 @@ struct Layer: Codable, Identifiable, Equatable { activatorButton: ControllerButton? = nil, buttonMappings: [ControllerButton: KeyMapping] = [:], dualSenseLEDSettings: DualSenseLEDSettings? = nil, - leftStickTuning: StickTuningOverride? = nil, - rightStickTuning: StickTuningOverride? = nil + leftStickModeOverride: StickMode? = nil, + rightStickModeOverride: StickMode? = nil ) { self.id = id self.name = name self.activatorButton = activatorButton self.buttonMappings = buttonMappings self.dualSenseLEDSettings = dualSenseLEDSettings - self.leftStickTuning = leftStickTuning - self.rightStickTuning = rightStickTuning + self.leftStickModeOverride = leftStickModeOverride + self.rightStickModeOverride = rightStickModeOverride } // MARK: - Custom Codable private enum CodingKeys: String, CodingKey { case id, name, activatorButton, buttonMappings, dualSenseLEDSettings - case leftStickTuning, rightStickTuning - // Legacy mode-only overrides (pre per-stick tuning) — migrated on decode, - // re-encoded for downgrade safety. case leftStickModeOverride, rightStickModeOverride } @@ -102,22 +97,10 @@ struct Layer: Codable, Identifiable, Equatable { }) dualSenseLEDSettings = try container.decodeIfPresent(DualSenseLEDSettings.self, forKey: .dualSenseLEDSettings) - // Prefer the new per-stick tuning override; otherwise migrate the legacy - // mode-only override into a tuning override carrying just the mode. - // Lenient: an unknown StickMode raw value (newer build) falls back to nil - // ("inherit") instead of throwing out the layer. - leftStickTuning = try container.decodeIfPresent(StickTuningOverride.self, forKey: .leftStickTuning) - ?? Self.migrateLegacyModeOverride(try container.decodeLenient(.leftStickModeOverride)) - rightStickTuning = try container.decodeIfPresent(StickTuningOverride.self, forKey: .rightStickTuning) - ?? Self.migrateLegacyModeOverride(try container.decodeLenient(.rightStickModeOverride)) - } - - /// Wraps a legacy mode-only override into a full tuning override (or nil). - private static func migrateLegacyModeOverride(_ mode: StickMode?) -> StickTuningOverride? { - guard let mode else { return nil } - var override = StickTuningOverride() - override.mode = mode - return override + // Lenient: an unknown StickMode raw value (e.g. from a newer build) + // falls back to nil ("inherit") instead of throwing out the layer. + leftStickModeOverride = try container.decodeLenient(.leftStickModeOverride) + rightStickModeOverride = try container.decodeLenient(.rightStickModeOverride) } func encode(to encoder: Encoder) throws { @@ -132,11 +115,7 @@ struct Layer: Codable, Identifiable, Equatable { try container.encode(stringKeyedMappings, forKey: .buttonMappings) try container.encodeIfPresent(dualSenseLEDSettings, forKey: .dualSenseLEDSettings) - try container.encodeIfPresent(leftStickTuning, forKey: .leftStickTuning) - try container.encodeIfPresent(rightStickTuning, forKey: .rightStickTuning) - // Downgrade safety: also write the legacy mode-only override so a - // pre-per-stick build still honors a layer's stick-mode change. - try container.encodeIfPresent(leftStickTuning?.mode, forKey: .leftStickModeOverride) - try container.encodeIfPresent(rightStickTuning?.mode, forKey: .rightStickModeOverride) + try container.encodeIfPresent(leftStickModeOverride, forKey: .leftStickModeOverride) + try container.encodeIfPresent(rightStickModeOverride, forKey: .rightStickModeOverride) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Models/Profile.swift b/XboxControllerMapper/XboxControllerMapper/Models/Profile.swift index 56776868..e193f1c1 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/Profile.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/Profile.swift @@ -554,11 +554,6 @@ struct Profile: Codable, Identifiable, Equatable { mappings[.rightTouchpadButton] = KeyMapping(keyCode: KeyCodeMapping.mouseLeftClick, isHoldModifier: true) mappings[.rightTouchpadTap] = KeyMapping(keyCode: KeyCodeMapping.mouseLeftClick, isHoldModifier: true) - // Oura Ring tap gestures. Single tap behaves like the default click; - // multi-tap gestures keep calibration controls editable. - mappings[.ouraTap] = KeyMapping(keyCode: KeyCodeMapping.mouseLeftClick, isHoldModifier: true) - applyOuraGestureDefaults(to: &mappings) - // Chord mappings let chords: [ChordMapping] = [ // RB + X = Cmd+Delete @@ -586,13 +581,19 @@ struct Profile: Codable, Identifiable, Equatable { // Joystick settings tuned for comfortable use let joystick = JoystickSettings( - leftStick: .leftDefault, - rightStick: .rightDefault, + mouseSensitivity: 0.5, + scrollSensitivity: 0.5, + mouseDeadzone: 0.15, + scrollDeadzone: 0.15, + invertMouseY: false, + invertScrollY: false, + mouseAcceleration: 0.5, touchpadSensitivity: 0.5, touchpadAcceleration: 0.5, touchpadDeadzone: JoystickSettings.defaultTouchpadDeadzone, touchpadSmoothing: 0.4, touchpadPanSensitivity: 0.5, + scrollAcceleration: 0.5, scrollBoostMultiplier: 4.0, focusModeSensitivity: 0.1, focusModeModifier: .command @@ -607,15 +608,6 @@ struct Profile: Codable, Identifiable, Equatable { joystickSettings: joystick ) } - - private static func applyOuraGestureDefaults(to mappings: inout [ControllerButton: KeyMapping]) { - if mappings[.ouraDoubleTap] == nil { - mappings[.ouraDoubleTap] = KeyMapping(systemCommand: .centerOuraRing, hint: "Center Ring") - } - if mappings[.ouraTripleTap] == nil { - mappings[.ouraTripleTap] = KeyMapping(systemCommand: .toggleOuraMotion, hint: "Toggle Ring Mouse") - } - } } // MARK: - Custom Codable for Dictionary with enum keys @@ -663,7 +655,6 @@ extension Profile { } return (button, value) }) - Self.applyOuraGestureDefaults(to: &buttonMappings) dpadPreset = try container.decode(.dpadPreset, default: DPadPreset.resolved(from: buttonMappings)) chordMappings = try container.decode(.chordMappings, default: []) @@ -798,107 +789,6 @@ extension Profile { } } -// MARK: - Oura Ring Motion Output - -extension Profile { - var ouraMotionOutputMode: OuraMotionOutputMode { - guard joystickSettings.ouraMotion.motionOutputEnabled else { return .off } - if let outputMode = joystickSettings.ouraMotion.outputMode, - ouraMotionOutputModeMatchesCurrentRouting(outputMode) { - return outputMode - } - - let mode = joystickSettings.inferredOuraMotionOutputMode - guard mode == .custom else { return mode } - return inferredCustomDirectionPresetOutputMode ?? .custom - } - - mutating func reconcileOuraMotionOutputModeWithCurrentRouting() { - guard joystickSettings.ouraMotion.motionOutputEnabled, - let outputMode = joystickSettings.ouraMotion.outputMode, - !ouraMotionOutputModeMatchesCurrentRouting(outputMode) else { - return - } - - let inferred = joystickSettings.inferredOuraMotionOutputMode - joystickSettings.ouraMotion.outputMode = inferred == .custom - ? inferredCustomDirectionPresetOutputMode ?? .custom - : inferred == .off ? nil : inferred - } - - private var inferredCustomDirectionPresetOutputMode: OuraMotionOutputMode? { - switch StickDirectionPreset.resolved( - from: buttonMappings, - side: joystickSettings.ouraMotion.targetStick - ) { - case .some(.arrows): - return .arrowKeys - case .some(.wasd): - return .wasdKeys - case nil: - return nil - } - } - - private func ouraMotionOutputModeMatchesCurrentRouting(_ mode: OuraMotionOutputMode) -> Bool { - switch mode { - case .arrowKeys: - return ouraMotionUsesLeftCustomPreset(.arrows) || - joystickSettings.ouraMotionOutputModeMatchesCurrentRouting(mode) - case .wasdKeys: - return ouraMotionUsesLeftCustomPreset(.wasd) || - joystickSettings.ouraMotionOutputModeMatchesCurrentRouting(mode) - case .mouse, .scroll, .custom, .dpad, .off: - return joystickSettings.ouraMotionOutputModeMatchesCurrentRouting(mode) - } - } - - private func ouraMotionUsesLeftCustomPreset(_ preset: StickDirectionPreset) -> Bool { - joystickSettings.ouraMotion.motionOutputEnabled && - joystickSettings.ouraMotion.targetStick == .left && - joystickSettings.leftStick.mode == .custom && - StickDirectionPreset.resolved(from: buttonMappings, side: .left) == preset - } - - mutating func setOuraMotionOutputMode(_ mode: OuraMotionOutputMode) { - joystickSettings.setOuraMotionOutputMode(mode) - - switch mode { - case .arrowKeys: - joystickSettings.leftStick.mode = .custom - joystickSettings.leftStick.customDeadzone = joystickSettings.leftStick.mouseDeadzone - StickDirectionPreset.arrows.apply(to: &buttonMappings, side: .left) - case .wasdKeys: - joystickSettings.leftStick.mode = .custom - joystickSettings.leftStick.customDeadzone = joystickSettings.leftStick.mouseDeadzone - StickDirectionPreset.wasd.apply(to: &buttonMappings, side: .left) - case .custom: - joystickSettings.leftStick.mode = .custom - case .mouse, .scroll, .dpad, .off: - break - } - } - - mutating func updateOuraMotionOutputModeIfNeeded(afterChanging button: ControllerButton) { - guard joystickSettings.ouraMotion.targetStick == .left, - ControllerButton.joystickDirectionButtons(side: .left).contains(button), - joystickSettings.ouraMotion.outputMode == .arrowKeys || - joystickSettings.ouraMotion.outputMode == .wasdKeys else { - return - } - - joystickSettings.leftStick.mode = .custom - switch StickDirectionPreset.resolved(from: buttonMappings, side: .left) { - case .some(.arrows): - joystickSettings.ouraMotion.outputMode = .arrowKeys - case .some(.wasd): - joystickSettings.ouraMotion.outputMode = .wasdKeys - case nil: - joystickSettings.ouraMotion.outputMode = .custom - } - } -} - // MARK: - Custom Equatable (excludes timestamps) extension Profile { @@ -908,17 +798,23 @@ extension Profile { } private mutating func migrateLegacyStickKeyMode(side: JoystickSide) { - let mode = joystickSettings.stick(side).mode + let mode: StickMode + switch side { + case .left: + mode = joystickSettings.leftStickMode + case .right: + mode = joystickSettings.rightStickMode + } guard mode == .wasdKeys || mode == .arrowKeys else { return } switch side { case .left: - joystickSettings.leftStick.mode = .custom - joystickSettings.leftStick.customDeadzone = joystickSettings.leftStick.mouseDeadzone + joystickSettings.leftStickMode = .custom + joystickSettings.leftStickCustomDeadzone = joystickSettings.mouseDeadzone case .right: - joystickSettings.rightStick.mode = .custom - joystickSettings.rightStick.customDeadzone = joystickSettings.rightStick.scrollDeadzone + joystickSettings.rightStickMode = .custom + joystickSettings.rightStickCustomDeadzone = joystickSettings.scrollDeadzone } let preset: StickDirectionPreset = mode == .wasdKeys ? .wasd : .arrows diff --git a/XboxControllerMapper/XboxControllerMapper/Models/StickTuning.swift b/XboxControllerMapper/XboxControllerMapper/Models/StickTuning.swift deleted file mode 100644 index e772561e..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Models/StickTuning.swift +++ /dev/null @@ -1,256 +0,0 @@ -import Foundation - -/// Shared analog-stick response curves. Factored out so per-stick `StickTuning` -/// and the global focus-mode multiplier compute identical curves from a 0-1 knob. -enum JoystickCurves { - /// Converts 0-1 sensitivity to a mouse pixel-scale multiplier (cubic curve, 2-120). - static func mouseMultiplier(sensitivity: Double) -> Double { - 2.0 + pow(sensitivity, 3.0) * 118.0 - } - - /// Converts 0-1 sensitivity to a scroll multiplier (1-30). - static func scrollMultiplier(sensitivity: Double) -> Double { - 1.0 + pow(sensitivity, 1.5) * 29.0 - } - - /// Converts 0-1 acceleration to a mouse exponent (1.0 linear … 3.0 max). - static func mouseAccelerationExponent(_ acceleration: Double) -> Double { - 1.0 + acceleration * 2.0 - } - - /// Converts 0-1 acceleration to a scroll exponent (1.0 linear … 2.5 max). - static func scrollAccelerationExponent(_ acceleration: Double) -> Double { - 1.0 + acceleration * 1.5 - } -} - -/// Tuning for a single analog stick. Each physical stick (left/right) owns an -/// independent copy, so the two sticks can have different sensitivity, -/// acceleration, and deadzone even when both drive the mouse. -/// -/// Replaces the old function-keyed fields on `JoystickSettings` -/// (`mouseSensitivity`/`scrollSensitivity`/…), which were shared across both -/// sticks — two mouse-mode sticks could not have different speeds. The legacy -/// fields are migrated into `JoystickSettings.leftStick` / `.rightStick` on -/// decode (see `JoystickSettings.init(from:)`), fanning the same old values into -/// both sticks so existing behavior is preserved exactly while becoming -/// independently editable. -struct StickTuning: Codable, Equatable { - /// What this stick does (mouse / scroll / custom / dpad / wasd / arrows / none). - var mode: StickMode - - // MARK: Mouse-mode knobs - var mouseSensitivity: Double - var mouseAcceleration: Double - var mouseDeadzone: Double - var invertMouseY: Bool - - // MARK: Scroll-mode knobs - var scrollSensitivity: Double - var scrollAcceleration: Double - var scrollDeadzone: Double - var invertScrollY: Bool - - // MARK: Custom-direction knobs - var customHorizontalSliceSize: Double - var customVerticalSliceSize: Double - var customDeadzone: Double - - init( - mode: StickMode, - mouseSensitivity: Double = 0.5, - mouseAcceleration: Double = 0.5, - mouseDeadzone: Double = 0.15, - invertMouseY: Bool = false, - scrollSensitivity: Double = 0.5, - scrollAcceleration: Double = 0.5, - scrollDeadzone: Double = 0.15, - invertScrollY: Bool = false, - customHorizontalSliceSize: Double = JoystickSettings.defaultCustomSliceSize, - customVerticalSliceSize: Double = JoystickSettings.defaultCustomSliceSize, - customDeadzone: Double = JoystickSettings.defaultCustomDeadzone - ) { - self.mode = mode - self.mouseSensitivity = mouseSensitivity - self.mouseAcceleration = mouseAcceleration - self.mouseDeadzone = mouseDeadzone - self.invertMouseY = invertMouseY - self.scrollSensitivity = scrollSensitivity - self.scrollAcceleration = scrollAcceleration - self.scrollDeadzone = scrollDeadzone - self.invertScrollY = invertScrollY - self.customHorizontalSliceSize = customHorizontalSliceSize - self.customVerticalSliceSize = customVerticalSliceSize - self.customDeadzone = customDeadzone - } - - /// Conventional defaults: left stick drives the mouse, right stick scrolls. - static let leftDefault = StickTuning(mode: .mouse) - static let rightDefault = StickTuning(mode: .scroll) - - // MARK: - Derived response values - - var mouseMultiplier: Double { JoystickCurves.mouseMultiplier(sensitivity: mouseSensitivity) } - var mouseAccelerationExponent: Double { JoystickCurves.mouseAccelerationExponent(mouseAcceleration) } - var scrollMultiplier: Double { JoystickCurves.scrollMultiplier(sensitivity: scrollSensitivity) } - var scrollAccelerationExponent: Double { JoystickCurves.scrollAccelerationExponent(scrollAcceleration) } - - func isValid() -> Bool { - let unit = 0.0...1.0 - return unit.contains(mouseSensitivity) && - unit.contains(mouseAcceleration) && - (0.0...0.99).contains(mouseDeadzone) && - unit.contains(scrollSensitivity) && - unit.contains(scrollAcceleration) && - (0.0...0.99).contains(scrollDeadzone) && - unit.contains(customHorizontalSliceSize) && - unit.contains(customVerticalSliceSize) && - unit.contains(customDeadzone) - } - - // MARK: - Codable (lenient: unknown mode → keep, clamp ranges, default missing) - - enum CodingKeys: String, CodingKey { - case mode - case mouseSensitivity, mouseAcceleration, mouseDeadzone, invertMouseY - case scrollSensitivity, scrollAcceleration, scrollDeadzone, invertScrollY - case customHorizontalSliceSize, customVerticalSliceSize, customDeadzone - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let unit = 0.0...1.0 - mode = try container.decodeLenient(.mode, default: .mouse) - mouseSensitivity = try container.decode(.mouseSensitivity, default: 0.5, clampedTo: unit) - mouseAcceleration = try container.decode(.mouseAcceleration, default: 0.5, clampedTo: unit) - mouseDeadzone = try container.decode(.mouseDeadzone, default: 0.15, clampedTo: 0.0...0.99) - invertMouseY = try container.decode(.invertMouseY, default: false) - scrollSensitivity = try container.decode(.scrollSensitivity, default: 0.5, clampedTo: unit) - scrollAcceleration = try container.decode(.scrollAcceleration, default: 0.5, clampedTo: unit) - scrollDeadzone = try container.decode(.scrollDeadzone, default: 0.15, clampedTo: 0.0...0.99) - invertScrollY = try container.decode(.invertScrollY, default: false) - customHorizontalSliceSize = try container.decode( - .customHorizontalSliceSize, default: JoystickSettings.defaultCustomSliceSize, clampedTo: unit) - customVerticalSliceSize = try container.decode( - .customVerticalSliceSize, default: JoystickSettings.defaultCustomSliceSize, clampedTo: unit) - customDeadzone = try container.decode( - .customDeadzone, default: JoystickSettings.defaultCustomDeadzone, clampedTo: unit) - } -} - -/// An optional, per-field override of a `StickTuning`, applied when a layer is -/// active. `nil` field = inherit the profile-level stick tuning; a set field -/// replaces it. Mirrors the layer transparency model used for button mappings: -/// a layer only changes what it explicitly sets, otherwise it falls through to -/// the base profile. -struct StickTuningOverride: Codable, Equatable { - var mode: StickMode? - var mouseSensitivity: Double? - var mouseAcceleration: Double? - var mouseDeadzone: Double? - var invertMouseY: Bool? - var scrollSensitivity: Double? - var scrollAcceleration: Double? - var scrollDeadzone: Double? - var invertScrollY: Bool? - var customHorizontalSliceSize: Double? - var customVerticalSliceSize: Double? - var customDeadzone: Double? - - /// True when nothing is overridden — the layer fully inherits the base stick. - var isEmpty: Bool { - mode == nil && - mouseSensitivity == nil && mouseAcceleration == nil && mouseDeadzone == nil && invertMouseY == nil && - scrollSensitivity == nil && scrollAcceleration == nil && scrollDeadzone == nil && invertScrollY == nil && - customHorizontalSliceSize == nil && customVerticalSliceSize == nil && customDeadzone == nil - } - - /// Overlays the set fields onto `base`, leaving unset fields inherited. - func applied(to base: StickTuning) -> StickTuning { - var t = base - if let mode { t.mode = mode } - if let mouseSensitivity { t.mouseSensitivity = mouseSensitivity } - if let mouseAcceleration { t.mouseAcceleration = mouseAcceleration } - if let mouseDeadzone { t.mouseDeadzone = mouseDeadzone } - if let invertMouseY { t.invertMouseY = invertMouseY } - if let scrollSensitivity { t.scrollSensitivity = scrollSensitivity } - if let scrollAcceleration { t.scrollAcceleration = scrollAcceleration } - if let scrollDeadzone { t.scrollDeadzone = scrollDeadzone } - if let invertScrollY { t.invertScrollY = invertScrollY } - if let customHorizontalSliceSize { t.customHorizontalSliceSize = customHorizontalSliceSize } - if let customVerticalSliceSize { t.customVerticalSliceSize = customVerticalSliceSize } - if let customDeadzone { t.customDeadzone = customDeadzone } - return t - } - - // MARK: - Codable (lenient mode so a newer build's StickMode never throws out the layer) - - enum CodingKeys: String, CodingKey { - case mode - case mouseSensitivity, mouseAcceleration, mouseDeadzone, invertMouseY - case scrollSensitivity, scrollAcceleration, scrollDeadzone, invertScrollY - case customHorizontalSliceSize, customVerticalSliceSize, customDeadzone - } - - init( - mode: StickMode? = nil, - mouseSensitivity: Double? = nil, - mouseAcceleration: Double? = nil, - mouseDeadzone: Double? = nil, - invertMouseY: Bool? = nil, - scrollSensitivity: Double? = nil, - scrollAcceleration: Double? = nil, - scrollDeadzone: Double? = nil, - invertScrollY: Bool? = nil, - customHorizontalSliceSize: Double? = nil, - customVerticalSliceSize: Double? = nil, - customDeadzone: Double? = nil - ) { - self.mode = mode - self.mouseSensitivity = mouseSensitivity - self.mouseAcceleration = mouseAcceleration - self.mouseDeadzone = mouseDeadzone - self.invertMouseY = invertMouseY - self.scrollSensitivity = scrollSensitivity - self.scrollAcceleration = scrollAcceleration - self.scrollDeadzone = scrollDeadzone - self.invertScrollY = invertScrollY - self.customHorizontalSliceSize = customHorizontalSliceSize - self.customVerticalSliceSize = customVerticalSliceSize - self.customDeadzone = customDeadzone - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - // Lenient: an unknown StickMode raw value (newer build) → nil ("inherit") - // instead of throwing and losing the whole override / layer. - mode = try container.decodeLenient(.mode) - mouseSensitivity = try container.decodeIfPresent(Double.self, forKey: .mouseSensitivity) - mouseAcceleration = try container.decodeIfPresent(Double.self, forKey: .mouseAcceleration) - mouseDeadzone = try container.decodeIfPresent(Double.self, forKey: .mouseDeadzone) - invertMouseY = try container.decodeIfPresent(Bool.self, forKey: .invertMouseY) - scrollSensitivity = try container.decodeIfPresent(Double.self, forKey: .scrollSensitivity) - scrollAcceleration = try container.decodeIfPresent(Double.self, forKey: .scrollAcceleration) - scrollDeadzone = try container.decodeIfPresent(Double.self, forKey: .scrollDeadzone) - invertScrollY = try container.decodeIfPresent(Bool.self, forKey: .invertScrollY) - customHorizontalSliceSize = try container.decodeIfPresent(Double.self, forKey: .customHorizontalSliceSize) - customVerticalSliceSize = try container.decodeIfPresent(Double.self, forKey: .customVerticalSliceSize) - customDeadzone = try container.decodeIfPresent(Double.self, forKey: .customDeadzone) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(mode, forKey: .mode) - try container.encodeIfPresent(mouseSensitivity, forKey: .mouseSensitivity) - try container.encodeIfPresent(mouseAcceleration, forKey: .mouseAcceleration) - try container.encodeIfPresent(mouseDeadzone, forKey: .mouseDeadzone) - try container.encodeIfPresent(invertMouseY, forKey: .invertMouseY) - try container.encodeIfPresent(scrollSensitivity, forKey: .scrollSensitivity) - try container.encodeIfPresent(scrollAcceleration, forKey: .scrollAcceleration) - try container.encodeIfPresent(scrollDeadzone, forKey: .scrollDeadzone) - try container.encodeIfPresent(invertScrollY, forKey: .invertScrollY) - try container.encodeIfPresent(customHorizontalSliceSize, forKey: .customHorizontalSliceSize) - try container.encodeIfPresent(customVerticalSliceSize, forKey: .customVerticalSliceSize) - try container.encodeIfPresent(customDeadzone, forKey: .customDeadzone) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Models/SystemCommand.swift b/XboxControllerMapper/XboxControllerMapper/Models/SystemCommand.swift index 8282461d..2cda0d98 100644 --- a/XboxControllerMapper/XboxControllerMapper/Models/SystemCommand.swift +++ b/XboxControllerMapper/XboxControllerMapper/Models/SystemCommand.swift @@ -83,7 +83,6 @@ enum SystemCommandCategory: String, CaseIterable, Identifiable { case shell = "Shell Command" case app = "Launch App" case link = "Open Link" - case ring = "Ring Control" case webhook = "Webhook" case obs = "OBS WebSocket" @@ -96,56 +95,12 @@ enum SystemCommandCategory: String, CaseIterable, Identifiable { case .shell: return "Shell" case .app: return "App" case .link: return "Link" - case .ring: return "Ring" case .webhook: return "Webhook" case .obs: return "OBS" } } } -enum OuraRingSystemCommandOption: String, CaseIterable, Identifiable { - case centerRing - case toggleMouseControl - - var id: String { rawValue } - - var displayName: String { - switch self { - case .centerRing: - return "Center Ring" - case .toggleMouseControl: - return "Toggle Ring Mouse Control" - } - } - - var helpText: String { - switch self { - case .centerRing: - return "Recalibrates ring motion at the current finger angle." - case .toggleMouseControl: - return "Starts or stops Oura Ring motion output." - } - } - - var systemCommand: SystemCommand { - switch self { - case .centerRing: - return .centerOuraRing - case .toggleMouseControl: - return .toggleOuraMotion - } - } - - init(command: SystemCommand) { - switch command { - case .toggleOuraMotion: - self = .toggleMouseControl - default: - self = .centerRing - } - } -} - /// Represents a system-level command that can be triggered by a button or chord mapping enum SystemCommand: Equatable { // Profile switching @@ -166,10 +121,6 @@ enum SystemCommand: Equatable { // OBS WebSocket request (generic requestType + optional requestData JSON) case obsWebSocket(url: String, password: String? = nil, requestType: String, requestData: String? = nil) - // Oura Ring control commands - case centerOuraRing - case toggleOuraMotion - /// Human-readable display name for the UI var displayName: String { switch self { @@ -208,10 +159,6 @@ enum SystemCommand: Equatable { return String(display.prefix(30)) + "..." } return display - case .centerOuraRing: - return "Center Ring" - case .toggleOuraMotion: - return "Toggle Ring Mouse Control" } } @@ -222,7 +169,6 @@ enum SystemCommand: Equatable { case .launchApp: return .app case .shellCommand: return .shell case .openLink: return .link - case .centerOuraRing, .toggleOuraMotion: return .ring case .httpRequest: return .webhook case .obsWebSocket: return .obs } @@ -234,7 +180,6 @@ enum SystemCommand: Equatable { extension SystemCommand: Codable { private enum CommandType: String, Codable { case switchProfile, launchApp, shellCommand, openLink, httpRequest, obsWebSocket - case centerOuraRing, toggleOuraMotion } private enum CodingKeys: String, CodingKey { @@ -284,10 +229,6 @@ extension SystemCommand: Codable { let requestType: String = try container.decode(.requestType, default: "") let requestData = try container.decodeIfPresent(String.self, forKey: .requestData) self = .obsWebSocket(url: url, password: password, requestType: requestType, requestData: requestData) - case .centerOuraRing: - self = .centerOuraRing - case .toggleOuraMotion: - self = .toggleOuraMotion } } @@ -335,10 +276,6 @@ extension SystemCommand: Codable { } try container.encode(requestType, forKey: .requestType) try container.encodeIfPresent(requestData, forKey: .requestData) - case .centerOuraRing: - try container.encode(CommandType.centerOuraRing, forKey: .type) - case .toggleOuraMotion: - try container.encode(CommandType.toggleOuraMotion, forKey: .type) } } } diff --git a/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Data/com.apple.CoreML/model.mlmodel b/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Data/com.apple.CoreML/model.mlmodel deleted file mode 100644 index c43db385..00000000 Binary files a/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Data/com.apple.CoreML/model.mlmodel and /dev/null differ diff --git a/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Data/com.apple.CoreML/weights/weight.bin b/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Data/com.apple.CoreML/weights/weight.bin deleted file mode 100644 index fefd6406..00000000 Binary files a/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Data/com.apple.CoreML/weights/weight.bin and /dev/null differ diff --git a/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Manifest.json b/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Manifest.json deleted file mode 100644 index e58bb8f8..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Resources/OuraGestureClassifier.mlpackage/Manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "fileFormatVersion": "1.0.0", - "itemInfoEntries": { - "485A19C6-E8A5-4F41-B34F-0D2917DCFCEA": { - "author": "com.apple.CoreML", - "description": "CoreML Model Weights", - "name": "weights", - "path": "com.apple.CoreML/weights" - }, - "865228C0-AA98-4A23-80EC-AF48970D8B5E": { - "author": "com.apple.CoreML", - "description": "CoreML Model Specification", - "name": "model.mlmodel", - "path": "com.apple.CoreML/model.mlmodel" - } - }, - "rootModelIdentifier": "865228C0-AA98-4A23-80EC-AF48970D8B5E" -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+AppleTVRemoteHID.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+AppleTVRemoteHID.swift index 42a5858d..73b3bf78 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+AppleTVRemoteHID.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+AppleTVRemoteHID.swift @@ -892,7 +892,15 @@ extension ControllerService { } storage.lock.lock() - storage.applyControllerTypeLocked(.appleTVRemote) + storage.isAppleTVRemote = true + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isNintendo = false + storage.isXboxElite = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isSteamController = false storage.lock.unlock() UserDefaults.standard.set(true, forKey: Config.lastControllerWasAppleTVRemoteKey) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+EightBitDoHID.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+EightBitDoHID.swift index 839a5699..2c6f2640 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+EightBitDoHID.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+EightBitDoHID.swift @@ -59,12 +59,20 @@ extension ControllerService { private static let eightBitDoHIDReportBufferSize = 64 + /// 8BitDo vendor ID (D-input / Android mode). In Switch mode the pads + /// impersonate Nintendo (VID 0x057E) and are handled by the Nintendo path. + private static let eightBitDoVendorID = 0x2DC8 + + /// D-input product IDs for the small pads whose Home/Star the GameController + /// profile omits. Micro 0x9020, Zero 2 0x3230, Lite 2 0x5112. + private static let eightBitDoDInputProductIDs = [microProductID, zero2ProductID, lite2ProductID] + // Report-parsing constants. `nonisolated` so handleEightBitDoHIDReport (which // runs nonisolated, off the HID run loop) can read them without crossing the // MainActor boundary — same pattern as the Nintendo HID dump constants. - nonisolated private static let microProductID = EightBitDoDInputHIDDriverDescriptor.microProductID - nonisolated private static let zero2ProductID = EightBitDoDInputHIDDriverDescriptor.zero2ProductID - nonisolated private static let lite2ProductID = EightBitDoDInputHIDDriverDescriptor.lite2ProductID + nonisolated private static let microProductID = 0x9020 + nonisolated private static let zero2ProductID = 0x3230 + nonisolated private static let lite2ProductID = 0x5112 /// Only the standard input reports carry the button bytes. nonisolated private static let microInputReportID: UInt32 = 0x03 nonisolated private static let lite2InputReportID: UInt32 = 0x01 @@ -93,7 +101,13 @@ extension ControllerService { eightBitDoHIDManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) guard let manager = eightBitDoHIDManager else { return } - IOHIDManagerSetDeviceMatchingMultiple(manager, EightBitDoDInputHIDDriverDescriptor().matchingCFArray) + let matches: [[String: Any]] = Self.eightBitDoDInputProductIDs.map { pid in + [ + kIOHIDVendorIDKey as String: Self.eightBitDoVendorID, + kIOHIDProductIDKey as String: pid, + ] + } + IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) let openResult = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+GenericHID.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+GenericHID.swift index 646f49da..7364ced8 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+GenericHID.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+GenericHID.swift @@ -27,10 +27,43 @@ extension ControllerService { let context = UnsafeMutableRawPointer(retainedContext) genericHIDSetupQueue.async { - let knownMappingCriteria = GameControllerDatabase.shared - .knownVendorProductPairs(excludingVendors: GenericHIDDriverDescriptor.excludedVendorIDs) - let descriptor = GenericHIDDriverDescriptor(knownVendorProductPairs: knownMappingCriteria) - let criteria = descriptor.matchingCFArray + let excludedVendors: Set = [ + 0x045E, // Xbox raw Guide/Elite path + 0x054C, // PlayStation raw PS button path + 0x057E, // Nintendo raw Home button path + SteamControllerHIDParser.valveVendorID, + ] + let knownMappingCriteria = GameControllerDatabase.shared + .knownVendorProductPairs(excludingVendors: excludedVendors) + .map { pair in + [ + kIOHIDVendorIDKey as String: pair.vendorID, + kIOHIDProductIDKey as String: pair.productID, + ] as CFDictionary + } + let standardControllerCriteria = [ + [ + kIOHIDDeviceUsagePageKey as String: kHIDPage_GenericDesktop, + kIOHIDDeviceUsageKey as String: kHIDUsage_GD_Joystick, + ], + [ + kIOHIDDeviceUsagePageKey as String: kHIDPage_GenericDesktop, + kIOHIDDeviceUsageKey as String: kHIDUsage_GD_GamePad, + ], + [ + kIOHIDDeviceUsagePageKey as String: kHIDPage_GenericDesktop, + kIOHIDDeviceUsageKey as String: kHIDUsage_GD_MultiAxisController, + ], + ].map { $0 as CFDictionary } + let bluetoothLECriteria = [ + [kIOHIDTransportKey as String: "BluetoothLowEnergy"], + [kIOHIDTransportKey as String: "Bluetooth Low Energy"], + ].map { $0 as CFDictionary } + let criteria = ( + knownMappingCriteria + + standardControllerCriteria + + bluetoothLECriteria + ) as CFArray guard CFArrayGetCount(criteria) > 0 else { return } IOHIDManagerSetDeviceMatchingMultiple(manager, criteria) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Haptics.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Haptics.swift index faabf58b..5618cbf8 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Haptics.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Haptics.swift @@ -31,66 +31,28 @@ extension ControllerService { } } hapticLock.lock() - hapticSessionGeneration += 1 hapticEngines = newEngines hapticLock.unlock() } func stopHaptics() { - let stop = { [self] in - hapticLock.lock() - hapticSessionGeneration += 1 - let engines = hapticEngines - let players = activeHapticPlayers - hapticEngines.removeAll() - activeHapticPlayers.removeAll() - hapticLock.unlock() - - for player in players { - try? player.player.stop(atTime: CHHapticTimeImmediate) - } - for engine in engines { - engine.stop() - } - } - - if DispatchQueue.getSpecific(key: hapticQueueSpecificKey) != nil { - stop() - } else { - hapticQueue.sync(execute: stop) - } + hapticLock.lock() + let engines = hapticEngines + hapticEngines.removeAll() + activeHapticPlayers.removeAll() + hapticLock.unlock() + for engine in engines { + engine.stop() + } } - nonisolated func currentHapticSessionGeneration() -> UInt64 { - hapticLock.lock() - defer { hapticLock.unlock() } - return hapticSessionGeneration - } - - nonisolated func isCurrentHapticSession(_ generation: UInt64) -> Bool { - currentHapticSessionGeneration() == generation - } - - #if DEBUG - nonisolated func invalidateHapticSessionForTesting() { - hapticLock.lock() - hapticSessionGeneration += 1 - hapticLock.unlock() - } - #endif - /// Plays a haptic pulse on the controller /// - Parameters: /// - intensity: Haptic intensity from 0.0 to 1.0 /// - duration: Duration in seconds nonisolated func playHaptic(intensity: Float = 0.5, sharpness: Float = 0.5, duration: TimeInterval = 0.1, transient: Bool = false) { - let sessionGeneration = currentHapticSessionGeneration() hapticQueue.async { [weak self] in guard let self = self else { return } - guard self.isCurrentHapticSession(sessionGeneration) else { return } - #if DEBUG - self.hapticSessionAcceptedForTesting?(sessionGeneration) - #endif if self.readStorage(\.isSteamController) { self.steamHIDControllerLock.lock() @@ -107,17 +69,12 @@ extension ControllerService { // Snapshot engines under lock self.hapticLock.lock() let engines = self.hapticEngines - let sessionStillCurrent = self.hapticSessionGeneration == sessionGeneration self.hapticLock.unlock() - guard sessionStillCurrent, !engines.isEmpty else { return } + guard !engines.isEmpty else { return } // Prune expired players to avoid truncating overlapping haptics let now = CFAbsoluteTimeGetCurrent() self.hapticLock.lock() - guard self.hapticSessionGeneration == sessionGeneration else { - self.hapticLock.unlock() - return - } self.activeHapticPlayers.removeAll { $0.endTime <= now } self.hapticLock.unlock() @@ -153,31 +110,20 @@ extension ControllerService { // Retain players so they aren't deallocated before playback completes var newPlayers: [ActiveHapticPlayer] = [] for engine in engines { - guard self.isCurrentHapticSession(sessionGeneration) else { break } do { let player = try engine.makePlayer(with: pattern) newPlayers.append(ActiveHapticPlayer(player: player, endTime: endTime)) - guard self.isCurrentHapticSession(sessionGeneration) else { break } try player.start(atTime: CHHapticTimeImmediate) } catch { // Try to restart engine and retry once - guard self.isCurrentHapticSession(sessionGeneration) else { break } try? engine.start() if let player = try? engine.makePlayer(with: pattern) { newPlayers.append(ActiveHapticPlayer(player: player, endTime: endTime)) - guard self.isCurrentHapticSession(sessionGeneration) else { break } try? player.start(atTime: CHHapticTimeImmediate) } } } self.hapticLock.lock() - guard self.hapticSessionGeneration == sessionGeneration else { - self.hapticLock.unlock() - for player in newPlayers { - try? player.player.stop(atTime: CHHapticTimeImmediate) - } - return - } self.activeHapticPlayers.append(contentsOf: newPlayers) if self.activeHapticPlayers.count > 12 { self.activeHapticPlayers.removeFirst(self.activeHapticPlayers.count - 12) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+LED.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+LED.swift index 21a6479b..d741e4f0 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+LED.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+LED.swift @@ -76,12 +76,12 @@ extension ControllerService { #if DEBUG print("[HID] Detected product ID: 0x\(String(productID, radix: 16)) (isEdge=\(isEdge))") #endif - storage.lock.lock() - storage.applyControllerTypeLocked(isEdge ? .dualSenseEdge : .dualSense) - storage.lock.unlock() - UserDefaults.standard.set(isEdge, forKey: Config.lastControllerWasDualSenseEdgeKey) - } - } + storage.lock.lock() + storage.isDualSenseEdge = isEdge + storage.lock.unlock() + UserDefaults.standard.set(isEdge, forKey: Config.lastControllerWasDualSenseEdgeKey) + } + } /// Applies LED settings to the connected DualSense controller. /// Over USB: sends a full HID output report (light bar, player LEDs, mute LED, brightness). diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Motion.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Motion.swift index 96d23f32..fd1558fe 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Motion.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Motion.swift @@ -50,10 +50,13 @@ extension ControllerService { storage.lock.lock() let gestures = storage.motionGestureDetector.processAll((pitchVelocity, rollVelocity), at: now) + let callback = storage.onMotionGesture storage.lock.unlock() + guard let callback = callback else { return } + for gesture in gestures { - emitInputEvent(.motionGesture(gesture)) + callback(gesture) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+NintendoHID.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+NintendoHID.swift index 58e1f5bb..dfaa17a6 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+NintendoHID.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+NintendoHID.swift @@ -47,6 +47,10 @@ extension ControllerService { private static let nintendoHIDReportBufferSize = 64 + /// Nintendo Switch Pro Controller identifiers + private static let nintendoVendorID = 0x057E + private static let proControllerProductID = 0x2009 + func setupNintendoHIDMonitoring() { // Clean up any previous monitoring if nintendoHIDManager != nil || !nintendoHIDRegistrations.isEmpty { @@ -56,7 +60,12 @@ extension ControllerService { nintendoHIDManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) guard let manager = nintendoHIDManager else { return } - IOHIDManagerSetDeviceMatchingMultiple(manager, NintendoHIDDriverDescriptor().matchingCFArray) + // Match Nintendo Pro Controller + let proControllerDict: [String: Any] = [ + kIOHIDVendorIDKey as String: Self.nintendoVendorID, + kIOHIDProductIDKey as String: Self.proControllerProductID, + ] + IOHIDManagerSetDeviceMatchingMultiple(manager, [proControllerDict] as CFArray) IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue) IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+SteamHID.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+SteamHID.swift index 09b5bf2d..78571d78 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+SteamHID.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+SteamHID.swift @@ -254,6 +254,7 @@ extension ControllerService { guard let self else { return } storage.lock.lock() let mode = storage.touchpadInputMode + let callback = storage.onControllerButtonTap storage.lock.unlock() let button: ControllerButton? @@ -264,7 +265,7 @@ extension ControllerService { button = ControllerButton.from(steamTouchpadSide: side, region: region, trigger: .touch) } if let button { - emitInputEvent(.controllerButtonTap(button)) + callback?(button) } } controller.onBatteryChanged = { [weak self, weak controller] level, state in @@ -342,7 +343,15 @@ extension ControllerService { storage.lock.lock() resetMotionStateLocked() resetTouchpadStateLocked() - storage.applyControllerTypeLocked(.steam) + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isNintendo = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isXboxElite = false + storage.isAppleTVRemote = false + storage.isSteamController = true storage.elitePaddleEventSource = .none storage.lock.unlock() diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Touchpad.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Touchpad.swift index 85dfd52a..13770a53 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Touchpad.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService+Touchpad.swift @@ -357,6 +357,7 @@ extension ControllerService { let newPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) let wasTouching = storage.isTouchpadTouching let wasTwoFinger = storage.isTouchpadTouching && storage.isTouchpadSecondaryTouching + let gestureCallback = storage.onTouchpadGesture let now = CFAbsoluteTimeGetCurrent() let secondaryFresh = (now - storage.touchpadSecondaryLastTouchTime) < Config.touchpadSecondaryStaleInterval // Secondary finger block is handled by secondaryFresh checks below. @@ -438,10 +439,11 @@ extension ControllerService { } storage.touchpadPosition = newPosition let gesture = shouldHandleAsPreSettleGesture ? computeTwoFingerGestureLocked(secondaryFresh: secondaryFresh) : nil + let gestureCallback = storage.onTouchpadGesture storage.lock.unlock() if let gesture { - emitInputEvent(.touchpadGesture(gesture)) + gestureCallback?(gesture) } return } @@ -542,6 +544,7 @@ extension ControllerService { current: storage.touchpadPosition ) : nil + let circularScrollCallback = storage.onAppleTVRemoteCircularScroll if circularScrollAngleDelta != nil { storage.appleTVRemoteCircularScrollActive = true } @@ -550,6 +553,8 @@ extension ControllerService { // Apply the PREVIOUS pending delta (if any), then store current as pending. // This 1-frame delay filters out artifacts right before finger lift. let previousPending = circularScrollActive ? nil : storage.pendingTouchpadDelta + let callback = storage.onTouchpadMoved + // Store current delta as pending for next frame unless ring scrolling owns this motion. if circularScrollActive { storage.pendingTouchpadDelta = nil @@ -575,30 +580,30 @@ extension ControllerService { let shouldHandleAsGesture = secondaryFresh && (!storage.isSteamController || steamTwoPadGesture) let gesture = computeTwoFingerGestureLocked(secondaryFresh: secondaryFresh) - let isSecondaryTouching = storage.isTouchpadSecondaryTouching - let shouldAllowSinglePadMovement = !circularScrollActive && (!isSecondaryTouching || (storage.isSteamController && !shouldHandleAsGesture)) + let isSecondaryTouching = storage.isTouchpadSecondaryTouching + let shouldAllowSinglePadMovement = !circularScrollActive && (!isSecondaryTouching || (storage.isSteamController && !shouldHandleAsGesture)) let shouldSendInactiveGesture = isSecondaryTouching && !shouldHandleAsGesture && shouldSendSteamTwoPadGestureEndLocked() let inactiveGesture = shouldSendInactiveGesture ? inactiveTouchpadGesture(primaryTouching: true, secondaryTouching: false) : nil storage.lock.unlock() - if let gesture, shouldHandleAsGesture { - emitInputEvent(.touchpadGesture(gesture)) + if let gesture, shouldHandleAsGesture { + gestureCallback?(gesture) if let circularScrollAngleDelta { - emitInputEvent(.appleTVRemoteCircularScroll(circularScrollAngleDelta)) - } - } else { - if let inactiveGesture { - emitInputEvent(.touchpadGesture(inactiveGesture)) + circularScrollCallback?(circularScrollAngleDelta) } + } else { + if let inactiveGesture { + gestureCallback?(inactiveGesture) + } if let circularScrollAngleDelta { - emitInputEvent(.appleTVRemoteCircularScroll(circularScrollAngleDelta)) + circularScrollCallback?(circularScrollAngleDelta) } - if let pending = previousPending, shouldAllowSinglePadMovement { - emitInputEvent(.touchpadMoved(pending)) + if let pending = previousPending, shouldAllowSinglePadMovement { + callback?(pending) + } } - } } else { // Finger just touched - initialize position, no delta yet storage.touchpadPosition = newPosition @@ -610,14 +615,14 @@ extension ControllerService { storage.touchpadGesturePreviousDistance = 0 storage.touchpadFramesSinceTouch = 0 storage.pendingTouchpadDelta = nil - storage.touchpadTouchStartTime = now - storage.touchpadTouchStartPosition = newPosition - storage.touchpadMaxDistanceFromStart = 0 + storage.touchpadTouchStartTime = now + storage.touchpadTouchStartPosition = newPosition + storage.touchpadMaxDistanceFromStart = 0 storage.appleTVRemoteCircularScrollActive = false storage.appleTVRemoteCircularScrollStartedInOuterRing = storage.isAppleTVRemote && storage.appleTVRemoteCircularScrollEnabled && Self.appleTVRemoteCircularScrollPositionIsOuterRing(newPosition) - // Check if secondary is already touching (for two-finger tap detection) + // Check if secondary is already touching (for two-finger tap detection) let secondaryFresh = (now - storage.touchpadSecondaryLastTouchTime) < Config.touchpadSecondaryStaleInterval storage.touchpadWasTwoFingerDuringTouch = secondaryFresh storage.touchpadTwoFingerGestureDistance = 0 // Reset for new touch session @@ -636,8 +641,8 @@ extension ControllerService { storage.touchpadLongTapFired = false // Start long tap timer - let shouldStartLongTapTimer = storage.onInputEvent != nil - if shouldStartLongTapTimer { + let longTapCallback = secondaryFresh ? storage.onTouchpadTwoFingerLongTap : storage.onTouchpadLongTap + if longTapCallback != nil { let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } self.storage.lock.lock() @@ -645,12 +650,11 @@ extension ControllerService { let distance = self.storage.touchpadMaxDistanceFromStart let stillTouching = self.storage.isTouchpadTouching let isTwoFinger = self.storage.touchpadWasTwoFingerDuringTouch + let callback = isTwoFinger ? self.storage.onTouchpadTwoFingerLongTap : self.storage.onTouchpadLongTap if stillTouching && distance < Config.touchpadLongTapMaxMovement { self.storage.touchpadLongTapFired = true self.storage.lock.unlock() - self.controllerQueue.async { - self.emitInputEvent(isTwoFinger ? .touchpadTwoFingerLongTap : .touchpadLongTap) - } + self.controllerQueue.async { callback?() } } else { self.storage.lock.unlock() } @@ -694,18 +698,21 @@ extension ControllerService { !clickFiredDuringTouch && touchDuration < Config.touchpadTapMaxDuration && touchDistance < Config.touchpadTapMaxMovement - let isAppleTVRemote = storage.isAppleTVRemote - let mode = isAppleTVRemote ? TouchpadInputMode.wholePad : storage.touchpadInputMode - let shouldEmitTap = isSingleTap && mode == .wholePad && !isSteamController + let isAppleTVRemote = storage.isAppleTVRemote + let mode = isAppleTVRemote ? TouchpadInputMode.wholePad : storage.touchpadInputMode + let tapCallback = (isSingleTap && mode == .wholePad && !isSteamController) ? storage.onTouchpadTap : nil + let regionTapCallback: ((TouchpadRegion) -> Void)? let tapRegion: TouchpadRegion? - if isSingleTap && mode == .quadrants && !isSteamController && !isAppleTVRemote { + if isSingleTap && mode == .quadrants && !isSteamController && !isAppleTVRemote { + regionTapCallback = storage.onTouchpadRegionTap let regionPosition = ControllerService.preferredTouchpadRegionPosition( currentPosition: storage.touchpadPosition, touchStartPosition: storage.touchpadTouchStartPosition ) tapRegion = TouchpadRegion.from(position: regionPosition) } else { + regionTapCallback = nil tapRegion = nil } @@ -726,7 +733,7 @@ extension ControllerService { secondaryTouchDistance < Config.touchpadTwoFingerTapMaxMovement && gestureDistance < Config.touchpadTwoFingerTapMaxGestureDistance && pinchDistance < Config.touchpadTwoFingerTapMaxPinchDistance - let shouldEmitTwoFingerTap = isTwoFingerTap && !isSteamController + let twoFingerTapCallback = (isTwoFingerTap && !isSteamController) ? storage.onTouchpadTwoFingerTap : nil if isSingleTap || isTwoFingerTap { storage.touchpadLastTapTime = now @@ -739,33 +746,29 @@ extension ControllerService { storage.touchpadFramesSinceTouch = 0 storage.pendingTouchpadDelta = nil storage.touchpadClickArmed = false - storage.touchpadClickFiredDuringTouch = false - storage.touchpadMovementBlocked = false - storage.touchpadLongTapFired = false - storage.appleTVRemoteCircularScrollActive = false - storage.appleTVRemoteCircularScrollStartedInOuterRing = false - let isSecondaryTouching = (now - storage.touchpadSecondaryLastTouchTime) < Config.touchpadSecondaryStaleInterval + storage.touchpadClickFiredDuringTouch = false + storage.touchpadMovementBlocked = false + storage.touchpadLongTapFired = false + storage.appleTVRemoteCircularScrollActive = false + storage.appleTVRemoteCircularScrollStartedInOuterRing = false + let isSecondaryTouching = (now - storage.touchpadSecondaryLastTouchTime) < Config.touchpadSecondaryStaleInterval let isTwoFinger = storage.isTouchpadTouching && isSecondaryTouching storage.lock.unlock() // Fire tap callback if it was a tap (not if long tap was fired) - if shouldEmitTap { - emitInputEvent(.touchpadTap) - } - if let tapRegion { - emitInputEvent(.touchpadRegionTap(tapRegion)) - } - if shouldEmitTwoFingerTap { - emitInputEvent(.touchpadTwoFingerTap) + tapCallback?() + if let regionTapCallback, let tapRegion { + regionTapCallback(tapRegion) } + twoFingerTapCallback?() if wasTwoFinger && !isTwoFinger { - emitInputEvent(.touchpadGesture(TouchpadGesture( + gestureCallback?(TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: isSecondaryTouching - ))) + )) } } } @@ -785,6 +788,8 @@ extension ControllerService { let newPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) let wasTouching = storage.isTouchpadSecondaryTouching let wasTwoFinger = storage.isTouchpadTouching && storage.isTouchpadSecondaryTouching + let gestureCallback = storage.onTouchpadGesture + let steamLeftTouchpadCallback = storage.onSteamLeftTouchpadMoved let now = CFAbsoluteTimeGetCurrent() // Detect if finger is on touchpad (non-zero position indicates touch) @@ -884,16 +889,17 @@ extension ControllerService { if isPrimaryTouching { storage.touchpadWasTwoFingerDuringTouch = true } + let gestureCallback = storage.onTouchpadGesture let shouldSendInitialGesture = isPrimaryTouching && !storage.isSteamController storage.lock.unlock() if shouldSendInitialGesture { - emitInputEvent(.touchpadGesture(TouchpadGesture( + gestureCallback?(TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: true, isSecondaryTouching: true - ))) + )) } return } @@ -923,13 +929,13 @@ extension ControllerService { : nil storage.lock.unlock() if let gesture, shouldHandleAsGesture { - emitInputEvent(.touchpadGesture(gesture)) + gestureCallback?(gesture) } else { if let inactiveGesture { - emitInputEvent(.touchpadGesture(inactiveGesture)) + gestureCallback?(inactiveGesture) } if let steamLeftTouchpadDelta { - emitInputEvent(.steamLeftTouchpadMoved(steamLeftTouchpadDelta)) + steamLeftTouchpadCallback?(steamLeftTouchpadDelta) } } } else { @@ -950,19 +956,19 @@ extension ControllerService { storage.lock.unlock() if wasTwoFinger && !isTwoFinger { - emitInputEvent(.touchpadGesture(TouchpadGesture( + gestureCallback?(TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: isPrimaryTouching, isSecondaryTouching: false - ))) + )) } else if isPrimaryTouching { - emitInputEvent(.touchpadGesture(TouchpadGesture( + gestureCallback?(TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: true, isSecondaryTouching: false - ))) + )) } } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService.swift index d70eb479..42f6f313 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerService.swift @@ -150,8 +150,22 @@ final class ControllerStorage: @unchecked Sendable { var chordParticipantButtons: Set = [] var lowLatencyInputEnabled: Bool = false - // Typed event boundary consumed by MappingEngine. - var onInputEvent: ((ControllerInputEvent) -> Void)? + // Callbacks + var onButtonPressed: ((ControllerButton) -> Void)? + var onButtonReleased: ((ControllerButton, TimeInterval) -> Void)? + var onChordDetected: ((Set) -> Void)? + var onLeftStickMoved: ((CGPoint) -> Void)? + var onRightStickMoved: ((CGPoint) -> Void)? + var onTouchpadMoved: ((CGPoint) -> Void)? // Delta movement + var onSteamLeftTouchpadMoved: ((CGPoint) -> Void)? // Left-pad delta movement for app-owned scroll + var onAppleTVRemoteCircularScroll: ((CGFloat) -> Void)? + var onTouchpadGesture: ((TouchpadGesture) -> Void)? + var onTouchpadTap: (() -> Void)? // Single tap (touch + release without moving) + var onControllerButtonTap: ((ControllerButton) -> Void)? // One-shot virtual tap events + var onTouchpadTwoFingerTap: (() -> Void)? // Two-finger tap or click (right-click) + var onTouchpadLongTap: (() -> Void)? // Long tap (touch held without moving) + var onTouchpadTwoFingerLongTap: (() -> Void)? // Two-finger long tap + var onTouchpadRegionTap: ((TouchpadRegion) -> Void)? // Region-specific tap /// When true, region-click events only fire while a finger is currently touching /// the pad. Default true. Mirrors `JoystickSettings.requireActiveTouchForRegionClick` /// and is updated by MappingEngine whenever joystick settings change. @@ -178,6 +192,8 @@ final class ControllerStorage: @unchecked Sendable { // Motion Gesture State (supported controller gyroscope) var motionInputEnabled: Bool = false var motionGestureDetector = MotionGestureDetector() + var onMotionGesture: ((MotionGestureType) -> Void)? + // Gyro aiming: accumulated rotation rates between polls (averaged on consume) // DS4 raw-HID gyro bias calibration. Apple's framework calibrates DualSense // automatically; for DS4 we sample the average gyro reading at rest so we @@ -218,7 +234,6 @@ class ControllerService: ObservableObject { @Published var currentControllerIdentity: ControllerIdentity? @Published var controllerName: String = "" @Published var controllerMappingSource: String? - @Published private(set) var isOuraRingConnected = false /// Currently pressed buttons (UI use only, updated asynchronously) @Published var activeButtons: Set = [] @@ -264,8 +279,6 @@ class ControllerService: ObservableObject { private var connectionGeneration: UInt64 = 0 private var configuredGameControllerIDs: Set = [] private let inactiveControllerAnalogActivationDeadzone: Float = 0.18 - private let inactiveControllerTakeoverQuietInterval: TimeInterval = 0.75 - private var activeGameControllerLastInputTime: TimeInterval = 0 /// Generation token for the battery polling chain. `updateBatteryInfo()` bumps /// it so previously scheduled polls cancel themselves — otherwise every external @@ -347,20 +360,14 @@ class ControllerService: ObservableObject { withStorageLock { $0[keyPath: keyPath] } } - nonisolated func writeStorage( - _ keyPath: ReferenceWritableKeyPath, - _ value: T - ) { - withStorageLock { $0[keyPath: keyPath] = value } - } - - nonisolated var threadSafeControllerPresentationState: ControllerPresentationState { - storage.lock.lock() - defer { storage.lock.unlock() } - return storage.controllerPresentationStateLocked - } + nonisolated func writeStorage( + _ keyPath: ReferenceWritableKeyPath, + _ value: T + ) { + withStorageLock { $0[keyPath: keyPath] = value } + } - // MARK: - Controller Snapshot (single lock acquisition for hot-path polling) + // MARK: - Controller Snapshot (single lock acquisition for hot-path polling) /// A value-type snapshot of controller input state, captured in a single lock acquisition. /// Used by JoystickHandler's 120Hz polling loop to avoid per-field lock thrashing. @@ -379,21 +386,20 @@ class ControllerService: ObservableObject { /// Captures a consistent snapshot of all joystick-polling-relevant state in a single lock acquisition. /// This replaces 4-6 individual `threadSafe*` property reads that each acquire/release the lock separately. - nonisolated func snapshot() -> ControllerSnapshot { - storage.lock.lock() - defer { storage.lock.unlock() } - let presentationState = storage.controllerPresentationStateLocked - return ControllerSnapshot( - leftStick: storage.leftStick, - rightStick: storage.rightStick, - leftTrigger: storage.leftTrigger, - rightTrigger: storage.rightTrigger, - touchpadPosition: storage.touchpadPosition, - isTouchpadTouching: storage.isTouchpadTouching, - hasMotion: presentationState.hasMotion, - isSteamController: presentationState.isSteamController - ) - } + nonisolated func snapshot() -> ControllerSnapshot { + storage.lock.lock() + defer { storage.lock.unlock() } + return ControllerSnapshot( + leftStick: storage.leftStick, + rightStick: storage.rightStick, + leftTrigger: storage.leftTrigger, + rightTrigger: storage.rightTrigger, + touchpadPosition: storage.touchpadPosition, + isTouchpadTouching: storage.isTouchpadTouching, + hasMotion: storage.isDualSense || storage.isDualShock || storage.isSteamController, + isSteamController: storage.isSteamController + ) + } nonisolated var threadSafeLeftStick: CGPoint { storage.lock.lock() @@ -457,61 +463,85 @@ class ControllerService: ObservableObject { return blocked } - nonisolated var threadSafeIsDualSense: Bool { - threadSafeControllerPresentationState.isDualSense - } + nonisolated var threadSafeIsDualSense: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return !storage.isSteamController && storage.isDualSense + } - nonisolated var threadSafeIsDualSenseEdge: Bool { - threadSafeControllerPresentationState.isDualSenseEdge - } + nonisolated var threadSafeIsDualSenseEdge: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return !storage.isSteamController && storage.isDualSenseEdge + } - nonisolated var threadSafeIsDualShock: Bool { - threadSafeControllerPresentationState.isDualShock - } + nonisolated var threadSafeIsDualShock: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return !storage.isSteamController && storage.isDualShock + } - /// Returns true if connected controller is any PlayStation controller (DualSense, DualShock) - nonisolated var threadSafeIsPlayStation: Bool { - threadSafeControllerPresentationState.isPlayStation - } + /// Returns true if connected controller is any PlayStation controller (DualSense, DualShock) + nonisolated var threadSafeIsPlayStation: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return !storage.isSteamController && (storage.isDualSense || storage.isDualShock) + } - nonisolated var threadSafeHasMotion: Bool { - threadSafeControllerPresentationState.hasMotion - } + nonisolated var threadSafeHasMotion: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.isDualSense || storage.isDualShock || storage.isSteamController + } - nonisolated var threadSafeIsNintendo: Bool { - threadSafeControllerPresentationState.isNintendo - } + nonisolated var threadSafeIsNintendo: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.isNintendo + } - nonisolated var threadSafeEightBitDoMinimapModel: EightBitDoMinimapModel? { - threadSafeControllerPresentationState.eightBitDoModel - } + nonisolated var threadSafeEightBitDoMinimapModel: EightBitDoMinimapModel? { + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.eightBitDoModel + } /// True for a stickless Nintendo-clone whose d-pad is funneled through the /// (mis-calibrated) phantom left stick — we drive the left stick from raw /// HID instead, so the GameController left-thumbstick must be ignored. /// Detected behaviorally (see [[SticklessDpadCloneDetector]]); gated to the /// Nintendo path since the DualShock clone's GameController stick is fine. - nonisolated var threadSafeIsNintendoDPadStickClone: Bool { - guard sticklessCloneDetector.isSticklessClone else { return false } - return threadSafeControllerPresentationState.isNintendo - } + nonisolated var threadSafeIsNintendoDPadStickClone: Bool { + guard sticklessCloneDetector.isSticklessClone else { return false } + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.isNintendo + } - nonisolated var threadSafeIsXboxElite: Bool { - threadSafeControllerPresentationState.isXboxElite - } + nonisolated var threadSafeIsXboxElite: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return !storage.isSteamController && storage.isXboxElite + } - nonisolated var threadSafeIsSteamController: Bool { - threadSafeControllerPresentationState.isSteamController - } + nonisolated var threadSafeIsSteamController: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.isSteamController + } - nonisolated var threadSafeIsAppleTVRemote: Bool { - threadSafeControllerPresentationState.isAppleTVRemote - } + nonisolated var threadSafeIsAppleTVRemote: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.isAppleTVRemote + } - /// Returns true if a single Joy-Con is connected (left or right, not a Pro Controller) - nonisolated var threadSafeIsSingleJoyCon: Bool { - threadSafeControllerPresentationState.isSingleJoyCon - } + /// Returns true if a single Joy-Con is connected (left or right, not a Pro Controller) + nonisolated var threadSafeIsSingleJoyCon: Bool { + storage.lock.lock() + defer { storage.lock.unlock() } + return storage.isJoyConLeft || storage.isJoyConRight + } /// Returns the average gyro rotation rates accumulated since the last call, then resets the accumulator. nonisolated func consumeAverageMotionRates() -> (pitch: Double, roll: Double) { @@ -666,10 +696,66 @@ class ControllerService: ObservableObject { /// Battery state @Published var batteryState: GCDeviceBattery.State = .unknown - // Typed input event sink (Thread-safe storage backing) - var onInputEvent: ((ControllerInputEvent) -> Void)? { - get { readStorage(\.onInputEvent) } - set { writeStorage(\.onInputEvent, newValue) } + // Callback proxies (Thread-safe storage backing) + var onButtonPressed: ((ControllerButton) -> Void)? { + get { readStorage(\.onButtonPressed) } + set { writeStorage(\.onButtonPressed, newValue) } + } + var onButtonReleased: ((ControllerButton, TimeInterval) -> Void)? { + get { readStorage(\.onButtonReleased) } + set { writeStorage(\.onButtonReleased, newValue) } + } + var onChordDetected: ((Set) -> Void)? { + get { readStorage(\.onChordDetected) } + set { writeStorage(\.onChordDetected, newValue) } + } + var onLeftStickMoved: ((CGPoint) -> Void)? { + get { readStorage(\.onLeftStickMoved) } + set { writeStorage(\.onLeftStickMoved, newValue) } + } + var onRightStickMoved: ((CGPoint) -> Void)? { + get { readStorage(\.onRightStickMoved) } + set { writeStorage(\.onRightStickMoved, newValue) } + } + var onTouchpadMoved: ((CGPoint) -> Void)? { + get { readStorage(\.onTouchpadMoved) } + set { writeStorage(\.onTouchpadMoved, newValue) } + } + var onSteamLeftTouchpadMoved: ((CGPoint) -> Void)? { + get { readStorage(\.onSteamLeftTouchpadMoved) } + set { writeStorage(\.onSteamLeftTouchpadMoved, newValue) } + } + var onAppleTVRemoteCircularScroll: ((CGFloat) -> Void)? { + get { readStorage(\.onAppleTVRemoteCircularScroll) } + set { writeStorage(\.onAppleTVRemoteCircularScroll, newValue) } + } + var onTouchpadGesture: ((TouchpadGesture) -> Void)? { + get { readStorage(\.onTouchpadGesture) } + set { writeStorage(\.onTouchpadGesture, newValue) } + } + var onTouchpadTap: (() -> Void)? { + get { readStorage(\.onTouchpadTap) } + set { writeStorage(\.onTouchpadTap, newValue) } + } + var onControllerButtonTap: ((ControllerButton) -> Void)? { + get { readStorage(\.onControllerButtonTap) } + set { writeStorage(\.onControllerButtonTap, newValue) } + } + var onTouchpadTwoFingerTap: (() -> Void)? { + get { readStorage(\.onTouchpadTwoFingerTap) } + set { writeStorage(\.onTouchpadTwoFingerTap, newValue) } + } + var onTouchpadLongTap: (() -> Void)? { + get { readStorage(\.onTouchpadLongTap) } + set { writeStorage(\.onTouchpadLongTap, newValue) } + } + var onTouchpadTwoFingerLongTap: (() -> Void)? { + get { readStorage(\.onTouchpadTwoFingerLongTap) } + set { writeStorage(\.onTouchpadTwoFingerLongTap, newValue) } + } + var onTouchpadRegionTap: ((TouchpadRegion) -> Void)? { + get { readStorage(\.onTouchpadRegionTap) } + set { writeStorage(\.onTouchpadRegionTap, newValue) } } var requireActiveTouchForRegionClick: Bool { get { readStorage(\.requireActiveTouchForRegionClick) } @@ -683,9 +769,9 @@ class ControllerService: ObservableObject { get { readStorage(\.touchpadInputMode) } set { writeStorage(\.touchpadInputMode, newValue) } } - nonisolated func emitInputEvent(_ event: ControllerInputEvent) { - let callback = readStorage(\.onInputEvent) - callback?(event) + var onMotionGesture: ((MotionGestureType) -> Void)? { + get { readStorage(\.onMotionGesture) } + set { writeStorage(\.onMotionGesture, newValue) } } private var cancellables = Set() @@ -710,18 +796,13 @@ class ControllerService: ObservableObject { // Haptic engines for controller feedback (try multiple localities) // Protected by hapticLock — accessed from both @MainActor (setup/stop) and hapticQueue (play) let hapticLock = NSLock() - nonisolated(unsafe) var hapticEngines: [CHHapticEngine] = [] + var hapticEngines: [CHHapticEngine] = [] let hapticQueue = DispatchQueue(label: "com.xboxmapper.haptic", qos: .userInitiated) - let hapticQueueSpecificKey = DispatchSpecificKey() struct ActiveHapticPlayer { let player: CHHapticPatternPlayer let endTime: TimeInterval } - nonisolated(unsafe) var activeHapticPlayers: [ActiveHapticPlayer] = [] - nonisolated(unsafe) var hapticSessionGeneration: UInt64 = 0 - #if DEBUG - nonisolated(unsafe) var hapticSessionAcceptedForTesting: ((UInt64) -> Void)? - #endif + var activeHapticPlayers: [ActiveHapticPlayer] = [] /// Display name shown in the toolbar pill for `--screenshot-variant` captures. static func screenshotControllerName(for variant: String) -> String { @@ -743,7 +824,7 @@ class ControllerService: ObservableObject { /// Identifies the small 8BitDo pads from their SDL/HID product names /// ("8BitDo Zero 2", "8BitDo Micro", "8BitDo Lite 2", "8BitDo Lite SE"). - nonisolated static func eightBitDoMinimapModel(forControllerName name: String) -> EightBitDoMinimapModel? { + static func eightBitDoMinimapModel(forControllerName name: String) -> EightBitDoMinimapModel? { let lowered = name.lowercased() guard lowered.contains("8bitdo") else { return nil } if lowered.contains("zero") { return .zero2 } @@ -753,21 +834,10 @@ class ControllerService: ObservableObject { return nil } - nonisolated static func eightBitDoMinimapModel(vendorName: String?, productCategory: String) -> EightBitDoMinimapModel? { + static func eightBitDoMinimapModel(vendorName: String?, productCategory: String) -> EightBitDoMinimapModel? { eightBitDoMinimapModel(forControllerName: "\(vendorName ?? "") \(productCategory)") } - nonisolated static func isSticklessEightBitDoModel(vendorName: String?, productCategory: String) -> Bool { - eightBitDoMinimapModel(vendorName: vendorName, productCategory: productCategory)?.isStickless == true - } - - nonisolated static func shouldUsePhysicalDirectionPadAsLeftStickFallback( - vendorName: String?, - productCategory: String - ) -> Bool { - !isSticklessEightBitDoModel(vendorName: vendorName, productCategory: productCategory) - } - private func startEightBitDoHIDMonitoringIfNeeded(for controller: GCController, reason: String) { guard Self.eightBitDoMinimapModel( vendorName: controller.vendorName, @@ -824,26 +894,39 @@ class ControllerService: ObservableObject { let shouldEnableHardwareMonitoring = enableHardwareMonitoring && !Self.isRunningTests self.guideMonitor = XboxGuideMonitor(enableHardwareMonitoring: shouldEnableHardwareMonitoring) self.hardwareMonitoringEnabled = shouldEnableHardwareMonitoring - hapticQueue.setSpecific(key: hapticQueueSpecificKey, value: ()) - - // Load last controller type (so UI shows correct button labels when no controller is connected) - storage.restoreControllerTypeFlags( - isDualSense: UserDefaults.standard.bool(forKey: Config.lastControllerWasDualSenseKey), - isDualSenseEdge: UserDefaults.standard.bool(forKey: Config.lastControllerWasDualSenseEdgeKey), - isDualShock: UserDefaults.standard.bool(forKey: Config.lastControllerWasDualShockKey), - isNintendo: UserDefaults.standard.bool(forKey: Config.lastControllerWasNintendoKey), - isXboxElite: UserDefaults.standard.bool(forKey: Config.lastControllerWasXboxEliteKey), - isSteamController: UserDefaults.standard.bool(forKey: Config.lastControllerWasSteamControllerKey), - isAppleTVRemote: UserDefaults.standard.bool(forKey: Config.lastControllerWasAppleTVRemoteKey) - ) + + // Load last controller type (so UI shows correct button labels when no controller is connected) + storage.isDualSense = UserDefaults.standard.bool(forKey: Config.lastControllerWasDualSenseKey) + storage.isDualSenseEdge = UserDefaults.standard.bool(forKey: Config.lastControllerWasDualSenseEdgeKey) + storage.isDualShock = UserDefaults.standard.bool(forKey: Config.lastControllerWasDualShockKey) + storage.isXboxElite = UserDefaults.standard.bool(forKey: Config.lastControllerWasXboxEliteKey) + storage.isSteamController = UserDefaults.standard.bool(forKey: Config.lastControllerWasSteamControllerKey) + storage.isAppleTVRemote = UserDefaults.standard.bool(forKey: Config.lastControllerWasAppleTVRemoteKey) + if storage.isSteamController { + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isXboxElite = false + storage.isAppleTVRemote = false + } else if storage.isAppleTVRemote { + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isXboxElite = false + storage.isNintendo = false + } // Screenshot mode: force the preview to the requested variant and // present as connected. Hardware monitoring is off in this mode (see // ServiceContainer), so nothing can override these between captures. if let variant = AppRuntime.screenshotVariant { - if let type = ControllerTypeState(screenshotVariant: variant) { - storage.applyControllerTypeLocked(type) - } + storage.isDualSense = (variant == "dualsense" || variant == "dualsense-edge") + storage.isDualSenseEdge = (variant == "dualsense-edge") + storage.isDualShock = (variant == "dualshock") + storage.isNintendo = (variant == "nintendo") + storage.isXboxElite = (variant == "xbox-elite") + storage.isSteamController = (variant == "steam") + storage.isAppleTVRemote = (variant == "appletv") storage.eightBitDoModel = { switch variant { case "8bitdo-zero2": return .zero2 @@ -1045,7 +1128,6 @@ class ControllerService: ObservableObject { connectionGeneration, reason, controller.vendorName ?? "(nil)") prepareForActiveControllerSwitch() - activeGameControllerLastInputTime = CFAbsoluteTimeGetCurrent() // Cancel generic HID fallback if GameController framework claimed this device genericHIDFallbackTimer?.cancel() @@ -1102,10 +1184,8 @@ class ControllerService: ObservableObject { self?.objectWillChange.send() } - let activationGeneration = connectionGeneration DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard let self, self.connectionGeneration == activationGeneration else { return } - self.playHaptic(intensity: 0.7, sharpness: 0.6, duration: 0.12) + self?.playHaptic(intensity: 0.7, sharpness: 0.6, duration: 0.12) } updateBatteryInfo() startDisplayUpdateTimer() @@ -1143,7 +1223,6 @@ class ControllerService: ObservableObject { cleanupEightBitDoHIDMonitoring() stopEliteHelper() controllerMappingSource = nil - activeGameControllerLastInputTime = 0 activeButtons.removeAll() leftStick = .zero @@ -1200,7 +1279,6 @@ class ControllerService: ObservableObject { connectedController?.motion?.sensorsActive = false connectedController = nil configuredGameControllerIDs.removeAll() - activeGameControllerLastInputTime = 0 isConnected = false currentControllerIdentity = nil isGenericController = false @@ -1252,56 +1330,6 @@ class ControllerService: ObservableObject { storage.rawHIDGuidePressed = false storage.rawHIDGuideLastEventTime = nil storage.lock.unlock() - - if isOuraRingConnected { - publishOuraRingConnection() - } - } - - func setOuraRingConnected(_ connected: Bool) { - guard isOuraRingConnected != connected else { return } - isOuraRingConnected = connected - - if connected { - publishOuraRingConnection() - } else { - if !hasActiveHardwareInputSource { - releaseOuraRingInputs() - isConnected = false - currentControllerIdentity = nil - controllerName = "" - controllerMappingSource = nil - activeButtons.removeAll() - stopDisplayUpdateTimer() - } else { - releaseOuraRingButtons() - } - } - } - - private var hasActiveHardwareInputSource: Bool { - connectedController != nil || - isGenericController || - steamHIDActiveDevice != nil || - appleTVRemoteHIDDevice != nil || - appleTVRemoteHIDTouchDevice != nil - } - - var isOuraRingActiveInputSource: Bool { - isOuraRingConnected && - controllerName == "Oura Ring" && - controllerMappingSource == "Oura Ring" - } - - private func publishOuraRingConnection() { - guard !hasActiveHardwareInputSource else { return } - isConnected = true - currentControllerIdentity = nil - controllerName = "Oura Ring" - controllerMappingSource = "Oura Ring" - if !AppRuntime.isRunningTests { - startDisplayUpdateTimer() - } } func resetTouchpadStateLocked() { @@ -1544,71 +1572,13 @@ class ControllerService: ObservableObject { if steamHIDActiveDevice != nil { return false } - let now = CFAbsoluteTimeGetCurrent() if connectedController !== controller { - guard Self.shouldActivateInactiveControllerInput( - meaningful: meaningful, - activeControllerHasInput: hasActiveGameControllerInput(), - activeControllerLastInputTime: activeGameControllerLastInputTime, - now: now, - quietInterval: inactiveControllerTakeoverQuietInterval - ) else { return false } + guard meaningful else { return false } activateGameController(controller, reason: "input") - } else { - if meaningful || hasActiveGameControllerInput() { - activeGameControllerLastInputTime = now - } } return shouldAcceptGameControllerInput() } - nonisolated static func shouldActivateInactiveControllerInput( - meaningful: Bool, - activeControllerHasInput: Bool, - activeControllerLastInputTime: TimeInterval, - now: TimeInterval, - quietInterval: TimeInterval - ) -> Bool { - guard meaningful else { return false } - guard !activeControllerHasInput else { return false } - guard activeControllerLastInputTime > 0 else { return true } - return now - activeControllerLastInputTime >= quietInterval - } - - nonisolated func hasActiveGameControllerInput() -> Bool { - storage.lock.lock() - defer { storage.lock.unlock() } - return Self.hasActiveGameControllerInput( - activeButtons: storage.activeButtons, - leftStick: storage.leftStick, - rightStick: storage.rightStick, - leftTrigger: storage.leftTrigger, - rightTrigger: storage.rightTrigger, - touchpadIsActive: storage.isTouchpadTouching || - storage.isTouchpadSecondaryTouching || - storage.isSteamLeftTouchpadTouching || - storage.isSteamRightTouchpadTouching, - deadzone: inactiveControllerAnalogActivationDeadzone - ) - } - - nonisolated static func hasActiveGameControllerInput( - activeButtons: Set, - leftStick: CGPoint, - rightStick: CGPoint, - leftTrigger: Float, - rightTrigger: Float, - touchpadIsActive: Bool, - deadzone: Float - ) -> Bool { - guard activeButtons.isEmpty else { return true } - guard !touchpadIsActive else { return true } - if hypotf(Float(leftStick.x), Float(leftStick.y)) >= deadzone { return true } - if hypotf(Float(rightStick.x), Float(rightStick.y)) >= deadzone { return true } - if leftTrigger >= deadzone || rightTrigger >= deadzone { return true } - return false - } - func clearGameControllerHandlers(for controller: GCController) { if let gamepad = controller.extendedGamepad { let buttons: [GCControllerButtonInput?] = [ @@ -1695,7 +1665,15 @@ class ControllerService: ObservableObject { func resetControllerTypeState() { storage.lock.lock() - storage.clearControllerTypeFlagsLocked() + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isNintendo = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isXboxElite = false + storage.isSteamController = false + storage.isAppleTVRemote = false resetCloneDetectionStateLocked() storage.elitePaddleEventSource = .none storage.lock.unlock() @@ -1881,7 +1859,13 @@ class ControllerService: ObservableObject { if let xboxGamepad { storage.lock.lock() - storage.applyControllerTypeLocked(.xbox) + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isNintendo = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isSteamController = false storage.lock.unlock() UserDefaults.standard.set(false, forKey: Config.lastControllerWasDualSenseKey) UserDefaults.standard.set(false, forKey: Config.lastControllerWasDualSenseEdgeKey) @@ -1905,7 +1889,7 @@ class ControllerService: ObservableObject { gameControllerHasPaddles: hasPaddles ) storage.lock.lock() - storage.applyControllerTypeLocked(.xboxElite) + storage.isXboxElite = true storage.elitePaddleEventSource = paddleEventSource storage.lock.unlock() UserDefaults.standard.set(true, forKey: Config.lastControllerWasXboxEliteKey) @@ -1924,7 +1908,7 @@ class ControllerService: ObservableObject { startEliteHelper(paddleEventSource: paddleEventSource) } else { storage.lock.lock() - storage.applyControllerTypeLocked(.xbox) + storage.isXboxElite = false storage.elitePaddleEventSource = .none storage.lock.unlock() UserDefaults.standard.set(false, forKey: Config.lastControllerWasXboxEliteKey) @@ -1957,7 +1941,13 @@ class ControllerService: ObservableObject { // DualSense-specific: Touchpad support if let dualSenseGamepad = gamepad as? GCDualSenseGamepad { storage.lock.lock() - storage.applyControllerTypeLocked(.dualSense) + storage.isDualSense = true + storage.isDualShock = false + storage.isNintendo = false + storage.isXboxElite = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isSteamController = false storage.lock.unlock() UserDefaults.standard.set(true, forKey: Config.lastControllerWasDualSenseKey) UserDefaults.standard.set(false, forKey: Config.lastControllerWasDualShockKey) @@ -1984,7 +1974,14 @@ class ControllerService: ObservableObject { // Note: DualShock 4 doesn't have mic button or LED control via GameController framework else if let dualShockGamepad = gamepad as? GCDualShockGamepad { storage.lock.lock() - storage.applyControllerTypeLocked(.dualShock) + storage.isDualShock = true + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isNintendo = false + storage.isXboxElite = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isSteamController = false storage.lock.unlock() UserDefaults.standard.set(true, forKey: Config.lastControllerWasDualShockKey) UserDefaults.standard.set(false, forKey: Config.lastControllerWasDualSenseKey) @@ -2033,7 +2030,15 @@ class ControllerService: ObservableObject { private func setupAppleTVRemoteInputHandlers(for controller: GCController, microGamepad: GCMicroGamepad) { storage.lock.lock() - storage.applyControllerTypeLocked(.appleTVRemote) + storage.isAppleTVRemote = true + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isNintendo = false + storage.isXboxElite = false + storage.isJoyConLeft = false + storage.isJoyConRight = false + storage.isSteamController = false storage.lock.unlock() UserDefaults.standard.set(true, forKey: Config.lastControllerWasAppleTVRemoteKey) @@ -2126,7 +2131,7 @@ class ControllerService: ObservableObject { releaseAppleTVRemoteTouchIfStillActive() storage.lock.lock() - storage.clearAppleTVRemoteFlagLocked() + storage.isAppleTVRemote = false storage.lock.unlock() UserDefaults.standard.set(false, forKey: Config.lastControllerWasAppleTVRemoteKey) @@ -2205,7 +2210,14 @@ class ControllerService: ObservableObject { NSLog("[ControllerKeys] Joy-Con L/R detection: isLeft=%d isRight=%d", isLeft ? 1 : 0, isRight ? 1 : 0) storage.lock.lock() - storage.applyControllerTypeLocked(.nintendo(ControllerJoyConSide(isLeft: isLeft, isRight: isRight))) + storage.isNintendo = true + storage.isJoyConLeft = isLeft + storage.isJoyConRight = isRight + storage.isDualSense = false + storage.isDualSenseEdge = false + storage.isDualShock = false + storage.isXboxElite = false + storage.isSteamController = false storage.lock.unlock() UserDefaults.standard.set(true, forKey: Config.lastControllerWasNintendoKey) @@ -2261,14 +2273,17 @@ class ControllerService: ObservableObject { ] // Stickless 8BitDo pads (Zero 2 / Micro in D-input mode) have no real - // analog stick. When macOS exposes the physical d-pad as a - // `Direction Pad`, bind it as real .dpad* buttons and do not also use it - // as the left-stick fallback. The mapping canvas labels this control as - // a D-pad, and Android-mode Micro reports digital D-pad directions here. - let useDirectionPadAsLeftStickFallback = Self.shouldUsePhysicalDirectionPadAsLeftStickFallback( + // analog stick — their physical d-pad IS the directional input. The + // Micro exposes it as a "Direction Pad" (with digital sub-buttons), + // which we must NOT bind as .dpad* buttons here: that would fire d-pad + // actions AND (via the thumbstick fallback below) drive the mouse — + // the double-input. Instead we route the d-pad to the left stick so + // the left-stick mode (Mouse by default, or the D-Pad mode) governs + // it, exactly like the Zero 2 (which exposes its d-pad as a thumbstick). + let isSticklessEightBitDo = Self.eightBitDoMinimapModel( vendorName: controller.vendorName, productCategory: controller.productCategory - ) + )?.isStickless == true var boundCount = 0 for (inputName, controllerButton) in buttonMap { @@ -2278,8 +2293,9 @@ class ControllerService: ObservableObject { } } - // D-pad. - if let dpad = profile.dpads[GCInputDirectionPad] { + // D-pad (skip the direct button binding for stickless 8BitDo pads — + // their d-pad flows through the left stick instead, see above). + if !isSticklessEightBitDo, let dpad = profile.dpads[GCInputDirectionPad] { bindButton(dpad.up, to: .dpadUp, from: controller) bindButton(dpad.down, to: .dpadDown, from: controller) bindButton(dpad.left, to: .dpadLeft, from: controller) @@ -2295,7 +2311,7 @@ class ControllerService: ObservableObject { service.updateLeftStick(x: x, y: y) } } - } else if useDirectionPadAsLeftStickFallback, let dpad = profile.dpads[GCInputDirectionPad] { + } else if let dpad = profile.dpads[GCInputDirectionPad] { // Fallback: Joy-Con stick may be exposed as a D-pad rather than a thumbstick. // Use it for mouse movement so the user gets analog-like cursor control. dpad.valueChangedHandler = { [weak self, weak controller] _, xValue, yValue in @@ -2327,9 +2343,9 @@ class ControllerService: ObservableObject { // MARK: - Thread-Safe Update Helpers nonisolated func updateLeftStick(x: Float, y: Float) { - let point = CGPoint(x: CGFloat(x), y: CGFloat(y)) storage.lock.lock() - storage.leftStick = point + storage.leftStick = CGPoint(x: CGFloat(x), y: CGFloat(y)) + let callback = storage.onLeftStickMoved let feedCloneDetector = storage.isDualShock storage.lock.unlock() @@ -2337,38 +2353,33 @@ class ControllerService: ObservableObject { // stick. (The Nintendo path feeds it from clean raw-HID axes instead — // see handleNintendoHIDReport — to avoid any GameController smoothing.) if feedCloneDetector { - sticklessCloneDetector.noteLeftStick(point) + sticklessCloneDetector.noteLeftStick(CGPoint(x: CGFloat(x), y: CGFloat(y))) + } + + // Only create Task if callback exists (avoids creating 250+ Tasks/sec for nil callbacks) + if let callback = callback { + let point = CGPoint(x: CGFloat(x), y: CGFloat(y)) + Task { @MainActor in + callback(point) + } } } nonisolated func updateRightStick(x: Float, y: Float) { - let point = CGPoint(x: CGFloat(x), y: CGFloat(y)) storage.lock.lock() - storage.rightStick = point + storage.rightStick = CGPoint(x: CGFloat(x), y: CGFloat(y)) + let callback = storage.onRightStickMoved storage.lock.unlock() - } - - nonisolated func updateOuraRingStick(_ point: CGPoint, side: JoystickSide) { - switch side { - case .left: - updateLeftStick(x: Float(point.x), y: Float(point.y)) - case .right: - updateRightStick(x: Float(point.x), y: Float(point.y)) - } - } - nonisolated func releaseOuraRingInputs() { - updateLeftStick(x: 0, y: 0) - updateRightStick(x: 0, y: 0) - releaseOuraRingButtons() + // Only create Task if callback exists (avoids creating 250+ Tasks/sec for nil callbacks) + if let callback = callback { + let point = CGPoint(x: CGFloat(x), y: CGFloat(y)) + Task { @MainActor in + callback(point) + } + } } - nonisolated private func releaseOuraRingButtons() { - for button in ControllerButton.ouraRingButtons { - handleButton(button, pressed: false) - } - } - nonisolated func updateLeftTrigger(_ value: Float, pressed: Bool) { storage.lock.lock() storage.leftTrigger = value @@ -2420,6 +2431,7 @@ class ControllerService: ObservableObject { && !storage.chordParticipantButtons.contains(button) && storage.capturedButtonsInWindow.isEmpty if shouldBypassChordWindow { + let callback = storage.onButtonPressed let uiButtons = storage.activeButtons storage.lock.unlock() @@ -2428,7 +2440,7 @@ class ControllerService: ObservableObject { } LatencyDiagnostics.mark("controller.lowLatencyPress \(button.rawValue)") - emitInputEvent(.buttonPressed(button)) + callback?(button) return } @@ -2481,8 +2493,9 @@ class ControllerService: ObservableObject { storage.pendingReleases[button] = holdDuration storage.lock.unlock() } else { + let callback = storage.onButtonReleased storage.lock.unlock() - emitInputEvent(.buttonReleased(button, holdDuration: holdDuration)) + callback?(button, holdDuration) } } @@ -2498,6 +2511,10 @@ class ControllerService: ObservableObject { let releases = storage.pendingReleases storage.pendingReleases.removeAll() + let chordCallback = storage.onChordDetected + let pressCallback = storage.onButtonPressed + let releaseCallback = storage.onButtonReleased + storage.lock.unlock() // Deliver chord/press callbacks FIRST, then pending releases. @@ -2509,15 +2526,15 @@ class ControllerService: ObservableObject { // hold-path buttons (mouse clicks) permanently stuck down. if captured.count >= 2 { LatencyDiagnostics.mark("controller.chord \(captured.map(\.rawValue).sorted().joined(separator: "+"))") - emitInputEvent(.chordDetected(captured)) + chordCallback?(captured) } else if let button = captured.first { LatencyDiagnostics.mark("controller.delayedPress \(button.rawValue)") - emitInputEvent(.buttonPressed(button)) + pressCallback?(button) } for button in captured { if let duration = releases[button] { - emitInputEvent(.buttonReleased(button, holdDuration: duration)) + releaseCallback?(button, duration) } } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerTypeState.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerTypeState.swift deleted file mode 100644 index 633927a0..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/ControllerTypeState.swift +++ /dev/null @@ -1,246 +0,0 @@ -import Foundation - -enum ControllerJoyConSide: Equatable, Sendable { - case left - case right - case pair - case unknown - - init(isLeft: Bool, isRight: Bool) { - switch (isLeft, isRight) { - case (true, false): - self = .left - case (false, true): - self = .right - case (true, true): - self = .pair - case (false, false): - self = .unknown - } - } - - var isLeft: Bool { - switch self { - case .left, .pair: - return true - case .right, .unknown: - return false - } - } - - var isRight: Bool { - switch self { - case .right, .pair: - return true - case .left, .unknown: - return false - } - } -} - -enum ControllerTypeState: Equatable, Sendable { - case xbox - case xboxElite - case dualSense - case dualSenseEdge - case dualShock - case nintendo(ControllerJoyConSide) - case steam - case appleTVRemote - - init?(screenshotVariant: String) { - switch screenshotVariant { - case "xbox", "8bitdo-zero2", "8bitdo-micro", "8bitdo-lite2", "8bitdo-lite-se": - self = .xbox - case "xbox-elite": - self = .xboxElite - case "dualsense": - self = .dualSense - case "dualsense-edge": - self = .dualSenseEdge - case "dualshock": - self = .dualShock - case "nintendo": - self = .nintendo(.unknown) - case "steam": - self = .steam - case "appletv": - self = .appleTVRemote - default: - return nil - } - } -} - -struct ControllerPresentationState: Equatable, Sendable { - let controllerType: ControllerTypeState - let eightBitDoModel: EightBitDoMinimapModel? - - var isAppleTVRemote: Bool { - controllerType == .appleTVRemote - } - - var isSteamController: Bool { - controllerType == .steam - } - - var isDualSense: Bool { - switch controllerType { - case .dualSense, .dualSenseEdge: - return true - default: - return false - } - } - - var isDualSenseEdge: Bool { - controllerType == .dualSenseEdge - } - - var isDualShock: Bool { - controllerType == .dualShock - } - - var isPlayStation: Bool { - switch controllerType { - case .dualSense, .dualSenseEdge, .dualShock: - return true - default: - return false - } - } - - var isNintendo: Bool { - if case .nintendo = controllerType { return true } - return false - } - - var isXboxElite: Bool { - controllerType == .xboxElite - } - - var hasMotion: Bool { - isPlayStation || isSteamController - } - - var isSingleJoyCon: Bool { - guard case let .nintendo(side) = controllerType else { return false } - return side.isLeft || side.isRight - } -} - -extension ControllerStorage { - func restoreControllerTypeFlags( - isDualSense: Bool, - isDualSenseEdge: Bool, - isDualShock: Bool, - isNintendo: Bool, - isXboxElite: Bool, - isSteamController: Bool, - isAppleTVRemote: Bool - ) { - lock.lock() - defer { lock.unlock() } - restoreControllerTypeFlagsLocked( - isDualSense: isDualSense, - isDualSenseEdge: isDualSenseEdge, - isDualShock: isDualShock, - isNintendo: isNintendo, - isXboxElite: isXboxElite, - isSteamController: isSteamController, - isAppleTVRemote: isAppleTVRemote - ) - } - - /// Caller owns `lock`. - func restoreControllerTypeFlagsLocked( - isDualSense: Bool, - isDualSenseEdge: Bool, - isDualShock: Bool, - isNintendo: Bool, - isXboxElite: Bool, - isSteamController: Bool, - isAppleTVRemote: Bool - ) { - clearControllerTypeFlagsLocked() - self.isDualSense = isDualSense - self.isDualSenseEdge = isDualSenseEdge - self.isDualShock = isDualShock - self.isNintendo = isNintendo - self.isXboxElite = isXboxElite - self.isSteamController = isSteamController - self.isAppleTVRemote = isAppleTVRemote - normalizeControllerTypeFlagsLocked() - } - - /// Caller owns `lock`. - func clearControllerTypeFlagsLocked() { - isDualSense = false - isDualSenseEdge = false - isDualShock = false - isNintendo = false - isJoyConLeft = false - isJoyConRight = false - isXboxElite = false - isSteamController = false - isAppleTVRemote = false - } - - /// Caller owns `lock`. - func applyControllerTypeLocked(_ type: ControllerTypeState) { - clearControllerTypeFlagsLocked() - switch type { - case .xbox: - break - case .xboxElite: - isXboxElite = true - case .dualSense: - isDualSense = true - case .dualSenseEdge: - isDualSense = true - isDualSenseEdge = true - case .dualShock: - isDualShock = true - case .nintendo(let side): - isNintendo = true - isJoyConLeft = side.isLeft - isJoyConRight = side.isRight - case .steam: - isSteamController = true - case .appleTVRemote: - isAppleTVRemote = true - } - } - - /// Caller owns `lock`. - func clearAppleTVRemoteFlagLocked() { - isAppleTVRemote = false - } - - /// Caller owns `lock`. - var controllerTypeStateLocked: ControllerTypeState { - if isAppleTVRemote { return .appleTVRemote } - if isSteamController { return .steam } - if isDualSenseEdge { return .dualSenseEdge } - if isDualSense { return .dualSense } - if isDualShock { return .dualShock } - if isNintendo { - return .nintendo(ControllerJoyConSide(isLeft: isJoyConLeft, isRight: isJoyConRight)) - } - if isXboxElite { return .xboxElite } - return .xbox - } - - /// Caller owns `lock`. - func normalizeControllerTypeFlagsLocked() { - applyControllerTypeLocked(controllerTypeStateLocked) - } - - /// Caller owns `lock`. - var controllerPresentationStateLocked: ControllerPresentationState { - ControllerPresentationState( - controllerType: controllerTypeStateLocked, - eightBitDoModel: eightBitDoModel - ) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Controller/HIDControllerDriverDescriptor.swift b/XboxControllerMapper/XboxControllerMapper/Services/Controller/HIDControllerDriverDescriptor.swift deleted file mode 100644 index eb1c1448..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Controller/HIDControllerDriverDescriptor.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -import IOKit.hid - -/// Declarative HID matching criteria for raw-controller backends. Kept pure so -/// adding a future HID controller can be covered by unit tests before touching -/// IOHIDManager callback wiring. -enum HIDMatchingCriterion: Hashable { - case vendorProduct(vendorID: Int, productID: Int) - case usage(page: Int, usage: Int) - case transport(String) - - var dictionary: [String: Any] { - switch self { - case let .vendorProduct(vendorID, productID): - return [ - kIOHIDVendorIDKey as String: vendorID, - kIOHIDProductIDKey as String: productID, - ] - case let .usage(page, usage): - return [ - kIOHIDDeviceUsagePageKey as String: page, - kIOHIDDeviceUsageKey as String: usage, - ] - case let .transport(name): - return [ - kIOHIDTransportKey as String: name, - ] - } - } -} - -protocol HIDControllerDriverDescriptor { - var displayName: String { get } - var matchingCriteria: [HIDMatchingCriterion] { get } -} - -extension HIDControllerDriverDescriptor { - var matchingCFArray: CFArray { - matchingCriteria.map(\.dictionary) as CFArray - } -} - -struct NintendoHIDDriverDescriptor: HIDControllerDriverDescriptor { - static let vendorID = 0x057E - static let proControllerProductID = 0x2009 - - let displayName = "Nintendo Switch Pro Controller" - - var matchingCriteria: [HIDMatchingCriterion] { - [ - .vendorProduct( - vendorID: Self.vendorID, - productID: Self.proControllerProductID - ) - ] - } -} - -struct EightBitDoDInputHIDDriverDescriptor: HIDControllerDriverDescriptor { - static let vendorID = 0x2DC8 - static let microProductID = 0x9020 - static let zero2ProductID = 0x3230 - static let lite2ProductID = 0x5112 - - let displayName = "8BitDo D-input pads" - - var matchingCriteria: [HIDMatchingCriterion] { - Self.productIDs.map { - .vendorProduct(vendorID: Self.vendorID, productID: $0) - } - } - - static var productIDs: [Int] { - [microProductID, zero2ProductID, lite2ProductID] - } -} - -struct GenericHIDDriverDescriptor: HIDControllerDriverDescriptor { - static let excludedVendorIDs: Set = [ - 0x045E, // Xbox raw Guide/Elite path - 0x054C, // PlayStation raw PS button path - 0x057E, // Nintendo raw Home button path - SteamControllerHIDParser.valveVendorID, - ] - - let knownVendorProductPairs: [(vendorID: Int, productID: Int)] - let displayName = "Generic HID Controller" - - var matchingCriteria: [HIDMatchingCriterion] { - knownVendorProductPairs.map { - .vendorProduct(vendorID: $0.vendorID, productID: $0.productID) - } + Self.standardControllerCriteria + Self.bluetoothLECriteria - } - - static let standardControllerCriteria: [HIDMatchingCriterion] = [ - .usage(page: Int(kHIDPage_GenericDesktop), usage: Int(kHIDUsage_GD_Joystick)), - .usage(page: Int(kHIDPage_GenericDesktop), usage: Int(kHIDUsage_GD_GamePad)), - .usage(page: Int(kHIDPage_GenericDesktop), usage: Int(kHIDUsage_GD_MultiAxisController)), - ] - - static let bluetoothLECriteria: [HIDMatchingCriterion] = [ - .transport("BluetoothLowEnergy"), - .transport("Bluetooth Low Energy"), - ] -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift index 31522535..d3cac9db 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulator.swift @@ -30,9 +30,6 @@ protocol InputSimulatorProtocol: Sendable { func getHeldModifiers() -> CGEventFlags func moveMouse(dx: CGFloat, dy: CGFloat) func moveMouseNative(dx: Int, dy: Int) - /// Configures pointer-lock (FPS) relative mouse mode. Default implementation - /// is a no-op for conformers that don't post real mouse events. - func setPointerLockMouseMode(_ mode: PointerLockMouseMode) func warpMouseTo(point: CGPoint) var isLeftMouseButtonHeld: Bool { get } func scroll(event: ScrollEvent) @@ -43,8 +40,6 @@ protocol InputSimulatorProtocol: Sendable { } extension InputSimulatorProtocol { - func setPointerLockMouseMode(_ mode: PointerLockMouseMode) {} - func scroll(dx: CGFloat, dy: CGFloat) { scroll(event: ScrollEvent(dx: dx, dy: dy)) } @@ -64,7 +59,7 @@ extension InputSimulatorProtocol { } func holdModifiers(_ modifiers: ModifierFlags) { - for mask in ModifierKeyEmissionPolicy.modifierPressOrder where modifiers.cgEventFlags.contains(mask) { + for mask in ModifierKeyState.modifierPressOrder where modifiers.cgEventFlags.contains(mask) { if let keyCode = modifiers.virtualKey(forMask: mask) { holdModifierKey(keyCode) } @@ -72,7 +67,7 @@ extension InputSimulatorProtocol { } func releaseModifiers(_ modifiers: ModifierFlags) { - for mask in ModifierKeyEmissionPolicy.modifierReleaseOrder where modifiers.cgEventFlags.contains(mask) { + for mask in ModifierKeyState.modifierReleaseOrder where modifiers.cgEventFlags.contains(mask) { if let keyCode = modifiers.virtualKey(forMask: mask) { releaseModifierKey(keyCode) } @@ -80,12 +75,51 @@ extension InputSimulatorProtocol { } } +// MARK: - Modifier Key Handler + +/// Helper class to manage modifier key reference counting and state +private class ModifierKeyState { + /// Maps modifier mask to virtual key code + static let maskToKeyCode: [UInt64: Int] = [ + CGEventFlags.maskCommand.rawValue: kVK_Command, + CGEventFlags.maskAlternate.rawValue: kVK_Option, + CGEventFlags.maskShift.rawValue: kVK_Shift, + CGEventFlags.maskControl.rawValue: kVK_Control + ] + + /// List of modifier masks in order for iteration + static let modifierMasks: [CGEventFlags] = [ + .maskCommand, .maskAlternate, .maskShift, .maskControl + ] + + static let modifierPressOrder: [CGEventFlags] = [ + .maskCommand, .maskShift, .maskAlternate, .maskControl + ] + + static let modifierReleaseOrder: [CGEventFlags] = [ + .maskControl, .maskAlternate, .maskShift, .maskCommand + ] +} + + /// Service for simulating keyboard and mouse input via CGEvent and IOHIDPostEvent. class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { + /// Static tracked cursor position for use by other services (e.g., ActionFeedbackIndicator) + /// when Accessibility Zoom is active. Access via getTrackedCursorPosition(). + private static var sharedTrackedPosition: CGPoint? + private static var sharedLastMoveTime: CFAbsoluteTime = 0 + private static var sharedLock = NSLock() + + /// Accumulated movement delta since last consumption (for hint positioning during zoom) + private static var accumulatedDelta: CGPoint = .zero + /// Returns the tracked cursor position if available and Accessibility Zoom is active, /// otherwise returns nil (caller should fall back to NSEvent.mouseLocation) static func getTrackedCursorPosition() -> CGPoint? { - InputSimulatorCursorState.trackedPositionIfZoomEnabled() + guard UAZoomEnabled() else { return nil } + sharedLock.lock() + defer { sharedLock.unlock() } + return sharedTrackedPosition } /// Returns true if cursor was moved very recently by the controller @@ -94,48 +128,89 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { /// The short 50ms window skips the immediate unreliable reading but allows /// frequent updates to follow the cursor during long drags. static func isCursorBeingMoved() -> Bool { - InputSimulatorCursorState.isCursorBeingMoved() + sharedLock.lock() + defer { sharedLock.unlock() } + return CFAbsoluteTimeGetCurrent() - sharedLastMoveTime < 0.05 } /// Consumes and returns the accumulated movement delta since last call. /// Used by ActionFeedbackIndicator to apply relative movement during zoom. /// Delta is in screen points (positive X = right, positive Y = down in CG coords). static func consumeMovementDelta() -> CGPoint { - InputSimulatorCursorState.consumeMovementDelta() + sharedLock.lock() + let delta = accumulatedDelta + accumulatedDelta = .zero + sharedLock.unlock() + return delta } /// Resets the accumulated movement delta without consuming it. /// Called when resyncing hint position to absolute coordinates. static func resetMovementDelta() { - InputSimulatorCursorState.resetMovementDelta() + sharedLock.lock() + accumulatedDelta = .zero + sharedLock.unlock() } /// Returns the current Accessibility Zoom level (1.0 = no zoom, 2.0 = 2x zoom, etc.) static func getZoomLevel() -> CGFloat { - InputSimulatorCursorState.zoomLevel() + CGFloat(UserDefaults(suiteName: "com.apple.universalaccess")?.double(forKey: "closeViewZoomFactor") ?? 1.0) } /// Returns whether Accessibility Zoom is currently active by checking UserDefaults directly. /// More reliable than `UAZoomEnabled()` which may return stale results on some macOS versions. /// Result is cached for 0.5s to avoid expensive inter-process UserDefaults reads at 120Hz. + private static var cachedZoomActive: Bool = false + private static var cachedZoomCheckTime: CFAbsoluteTime = 0 + private static let zoomCacheInterval: CFAbsoluteTime = 0.5 + /// Lock protecting zoom cache static vars (cachedZoomActive, cachedZoomCheckTime) + private static let zoomCacheLock = NSLock() + static func isZoomCurrentlyActive() -> Bool { - InputSimulatorCursorState.isZoomCurrentlyActive() + let now = CFAbsoluteTimeGetCurrent() + zoomCacheLock.lock() + if now - cachedZoomCheckTime < zoomCacheInterval { + let cached = cachedZoomActive + zoomCacheLock.unlock() + return cached + } + cachedZoomCheckTime = now + zoomCacheLock.unlock() + + // UserDefaults read is thread-safe and potentially slow -- do it outside the lock + let defaults = UserDefaults(suiteName: "com.apple.universalaccess") + let active = (defaults?.bool(forKey: "closeViewZoomedIn") ?? false) + && (defaults?.double(forKey: "closeViewZoomFactor") ?? 1.0) > 1.0 + + zoomCacheLock.lock() + cachedZoomActive = active + zoomCacheLock.unlock() + return active } /// Returns the last tracked cursor position regardless of zoom state. /// Unlike `getTrackedCursorPosition()`, this does not gate on `UAZoomEnabled()`. /// Used by overlay indicators that manage their own zoom detection via UserDefaults. static func getLastTrackedPosition() -> CGPoint? { - InputSimulatorCursorState.lastTrackedPosition() + sharedLock.lock() + defer { sharedLock.unlock() } + return sharedTrackedPosition } static func getLastTrackedPositionSnapshot() -> (position: CGPoint?, lastMoveTime: CFAbsoluteTime) { - InputSimulatorCursorState.lastTrackedPositionSnapshot() + sharedLock.lock() + defer { sharedLock.unlock() } + return (sharedTrackedPosition, sharedLastMoveTime) } /// Updates the shared tracked position (called from moveMouse) private static func updateSharedTrackedPosition(_ point: CGPoint?, delta: CGPoint = .zero) { - InputSimulatorCursorState.updateTrackedPosition(point, delta: delta) + sharedLock.lock() + sharedTrackedPosition = point + sharedLastMoveTime = CFAbsoluteTimeGetCurrent() + accumulatedDelta.x += delta.x + accumulatedDelta.y += delta.y + sharedLock.unlock() } private let eventSource: CGEventSource? @@ -175,134 +250,6 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { /// Lock for protecting shared state private let stateLock = NSLock() - // MARK: - Pointer-Lock (FPS) Relative Mouse Mode - - /// Configured mode; pushed in by MappingEngine on profile/settings changes. (stateLock) - private var pointerLockMouseMode: PointerLockMouseMode = .auto - /// Throttled cursor-visibility poll state. (stateLock) - private var lastCursorVisibilityPollTime: CFAbsoluteTime? - private var cachedCursorVisible: Bool? - private var cachedAppInitiatedCursorHide = false - - /// Last logged (decision, cursorVisible) for state-change diagnostics. (stateLock) - private var lastLoggedRelativeModeState: (decision: Bool, cursorVisible: Bool?)? - /// Throttle for the liveness trace line. (stateLock) - private var lastRelativeModeTraceTime: CFAbsoluteTime = 0 - - /// Opt-in support diagnostics: `defaults write KevinTang.XboxControllerMapper - /// pointerLockTraceLogging -bool true` then relaunch. NSLog from the release app - /// doesn't reach the unified log reliably, so pointer-lock diagnostics append to - /// /tmp/controllerkeys-pointerlock-trace.log instead. - private static let pointerLockTraceEnabled = UserDefaults.standard.bool(forKey: "pointerLockTraceLogging") - - private static func pointerLockTrace(_ message: String) { - guard pointerLockTraceEnabled else { return } - let line = "\(Date()) \(message)\n" - if let data = line.data(using: .utf8), - let handle = FileHandle(forWritingAtPath: "/tmp/controllerkeys-pointerlock-trace.log") { - handle.seekToEndOfFile() - handle.write(data) - handle.closeFile() - } else { - FileManager.default.createFile( - atPath: "/tmp/controllerkeys-pointerlock-trace.log", - contents: line.data(using: .utf8) - ) - } - } - - func setPointerLockMouseMode(_ mode: PointerLockMouseMode) { - stateLock.lock() - pointerLockMouseMode = mode - // Force a fresh visibility poll on the next move so mode changes apply immediately. - lastCursorVisibilityPollTime = nil - stateLock.unlock() - Self.pointerLockTrace("mode set: \(mode.rawValue)") - } - - /// Decides whether the current move should post relative deltas instead of the - /// absolute tracked/clamped path. Caller must hold `stateLock`. - private func shouldUseRelativeMouseMovementLocked(now: CFAbsoluteTime, zoomActive: Bool) -> Bool { - let mode = pointerLockMouseMode - guard mode != .off else { return false } - if PointerLockMousePolicy.shouldRefreshCursorVisibility(now: now, lastPoll: lastCursorVisibilityPollTime) { - lastCursorVisibilityPollTime = now - cachedCursorVisible = CursorVisibility.isCursorVisible() - cachedAppInitiatedCursorHide = UserDefaults.standard.bool( - forKey: Config.onScreenKeyboardCursorHiddenDefaultsKey - ) - } - let decision = PointerLockMousePolicy.shouldUseRelativeMovement( - mode: mode, - cursorVisible: cachedCursorVisible, - zoomActive: zoomActive, - universalControlRelayActive: universalControlRelayActive, - appInitiatedCursorHide: cachedAppInitiatedCursorHide - ) - let stateChanged = lastLoggedRelativeModeState?.decision != decision - || lastLoggedRelativeModeState?.cursorVisible != cachedCursorVisible - if stateChanged || now - lastRelativeModeTraceTime > 1.0 { - lastLoggedRelativeModeState = (decision, cachedCursorVisible) - lastRelativeModeTraceTime = now - Self.pointerLockTrace( - "decision: mode=\(mode.rawValue)" - + " cursorVisible=\(cachedCursorVisible.map { $0 ? "yes" : "no" } ?? "unknown")" - + " appHide=\(cachedAppInitiatedCursorHide)" - + " relay=\(universalControlRelayActive)" - + " zoom=\(zoomActive)" - + " -> relative=\(decision)\(stateChanged ? " [CHANGED]" : "")" - ) - } - return decision - } - - /// Posts a delta-only mouse event at the current cursor position. Under pointer - /// lock the app receives movementX/Y 1:1, the cursor stays pinned, and the lock - /// stays engaged (verified against Chrome's Pointer Lock API 2026-07-02). - private func postRelativeMouseMove(dx: Int, dy: Int, heldButtons: Set, eventNumber: Int64) { - let eventType: CGEventType - let mouseButton: CGMouseButton - if heldButtons.contains(.left) { - eventType = .leftMouseDragged - mouseButton = .left - } else if heldButtons.contains(.right) { - eventType = .rightMouseDragged - mouseButton = .right - } else if heldButtons.contains(.center) { - eventType = .otherMouseDragged - mouseButton = .center - } else { - eventType = .mouseMoved - mouseButton = .left - } - - let cursorPos: CGPoint - if let locEvent = CGEvent(source: nil) { - cursorPos = locEvent.location - } else { - let ns = NSEvent.mouseLocation - let screenH = NSScreen.main?.frame.height ?? 1080 - cursorPos = CGPoint(x: ns.x, y: screenH - ns.y) - } - - guard let event = CGEvent( - mouseEventSource: eventSource, - mouseType: eventType, - mouseCursorPosition: cursorPos, - mouseButton: mouseButton - ) else { - NSLog("[InputSimulator] Failed to create relative mouse move event - check Accessibility permissions") - return - } - event.setIntegerValueField(.mouseEventDeltaX, value: Int64(dx)) - event.setIntegerValueField(.mouseEventDeltaY, value: Int64(dy)) - if eventType != .mouseMoved { - event.setIntegerValueField(.mouseEventNumber, value: eventNumber) - event.setDoubleValueField(.mouseEventPressure, value: 1.0) - } - event.post(tap: .cghidEventTap) - } - // MARK: - Keyboard Simulation /// Simulates a key press with optional modifiers @@ -317,8 +264,17 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { /// Picks the modifier pre-press keycode for a given mask. Honors `modifierSides` /// when supplied; otherwise defaults to the Left variant (existing behavior). - private static func modifierVirtualKey(for mask: CGEventFlags, sides: ModifierFlags?) -> CGKeyCode { - ModifierKeyEmissionPolicy.keyCode(for: mask, sides: sides) ?? 0 + private static func modifierVirtualKey(for mask: CGEventFlags, sides: ModifierFlags?) -> Int { + if let sides, let keyCode = sides.virtualKey(forMask: mask) { + return Int(keyCode) + } + switch mask { + case .maskCommand: return kVK_Command + case .maskAlternate: return kVK_Option + case .maskShift: return kVK_Shift + case .maskControl: return kVK_Control + default: return 0 + } } private func pressKeyInternal(_ keyCode: CGKeyCode, modifiers: CGEventFlags, modifierSides: ModifierFlags?) { @@ -373,7 +329,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { func pressMod(flag: CGEventFlags) { let key = InputSimulator.modifierVirtualKey(for: flag, sides: modifierSides) currentFlags.insert(flag) - self.postKeyEvent(keyCode: key, keyDown: true, flags: currentFlags) + self.postKeyEvent(keyCode: CGKeyCode(key), keyDown: true, flags: currentFlags) usleep(Config.modifierPressDelay) } @@ -402,7 +358,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { func releaseMod(flag: CGEventFlags) { let key = InputSimulator.modifierVirtualKey(for: flag, sides: modifierSides) currentFlags.remove(flag) - self.postKeyEvent(keyCode: key, keyDown: false, flags: currentFlags) + self.postKeyEvent(keyCode: CGKeyCode(key), keyDown: false, flags: currentFlags) usleep(Config.modifierPressDelay) } @@ -550,7 +506,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { stateLock.lock() defer { stateLock.unlock() } - for mask in ModifierKeyEmissionPolicy.modifierMasks where modifier.contains(mask) { + for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { let key = mask.rawValue let count = modifierCounts[key] ?? 0 modifierCounts[key] = count + 1 @@ -558,9 +514,9 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { if count == 0 { // First time this modifier is being held heldModifiers.insert(mask) - if let vKey = ModifierKeyEmissionPolicy.defaultKeyCode(forRawMask: key) { - modifierHeldKeyCodes[key] = vKey - if let event = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true) { + if let vKey = ModifierKeyState.maskToKeyCode[key] { + modifierHeldKeyCodes[key] = CGKeyCode(vKey) + if let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(vKey), keyDown: true) { event.flags = heldModifiers event.post(tap: .cghidEventTap) } else { @@ -585,7 +541,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { stateLock.lock() defer { stateLock.unlock() } - for mask in ModifierKeyEmissionPolicy.modifierMasks where modifier.contains(mask) { + for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { let key = mask.rawValue let count = modifierCounts[key] ?? 0 guard count > 0 else { @@ -595,7 +551,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { if heldModifiers.contains(mask) { NSLog("[InputSimulator] WARNING: modifier 0x%llx in heldModifiers but count is 0 — force-removing to prevent stuck modifier", key) heldModifiers.remove(mask) - let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyEmissionPolicy.defaultKeyCode(forRawMask: key) + let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyState.maskToKeyCode[key].map(CGKeyCode.init) modifierHeldKeyCodes[key] = nil if let releaseKeyCode { if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { @@ -611,7 +567,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { if count == 1 { // Last button holding this modifier released heldModifiers.remove(mask) - let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyEmissionPolicy.defaultKeyCode(forRawMask: key) + let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyState.maskToKeyCode[key].map(CGKeyCode.init) modifierHeldKeyCodes[key] = nil if let releaseKeyCode { if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { @@ -746,10 +702,10 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { defer { stateLock.unlock() } // Post key-up events for each modifier that is currently held - for mask in ModifierKeyEmissionPolicy.modifierMasks where heldModifiers.contains(mask) { + for mask in ModifierKeyState.modifierMasks where heldModifiers.contains(mask) { let key = mask.rawValue heldModifiers.remove(mask) - let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyEmissionPolicy.defaultKeyCode(forRawMask: key) + let releaseKeyCode = modifierHeldKeyCodes[key] ?? ModifierKeyState.maskToKeyCode[key].map(CGKeyCode.init) modifierHeldKeyCodes[key] = nil if let releaseKeyCode { if let event = CGEvent(keyboardEventSource: source, virtualKey: releaseKeyCode, keyDown: false) { @@ -773,12 +729,12 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { stateLock.lock() defer { stateLock.unlock() } - for mask in ModifierKeyEmissionPolicy.modifierMasks where modifier.contains(mask) { + for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { let key = mask.rawValue let count = modifierCounts[key] ?? 0 modifierCounts[key] = count + 1 - if count == 0, let vKey = ModifierKeyEmissionPolicy.defaultKeyCode(forRawMask: key) { - modifierHeldKeyCodes[key] = vKey + if count == 0, let vKey = ModifierKeyState.maskToKeyCode[key] { + modifierHeldKeyCodes[key] = CGKeyCode(vKey) } heldModifiers.insert(mask) } @@ -789,7 +745,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { stateLock.lock() defer { stateLock.unlock() } - for mask in ModifierKeyEmissionPolicy.modifierMasks where modifier.contains(mask) { + for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { let key = mask.rawValue let count = modifierCounts[key] ?? 0 modifierCounts[key] = count + 1 @@ -804,7 +760,7 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { stateLock.lock() defer { stateLock.unlock() } - for mask in ModifierKeyEmissionPolicy.modifierMasks where modifier.contains(mask) { + for mask in ModifierKeyState.modifierMasks where modifier.contains(mask) { let key = mask.rawValue let count = modifierCounts[key] ?? 0 if count <= 1 { @@ -1005,23 +961,6 @@ class InputSimulator: InputSimulatorProtocol, @unchecked Sendable { // cursor "reset" behavior when reading position each frame let now = CFAbsoluteTimeGetCurrent() let zoomActive = Self.isZoomCurrentlyActive() - - // Pointer-lock (FPS) relative mode: bypass the tracked/clamped absolute - // path entirely so aiming never dies at a screen edge. Tracking is - // cleared so absolute mode re-syncs from the system on exit. - if self.shouldUseRelativeMouseMovementLocked(now: now, zoomActive: zoomActive) { - self.trackedCursorPosition = nil - self.lastMouseMoveTime = now - self.stateLock.unlock() - self.postRelativeMouseMove( - dx: Int(moveX), - dy: Int(moveY), - heldButtons: heldButtons, - eventNumber: eventNumber - ) - return - } - let currentCGPoint: CGPoint // If there's been no movement for 2+ seconds, clear tracked position diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulatorCursorState.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulatorCursorState.swift deleted file mode 100644 index f66d922e..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/InputSimulatorCursorState.swift +++ /dev/null @@ -1,91 +0,0 @@ -import AppKit -import CoreGraphics -import Foundation -import ApplicationServices.HIServices - -enum InputSimulatorCursorState { - private static var sharedTrackedPosition: CGPoint? - private static var sharedLastMoveTime: CFAbsoluteTime = 0 - private static let sharedLock = NSLock() - - private static var accumulatedDelta: CGPoint = .zero - - private static var cachedZoomActive: Bool = false - private static var cachedZoomCheckTime: CFAbsoluteTime = 0 - private static let zoomCacheInterval: CFAbsoluteTime = 0.5 - private static let zoomCacheLock = NSLock() - - static func trackedPositionIfZoomEnabled() -> CGPoint? { - guard UAZoomEnabled() else { return nil } - sharedLock.lock() - defer { sharedLock.unlock() } - return sharedTrackedPosition - } - - static func isCursorBeingMoved() -> Bool { - sharedLock.lock() - defer { sharedLock.unlock() } - return CFAbsoluteTimeGetCurrent() - sharedLastMoveTime < 0.05 - } - - static func consumeMovementDelta() -> CGPoint { - sharedLock.lock() - let delta = accumulatedDelta - accumulatedDelta = .zero - sharedLock.unlock() - return delta - } - - static func resetMovementDelta() { - sharedLock.lock() - accumulatedDelta = .zero - sharedLock.unlock() - } - - static func zoomLevel() -> CGFloat { - CGFloat(UserDefaults(suiteName: "com.apple.universalaccess")?.double(forKey: "closeViewZoomFactor") ?? 1.0) - } - - static func isZoomCurrentlyActive() -> Bool { - let now = CFAbsoluteTimeGetCurrent() - zoomCacheLock.lock() - if now - cachedZoomCheckTime < zoomCacheInterval { - let cached = cachedZoomActive - zoomCacheLock.unlock() - return cached - } - cachedZoomCheckTime = now - zoomCacheLock.unlock() - - // UserDefaults read is thread-safe and potentially slow -- do it outside the lock. - let defaults = UserDefaults(suiteName: "com.apple.universalaccess") - let active = (defaults?.bool(forKey: "closeViewZoomedIn") ?? false) - && (defaults?.double(forKey: "closeViewZoomFactor") ?? 1.0) > 1.0 - - zoomCacheLock.lock() - cachedZoomActive = active - zoomCacheLock.unlock() - return active - } - - static func lastTrackedPosition() -> CGPoint? { - sharedLock.lock() - defer { sharedLock.unlock() } - return sharedTrackedPosition - } - - static func lastTrackedPositionSnapshot() -> (position: CGPoint?, lastMoveTime: CFAbsoluteTime) { - sharedLock.lock() - defer { sharedLock.unlock() } - return (sharedTrackedPosition, sharedLastMoveTime) - } - - static func updateTrackedPosition(_ point: CGPoint?, delta: CGPoint = .zero) { - sharedLock.lock() - sharedTrackedPosition = point - sharedLastMoveTime = CFAbsoluteTimeGetCurrent() - accumulatedDelta.x += delta.x - accumulatedDelta.y += delta.y - sharedLock.unlock() - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/ModifierKeyEmissionPolicy.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/ModifierKeyEmissionPolicy.swift deleted file mode 100644 index 62ee0e2e..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/ModifierKeyEmissionPolicy.swift +++ /dev/null @@ -1,41 +0,0 @@ -import CoreGraphics -import Carbon.HIToolbox - -/// Pure policy for deciding which physical modifier key should be emitted for -/// a modifier mask. Keeps left/right side selection testable without posting -/// CGEvents or requiring Accessibility permissions. -enum ModifierKeyEmissionPolicy { - static let modifierMasks: [CGEventFlags] = [ - .maskCommand, .maskAlternate, .maskShift, .maskControl - ] - - static let modifierPressOrder: [CGEventFlags] = [ - .maskCommand, .maskShift, .maskAlternate, .maskControl - ] - - static let modifierReleaseOrder: [CGEventFlags] = [ - .maskControl, .maskAlternate, .maskShift, .maskCommand - ] - - private static let defaultKeyCodes: [UInt64: CGKeyCode] = [ - CGEventFlags.maskCommand.rawValue: CGKeyCode(kVK_Command), - CGEventFlags.maskAlternate.rawValue: CGKeyCode(kVK_Option), - CGEventFlags.maskShift.rawValue: CGKeyCode(kVK_Shift), - CGEventFlags.maskControl.rawValue: CGKeyCode(kVK_Control) - ] - - static func defaultKeyCode(for mask: CGEventFlags) -> CGKeyCode? { - defaultKeyCode(forRawMask: mask.rawValue) - } - - static func defaultKeyCode(forRawMask rawMask: UInt64) -> CGKeyCode? { - defaultKeyCodes[rawMask] - } - - static func keyCode(for mask: CGEventFlags, sides: ModifierFlags?) -> CGKeyCode? { - if let sides, let sidedKeyCode = sides.virtualKey(forMask: mask) { - return sidedKeyCode - } - return defaultKeyCode(for: mask) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/PointerLockMousePolicy.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/PointerLockMousePolicy.swift deleted file mode 100644 index 1be2cc1a..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/PointerLockMousePolicy.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -import CoreGraphics - -/// How controller/touchpad mouse movement is posted when a game captures the mouse. -/// -/// Browser pointer-lock games (Pointer Lock API) and native FPS games hide the -/// system cursor and consume relative mouse deltas for unlimited 360° aiming. -/// The absolute-position mouse path clamps to screen bounds, so aiming dies at -/// the screen edge. Relative mode instead posts delta-only mouse events at the -/// current cursor position: apps receive movement 1:1, the cursor stays pinned, -/// and pointer lock stays engaged. -enum PointerLockMouseMode: String, Codable, CaseIterable, Sendable { - /// Relative movement while the system cursor is hidden (pointer lock hides it). - case auto - /// Never use relative movement (legacy absolute-only behavior). - case off - /// Always use relative movement. The cursor never moves from controller input; - /// intended for per-app game profiles. - case always - - var displayName: String { - switch self { - case .auto: return "Auto" - case .off: return "Off" - case .always: return "Always" - } - } -} - -/// Pure policy for when mouse movement should be posted as relative deltas. -struct PointerLockMousePolicy { - /// Cursor-visibility is a WindowServer query; poll it at most this often. - static let cursorVisibilityPollInterval: CFTimeInterval = 0.25 - - /// Returns true when movement should bypass the absolute tracked/clamped path - /// and post delta-only events. - /// - /// - Accessibility Zoom always wins: its viewport panning and IOHID click - /// targeting need the absolute path. - /// - An active Universal Control relay session wins: edge handoff routes - /// movement to the remote Mac. - /// - `appInitiatedCursorHide` guards the `auto` heuristic against the app's own - /// cursor hide (on-screen keyboard navigation mode), which would otherwise - /// read as a pointer-lock game. It does not suppress `always`. - /// - `cursorVisible == nil` means detection is unavailable (symbol no longer - /// resolvable); `auto` then behaves like `off` and only `always` engages. - static func shouldUseRelativeMovement( - mode: PointerLockMouseMode, - cursorVisible: Bool?, - zoomActive: Bool, - universalControlRelayActive: Bool, - appInitiatedCursorHide: Bool - ) -> Bool { - guard !zoomActive, !universalControlRelayActive else { return false } - switch mode { - case .off: - return false - case .always: - return true - case .auto: - return cursorVisible == false && !appInitiatedCursorHide - } - } - - /// Throttles the cursor-visibility poll to `cursorVisibilityPollInterval`. - static func shouldRefreshCursorVisibility( - now: CFTimeInterval, - lastPoll: CFTimeInterval? - ) -> Bool { - guard let lastPoll else { return true } - return now - lastPoll >= cursorVisibilityPollInterval - } -} - -/// Global cursor visibility via `CGCursorIsVisible`. -/// -/// The function is marked unavailable in the macOS 26 SDK but is still exported -/// at runtime (CoreGraphics re-exports SkyLight's `SLCursorIsVisible`), so it is -/// resolved with `dlsym`. Verified 2026-07-02: it flips to hidden the moment a -/// browser engages the Pointer Lock API and restores on release. If Apple drops -/// the export, `isCursorVisible()` returns nil and auto-detection degrades to -/// the manual `always` mode. -enum CursorVisibility { - private typealias CursorVisibleFn = @convention(c) () -> boolean_t - - private static let resolvedFn: CursorVisibleFn? = { - for name in ["CGCursorIsVisible", "SLCursorIsVisible"] { - if let symbol = dlsym(dlopen(nil, RTLD_NOW), name) { - return unsafeBitCast(symbol, to: CursorVisibleFn.self) - } - } - NSLog("[CursorVisibility] CGCursorIsVisible/SLCursorIsVisible unavailable - pointer-lock auto-detection disabled") - return nil - }() - - static var isDetectionSupported: Bool { resolvedFn != nil } - - /// nil when detection is unavailable on this OS. - static func isCursorVisible() -> Bool? { - guard let fn = resolvedFn else { return nil } - return fn() != 0 - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/SystemCommandExecutor.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/SystemCommandExecutor.swift index 3341feed..28ab703d 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/SystemCommandExecutor.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Input/SystemCommandExecutor.swift @@ -88,24 +88,6 @@ class SystemCommandExecutor: @unchecked Sendable { case .obsWebSocket(let url, let password, let requestType, let requestData): executeOBSWebSocket(url: url, password: password, requestType: requestType, requestData: requestData) - case .centerOuraRing: - executeOuraRingCommand(.centerRing) - case .toggleOuraMotion: - executeOuraRingCommand(.toggleMouseControl) - } - } - - private func executeOuraRingCommand(_ option: OuraRingSystemCommandOption) { - let handled: Bool - switch option { - case .centerRing: - handled = OuraRingCommandCenter.shared.centerRing() - case .toggleMouseControl: - handled = OuraRingCommandCenter.shared.toggleMotionOutput() - } - - if !handled { - NSLog("[SystemCommand] Oura Ring command ignored because the ring service is unavailable: %@", option.displayName) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift b/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift index 15ecd7cb..40e97b37 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Input/UniversalControlMouseRelay.swift @@ -1405,9 +1405,10 @@ final class UniversalControlMouseRelay: @unchecked Sendable { NSLog("[UCMouseRelay] Could not run tailscale status: %@", String(describing: error)) return [] } - let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0 else { return [] } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let peers = object["Peer"] as? [String: Any] else { return [] diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonMappingResolutionPolicy.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonMappingResolutionPolicy.swift index 4e2ba4f0..6206f7f1 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonMappingResolutionPolicy.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ButtonMappingResolutionPolicy.swift @@ -92,13 +92,6 @@ enum ButtonMappingResolutionPolicy { private static func shouldPreservePhysicalButton( _ button: ControllerButton, profile: Profile - ) -> Bool { - hasExplicitBinding(for: button, profile: profile) - } - - static func hasExplicitBinding( - for button: ControllerButton, - profile: Profile ) -> Bool { if profile.buttonMappings[button] != nil { return true @@ -109,9 +102,6 @@ enum ButtonMappingResolutionPolicy { if profile.sequenceMappings.contains(where: { $0.steps.contains(button) }) { return true } - if profile.layers.contains(where: { $0.activatorButton == button || $0.buttonMappings[button] != nil }) { - return true - } return false } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ControllerInputEvent.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ControllerInputEvent.swift deleted file mode 100644 index 8fdd3d12..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ControllerInputEvent.swift +++ /dev/null @@ -1,19 +0,0 @@ -import CoreGraphics -import Foundation - -enum ControllerInputEvent: Equatable { - case buttonPressed(ControllerButton) - case buttonReleased(ControllerButton, holdDuration: TimeInterval) - case chordDetected(Set) - case touchpadMoved(CGPoint) - case steamLeftTouchpadMoved(CGPoint) - case appleTVRemoteCircularScroll(CGFloat) - case touchpadGesture(TouchpadGesture) - case touchpadTap - case controllerButtonTap(ControllerButton) - case touchpadTwoFingerTap - case touchpadLongTap - case touchpadTwoFingerLongTap - case touchpadRegionTap(TouchpadRegion) - case motionGesture(MotionGestureType) -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ControllerInputEventRouting.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ControllerInputEventRouting.swift deleted file mode 100644 index 218dfe8d..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/ControllerInputEventRouting.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -enum ControllerInputEventQueue { - case input - case polling -} - -enum ControllerInputEventRouting { - static func queue(for event: ControllerInputEvent) -> ControllerInputEventQueue { - switch event { - case .touchpadMoved, - .steamLeftTouchpadMoved, - .appleTVRemoteCircularScroll, - .touchpadGesture, - .touchpadTap, - .touchpadTwoFingerTap, - .touchpadLongTap, - .touchpadTwoFingerLongTap: - return .polling - case .buttonPressed, - .buttonReleased, - .chordDetected, - .controllerButtonTap, - .touchpadRegionTap, - .motionGesture: - return .input - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickDirectionResolver.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickDirectionResolver.swift index ad19cb04..5a460ff5 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickDirectionResolver.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickDirectionResolver.swift @@ -5,9 +5,9 @@ enum JoystickDirectionResolver { static func activeButtons( stick: CGPoint, side: JoystickSide, - tuning: StickTuning + settings: JoystickSettings ) -> Set { - let config = customConfig(side: side, tuning: tuning) + let config = customConfig(side: side, settings: settings) return activeDirections( stick: stick, deadzone: config.deadzone, @@ -61,10 +61,10 @@ enum JoystickDirectionResolver { static func activeAxisButtons( stick: CGPoint, side: JoystickSide, - tuning: StickTuning, + settings: JoystickSettings, threshold: Double = 0.4 ) -> Set { - let config = customConfig(side: side, tuning: tuning) + let config = customConfig(side: side, settings: settings) return activeAxisButtons( stick: stick, side: side, @@ -102,20 +102,28 @@ enum JoystickDirectionResolver { return directions } - private static func customConfig(side: JoystickSide, tuning: StickTuning) -> ( + private static func customConfig(side: JoystickSide, settings: JoystickSettings) -> ( deadzone: Double, horizontalSliceSize: Double, verticalSliceSize: Double, invertY: Bool ) { - // Custom-direction invert still tracks the stick's mouse (left) vs. scroll - // (right) invert toggle, matching the custom-direction panel's binding. - return ( - deadzone: tuning.customDeadzone, - horizontalSliceSize: tuning.customHorizontalSliceSize, - verticalSliceSize: tuning.customVerticalSliceSize, - invertY: side == .left ? tuning.invertMouseY : tuning.invertScrollY - ) + switch side { + case .left: + return ( + deadzone: settings.leftStickCustomDeadzone, + horizontalSliceSize: settings.leftStickCustomHorizontalSliceSize, + verticalSliceSize: settings.leftStickCustomVerticalSliceSize, + invertY: settings.invertMouseY + ) + case .right: + return ( + deadzone: settings.rightStickCustomDeadzone, + horizontalSliceSize: settings.rightStickCustomHorizontalSliceSize, + verticalSliceSize: settings.rightStickCustomVerticalSliceSize, + invertY: settings.invertScrollY + ) + } } private static func activeCardinalDirection( diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift index 20907cec..ac48e7ac 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickHandler.swift @@ -66,10 +66,6 @@ extension MappingEngine { // Single lock acquisition for all controller input state (cache-friendly snapshot) let controllerSnapshot = controllerService.snapshot() let leftStick = controllerSnapshot.leftStick - let analogPrecisionMultiplier = settings.analogPrecisionMultiplier( - leftTrigger: Double(controllerSnapshot.leftTrigger), - rightTrigger: Double(controllerSnapshot.rightTrigger) - ) processGyroAiming( settings: settings, now: now, @@ -216,15 +212,11 @@ extension MappingEngine { return } - // Resolve effective per-side tuning: active layer override (if any) is - // overlaid onto the profile's base stick tuning, per field. Mode is part - // of that tuning, so this also resolves the effective stick mode. + // Resolve effective per-side modes: active layer override (if any) wins over profile default. // Lock is held for the whole function, so reading state.activeLayerIds + state.layersById here is safe. let activeLayer = state.activeLayerIds.last.flatMap { state.layersById[$0] } - let leftTuning = activeLayer?.leftStickTuning?.applied(to: settings.leftStick) ?? settings.leftStick - let rightTuning = activeLayer?.rightStickTuning?.applied(to: settings.rightStick) ?? settings.rightStick - let effectiveLeftMode = leftTuning.mode - let effectiveRightMode = rightTuning.mode + let effectiveLeftMode = activeLayer?.leftStickModeOverride ?? settings.leftStickMode + let effectiveRightMode = activeLayer?.rightStickModeOverride ?? settings.rightStickMode if JoystickHandlerDiagnostics.pollDumpEnabled, abs(leftStick.x) > 0.05 || abs(leftStick.y) > 0.05 { @@ -236,8 +228,6 @@ extension MappingEngine { stick: leftStick, side: .left, settings: settings, - tuning: leftTuning, - analogPrecisionMultiplier: analogPrecisionMultiplier, dt: dt, now: now ) @@ -255,8 +245,6 @@ extension MappingEngine { stick: rightStick, side: .right, settings: settings, - tuning: rightTuning, - analogPrecisionMultiplier: analogPrecisionMultiplier, dt: dt, now: now ) @@ -270,7 +258,7 @@ extension MappingEngine { /// Uses a deadzone + throttle approach: the first deflection triggers immediately, /// then repeats at the D-pad repeat interval while held. nonisolated func processDirectoryNavigatorStick(_ stick: CGPoint, now: CFAbsoluteTime, deferring deferredIO: inout [() -> Void]) { - let deadzone: Double = state.joystickSettings?.rightStick.mouseDeadzone ?? 0.4 + let deadzone: Double = state.joystickSettings?.mouseDeadzone ?? 0.4 let magnitude = sqrt(Double(stick.x * stick.x + stick.y * stick.y)) guard magnitude > deadzone else { @@ -347,8 +335,8 @@ extension MappingEngine { // MARK: - Scroll Double-Tap Boost - nonisolated func updateScrollDoubleTapState(rawStick: CGPoint, tuning: StickTuning, now: TimeInterval) { - let deadzone = tuning.scrollDeadzone + nonisolated func updateScrollDoubleTapState(rawStick: CGPoint, settings: JoystickSettings, now: TimeInterval) { + let deadzone = settings.scrollDeadzone let magnitudeSquared = rawStick.x * rawStick.x + rawStick.y * rawStick.y let deadzoneSquared = deadzone * deadzone let isOutside = magnitudeSquared > deadzoneSquared @@ -506,13 +494,7 @@ extension MappingEngine { } } - nonisolated func processMouseMovement( - _ stick: CGPoint, - tuning: StickTuning, - settings: JoystickSettings, - analogPrecisionMultiplier: Double, - now: CFAbsoluteTime - ) { + nonisolated func processMouseMovement(_ stick: CGPoint, settings: JoystickSettings, now: CFAbsoluteTime) { let focusFlags = settings.focusModeModifier.cgEventFlags let isFocusActive = focusFlags.rawValue != 0 && inputSimulator.isHoldingModifiers(focusFlags) @@ -521,14 +503,13 @@ extension MappingEngine { } guard let magnitude = JoystickMath.circularDeadzone( - x: Double(stick.x), y: Double(stick.y), deadzone: tuning.mouseDeadzone + x: Double(stick.x), y: Double(stick.y), deadzone: settings.mouseDeadzone ) else { return } - let normalizedMagnitude = JoystickMath.normalizedMagnitude(magnitude, deadzone: tuning.mouseDeadzone) - let acceleratedMagnitude = pow(normalizedMagnitude, tuning.mouseAccelerationExponent) + let normalizedMagnitude = JoystickMath.normalizedMagnitude(magnitude, deadzone: settings.mouseDeadzone) + let acceleratedMagnitude = pow(normalizedMagnitude, settings.mouseAccelerationExponent) - let baseTargetMultiplier = isFocusActive ? settings.focusMultiplier : tuning.mouseMultiplier - let targetMultiplier = baseTargetMultiplier * analogPrecisionMultiplier + let targetMultiplier = isFocusActive ? settings.focusMultiplier : settings.mouseMultiplier if state.currentMultiplier == 0 { state.currentMultiplier = targetMultiplier @@ -540,7 +521,7 @@ extension MappingEngine { let dx = stick.x * scale var dy = stick.y * scale - dy = tuning.invertMouseY ? dy : -dy + dy = settings.invertMouseY ? dy : -dy inputSimulator.moveMouse(dx: dx, dy: dy) usageStatsService?.recordJoystickMouseDistance(dx: Double(dx), dy: Double(dy)) @@ -548,14 +529,14 @@ extension MappingEngine { // MARK: - Scrolling - nonisolated func processScrolling(_ stick: CGPoint, rawStick: CGPoint, tuning: StickTuning, settings: JoystickSettings, now: TimeInterval) { + nonisolated func processScrolling(_ stick: CGPoint, rawStick: CGPoint, settings: JoystickSettings, now: TimeInterval) { guard let magnitude = JoystickMath.circularDeadzone( - x: Double(stick.x), y: Double(stick.y), deadzone: tuning.scrollDeadzone + x: Double(stick.x), y: Double(stick.y), deadzone: settings.scrollDeadzone ) else { return } - let normalizedMag = JoystickMath.normalizedMagnitude(magnitude, deadzone: tuning.scrollDeadzone) - let acceleratedMagnitude = pow(normalizedMag, tuning.scrollAccelerationExponent) - let scale = acceleratedMagnitude * tuning.scrollMultiplier / magnitude + let normalizedMag = JoystickMath.normalizedMagnitude(magnitude, deadzone: settings.scrollDeadzone) + let acceleratedMagnitude = pow(normalizedMag, settings.scrollAccelerationExponent) + let scale = acceleratedMagnitude * settings.scrollMultiplier / magnitude let effectiveX = JoystickMath.scrollEffectiveX( stickX: Double(stick.x), stickY: Double(stick.y), @@ -565,7 +546,7 @@ extension MappingEngine { let dx = -effectiveX * scale var dy = stick.y * scale - dy = tuning.invertScrollY ? -dy : dy + dy = settings.invertScrollY ? -dy : dy if state.scrollBoostDirection != 0, (rawStick.y >= 0 ? 1 : -1) == state.scrollBoostDirection { @@ -663,11 +644,11 @@ extension MappingEngine { nonisolated func processDPadDirectionButtons( stick: CGPoint, side: JoystickSide, - tuning: StickTuning, + settings: JoystickSettings, heldButtons: inout Set ) { - let deadzone = side == .left ? tuning.mouseDeadzone : tuning.scrollDeadzone - let invertY = side == .left ? tuning.invertMouseY : tuning.invertScrollY + let deadzone = side == .left ? settings.mouseDeadzone : settings.scrollDeadzone + let invertY = side == .left ? settings.invertMouseY : settings.invertScrollY let directions = JoystickDirectionResolver.activeAxisDirections( stick: stick, deadzone: deadzone, @@ -695,22 +676,22 @@ extension MappingEngine { nonisolated func processCustomDirectionButtons( stick: CGPoint, side: JoystickSide, - tuning: StickTuning, + settings: JoystickSettings, heldButtons: inout Set ) { let targetButtons = customDirectionMappingsUseAxisMovement(side: side) ? JoystickDirectionResolver.activeAxisButtons( stick: stick, side: side, - tuning: tuning + settings: settings ) : JoystickDirectionResolver.activeButtons( stick: stick, side: side, - tuning: tuning + settings: settings ) updateHeldDirectionButtons(targetButtons, heldButtons: &heldButtons) - processCustomDirectionScrollActions(targetButtons, stick: stick, side: side, tuning: tuning) + processCustomDirectionScrollActions(targetButtons, stick: stick, side: side, settings: settings) } nonisolated private func customDirectionMappingsUseAxisMovement(side: JoystickSide) -> Bool { @@ -734,7 +715,7 @@ extension MappingEngine { _ targetButtons: Set, stick: CGPoint, side: JoystickSide, - tuning: StickTuning + settings: JoystickSettings ) { guard let profile = state.activeProfile else { return } @@ -752,7 +733,7 @@ extension MappingEngine { button: button, stick: stick, side: side, - tuning: tuning, + settings: settings, scrollActionSettings: mapping.scrollActionSettings ) else { @@ -781,12 +762,18 @@ extension MappingEngine { button: ControllerButton, stick: CGPoint, side: JoystickSide, - tuning: StickTuning, + settings: JoystickSettings, scrollActionSettings: ScrollActionSettings? ) -> CGFloat? { guard let direction = button.joystickDirection else { return nil } - let deadzone = tuning.customDeadzone + let deadzone: Double + switch side { + case .left: + deadzone = settings.leftStickCustomDeadzone + case .right: + deadzone = settings.rightStickCustomDeadzone + } let axisMagnitude: Double switch direction { @@ -800,8 +787,8 @@ extension MappingEngine { guard axisMagnitude > deadzone else { return nil } let normalized = min(1.0, max(0.0, JoystickMath.normalizedMagnitude(axisMagnitude, deadzone: deadzone))) - let accelerationExponent = scrollActionSettings?.accelerationExponent ?? tuning.scrollAccelerationExponent - let multiplier = scrollActionSettings?.scrollMultiplier ?? tuning.scrollMultiplier + let accelerationExponent = scrollActionSettings?.accelerationExponent ?? settings.scrollAccelerationExponent + let multiplier = scrollActionSettings?.scrollMultiplier ?? settings.scrollMultiplier let accelerated = pow(normalized, accelerationExponent) let amount = accelerated * multiplier guard amount > 0 else { return nil } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategies.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategies.swift index d1d057b2..63f76a50 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategies.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategies.swift @@ -19,9 +19,7 @@ struct MouseStickStrategy: JoystickStickStrategy { func process(_ input: JoystickStickInput, on engine: MappingEngine) { engine.processMouseMovement( input.stick, - tuning: input.tuning, settings: input.settings, - analogPrecisionMultiplier: input.analogPrecisionMultiplier, now: input.now ) } @@ -39,10 +37,10 @@ struct ScrollStickStrategy: JoystickStickStrategy { // Right-stick scroll has a double-tap-to-boost feature; left-stick // does not. Match existing behavior exactly. if input.side == .right { - engine.updateScrollDoubleTapState(rawStick: input.stick, tuning: input.tuning, now: input.now) + engine.updateScrollDoubleTapState(rawStick: input.stick, settings: input.settings, now: input.now) } - let deadzone = input.side == .right ? input.tuning.scrollDeadzone : input.tuning.mouseDeadzone + let deadzone = input.side == .right ? input.settings.scrollDeadzone : input.settings.mouseDeadzone let magnitudeSquared = input.stick.x * input.stick.x + input.stick.y * input.stick.y let deadzoneSquared = deadzone * deadzone @@ -59,7 +57,7 @@ struct ScrollStickStrategy: JoystickStickStrategy { state.smoothedRightStick = smoothed } - engine.processScrolling(smoothed, rawStick: input.stick, tuning: input.tuning, settings: input.settings, now: input.now) + engine.processScrolling(smoothed, rawStick: input.stick, settings: input.settings, now: input.now) } } @@ -78,14 +76,13 @@ struct DirectionKeyStickStrategy: JoystickStickStrategy { // Left and right sticks read different deadzone/invert fields, matching // the long-standing convention that those settings cluster with mouse - // (left stick) vs. scroll (right stick) primary modes — now sourced from - // each stick's own tuning. + // (left stick) vs. scroll (right stick) primary modes. if input.side == .left { - deadzone = input.tuning.mouseDeadzone - invertY = input.tuning.invertMouseY + deadzone = input.settings.mouseDeadzone + invertY = input.settings.invertMouseY } else { - deadzone = input.tuning.scrollDeadzone - invertY = input.tuning.invertScrollY + deadzone = input.settings.scrollDeadzone + invertY = input.settings.invertScrollY } if input.side == .left { @@ -119,14 +116,14 @@ struct CustomDirectionStickStrategy: JoystickStickStrategy { engine.processCustomDirectionButtons( stick: input.stick, side: input.side, - tuning: input.tuning, + settings: input.settings, heldButtons: &state.leftStickHeldDirectionButtons ) } else { engine.processCustomDirectionButtons( stick: input.stick, side: input.side, - tuning: input.tuning, + settings: input.settings, heldButtons: &state.rightStickHeldDirectionButtons ) } @@ -148,14 +145,14 @@ struct DPadStickStrategy: JoystickStickStrategy { engine.processDPadDirectionButtons( stick: input.stick, side: input.side, - tuning: input.tuning, + settings: input.settings, heldButtons: &state.leftStickHeldDirectionButtons ) } else { engine.processDPadDirectionButtons( stick: input.stick, side: input.side, - tuning: input.tuning, + settings: input.settings, heldButtons: &state.rightStickHeldDirectionButtons ) } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategy.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategy.swift index 9e6f2855..a75e0760 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategy.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/JoystickStickStrategy.swift @@ -3,18 +3,9 @@ import CoreGraphics /// Which physical stick is being processed. Used by mode strategies that need /// per-side state (e.g. smoothing buffers, held-key sets, scroll boost). -enum JoystickSide: String, Codable, CaseIterable, Identifiable, Sendable { +enum JoystickSide { case left case right - - var id: String { rawValue } - - var displayName: String { - switch self { - case .left: return "Left Stick" - case .right: return "Right Stick" - } - } } /// Inputs to a stick mode strategy for one polling tick. Bundled into a struct @@ -24,13 +15,6 @@ struct JoystickStickInput { let stick: CGPoint let side: JoystickSide let settings: JoystickSettings - /// Per-side tuning, already resolved for the active layer (override applied - /// over the base stick). Strategies read sensitivity/acceleration/deadzone/ - /// mode from here; `settings` is for genuinely global knobs (focus mode, - /// scroll boost). - let tuning: StickTuning - /// Continuous precision multiplier from analog trigger depth (1.0 = normal). - let analogPrecisionMultiplier: Double let dt: TimeInterval let now: CFAbsoluteTime } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingActionExecutor.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingActionExecutor.swift index 3c306267..45230a83 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingActionExecutor.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingActionExecutor.swift @@ -118,8 +118,6 @@ struct MappingExecutor { if inTerminal { service.recordTerminalCommand() } - case .centerOuraRing, .toggleOuraMotion: - break } return } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift index 7061855a..eaf1026c 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngine.swift @@ -143,10 +143,15 @@ class MappingEngine: ObservableObject { self.state.swipeTypingSensitivity = oskSettings.swipeTypingSensitivity self.state.frontmostBundleId = appMonitor.frontmostBundleId self.state.sequenceDetector.configure(sequences: profileManager.activeProfile?.sequenceMappings ?? []) - self.state.applyProfileIndex(MappingProfileIndex(profile: profileManager.activeProfile)) + // Build precomputed lookup caches + let initialChords = profileManager.activeProfile?.chordMappings ?? [] + self.state.chordParticipantButtons = Self.expandedParticipantButtons(for: initialChords.flatMap { $0.buttons }) + self.state.sequenceParticipantButtons = Self.expandedParticipantButtons(for: (profileManager.activeProfile?.sequenceMappings ?? []).flatMap { $0.steps }) + self.state.chordLookup = Dictionary(uniqueKeysWithValues: initialChords.map { ($0.buttons, $0) }) + self.state.layersById = Self.layersById(for: profileManager.activeProfile) + rebuildLayerActivatorMap(profile: profileManager.activeProfile) syncLatencySettings(for: profileManager.activeProfile) syncGestureSettings(from: profileManager.activeProfile?.joystickSettings) - syncPointerLockMouseMode(from: profileManager.activeProfile?.joystickSettings) syncTouchpadSettings(from: profileManager.activeProfile) syncMotionActivation(for: profileManager.activeProfile) } @@ -159,12 +164,35 @@ class MappingEngine: ObservableObject { joystickTimer = nil } + /// Rebuilds the layer activator button -> layer ID lookup map + private func rebuildLayerActivatorMap(profile: Profile?) { + state.layerActivatorMap.removeAll() + guard let profile = profile else { return } + for layer in profile.layers { + if let activatorButton = layer.activatorButton { + state.layerActivatorMap[activatorButton] = layer.id + } + } + } + private func syncLatencySettings(for profile: Profile?) { - let chordButtons = MappingProfileIndex(profile: profile).chordParticipantButtons + let chordButtons = Self.expandedParticipantButtons(for: (profile?.chordMappings ?? []).flatMap { $0.buttons }) controllerService.chordParticipantButtons = chordButtons controllerService.lowLatencyInputEnabled = profile?.inputLatencyMode == .realtime } + private static func expandedParticipantButtons(for buttons: [ControllerButton]) -> Set { + Set(buttons.flatMap { button in + [button] + button.physicalEquivalentButtons + }) + } + + /// O(1) layer lookup cache for the 120Hz joystick poll. Tolerates duplicate + /// layer IDs in hand-edited configs by keeping the first occurrence. + private static func layersById(for profile: Profile?) -> [UUID: Layer] { + Dictionary((profile?.layers ?? []).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + /// Pushes effective gesture detection settings from the profile into ControllerStorage /// so the motion callback thread can read them without accessing JoystickSettings. /// Also resets gesture detector tracking state to prevent stale gestures from a @@ -189,10 +217,6 @@ class MappingEngine: ObservableObject { controllerService.appleTVRemoteCircularScrollEnabled = settings.appleTVRemoteCircularScrollEnabled } - private func syncPointerLockMouseMode(from settings: JoystickSettings?) { - inputSimulator.setPointerLockMouseMode((settings ?? .default).pointerLockMouseMode) - } - private func syncGestureSettings(from settings: JoystickSettings?) { let settings = settings ?? .default controllerService.storage.lock.lock() @@ -242,15 +266,21 @@ class MappingEngine: ObservableObject { self.state.swipeTypingSensitivity = osk.swipeTypingSensitivity self.state.activeLayerIds.removeAll() self.state.buttonsActingAsLayerActivators.removeAll() + self.rebuildLayerActivatorMap(profile: profile) self.state.sequenceDetector.configure(sequences: profile?.sequenceMappings ?? []) - self.state.applyProfileIndex(MappingProfileIndex(profile: profile)) + + // Build precomputed lookup caches + let chords = profile?.chordMappings ?? [] + self.state.chordParticipantButtons = Self.expandedParticipantButtons(for: chords.flatMap { $0.buttons }) + self.state.sequenceParticipantButtons = Self.expandedParticipantButtons(for: (profile?.sequenceMappings ?? []).flatMap { $0.steps }) + self.state.chordLookup = Dictionary(uniqueKeysWithValues: chords.map { ($0.buttons, $0) }) + self.state.layersById = Self.layersById(for: profile) return heldDirections } for button in directionButtonsToRelease { self.controllerService.handleButton(button, pressed: false) } self.syncGestureSettings(from: profile?.joystickSettings) - self.syncPointerLockMouseMode(from: profile?.joystickSettings) self.syncTouchpadSettings(from: profile) self.syncMotionActivation(for: profile) self.syncLatencySettings(for: profile) @@ -268,10 +298,24 @@ class MappingEngine: ObservableObject { } .store(in: &cancellables) - // Controller input events — ControllerService owns event emission; - // MappingEngine owns queue routing and mapping behavior. - controllerService.onInputEvent = { [weak self] event in - self?.enqueueControllerInputEvent(event) + // Controller input callbacks — route each event to the appropriate queue. + controllerService.onButtonPressed = { [weak self] button in + guard let self = self else { return } + self.inputQueue.async { + self.handleButtonPressed(button) + } + } + controllerService.onButtonReleased = { [weak self] button, duration in + guard let self = self else { return } + self.inputQueue.async { + self.handleButtonReleased(button, holdDuration: duration) + } + } + controllerService.onChordDetected = { [weak self] buttons in + guard let self = self else { return } + self.inputQueue.async { + self.handleChord(buttons) + } } // Joystick polling @@ -302,10 +346,76 @@ class MappingEngine: ObservableObject { } .store(in: &cancellables) + controllerService.onTouchpadMoved = { [weak self] delta in + guard let self = self else { return } + self.pollingQueue.async { + self.processTouchpadMovement(delta) + } + } + controllerService.onSteamLeftTouchpadMoved = { [weak self] delta in + guard let self = self else { return } + self.pollingQueue.async { + self.processSteamLeftTouchpadScroll(delta) + } + } + controllerService.onAppleTVRemoteCircularScroll = { [weak self] angleDelta in + guard let self = self else { return } + self.pollingQueue.async { + self.processAppleTVRemoteCircularScroll(angleDelta) + } + } + controllerService.onTouchpadGesture = { [weak self] gesture in + guard let self = self else { return } + self.pollingQueue.async { + self.processTouchpadGesture(gesture) + } + } + controllerService.onTouchpadTap = { [weak self] in + guard let self = self else { return } + self.pollingQueue.async { + self.processTouchpadTap() + } + } + controllerService.onControllerButtonTap = { [weak self] button in + guard let self = self else { return } + self.inputQueue.async { + self.processTapGesture(button) + } + } + controllerService.onTouchpadTwoFingerTap = { [weak self] in + guard let self = self else { return } + self.pollingQueue.async { + self.processTouchpadTwoFingerTap() + } + } + controllerService.onTouchpadLongTap = { [weak self] in + guard let self = self else { return } + self.pollingQueue.async { + self.processTouchpadLongTap() + } + } + controllerService.onTouchpadTwoFingerLongTap = { [weak self] in + guard let self = self else { return } + self.pollingQueue.async { + self.processTouchpadTwoFingerLongTap() + } + } + controllerService.onTouchpadRegionTap = { [weak self] region in + guard let self = self else { return } + self.inputQueue.async { + self.processTouchpadRegionEvent(region, trigger: .touch) + } + } // Region clicks no longer use a callback. ControllerService dispatches // them directly as `handleButton(.touchpadRegion*Click, pressed:)`, // which goes through the standard press/release machinery (long hold, // double tap, repeat, layer overrides all work for free). + controllerService.onMotionGesture = { [weak self] gestureType in + guard let self = self else { return } + self.inputQueue.async { + self.processMotionGesture(gestureType) + } + } // Enable/Disable toggle sync $isEnabled @@ -314,9 +424,15 @@ class MappingEngine: ObservableObject { let cleanup: (leftKeys: Set, rightKeys: Set, directionButtons: Set)? = self.state.lock.withLock { self.state.isEnabled = enabled if enabled { + // Rebuild precomputed lookup caches that were cleared by reset() let profile = self.state.activeProfile + let chords = profile?.chordMappings ?? [] + self.state.chordParticipantButtons = Self.expandedParticipantButtons(for: chords.flatMap { $0.buttons }) + self.state.sequenceParticipantButtons = Self.expandedParticipantButtons(for: (profile?.sequenceMappings ?? []).flatMap { $0.steps }) + self.state.chordLookup = Dictionary(uniqueKeysWithValues: chords.map { ($0.buttons, $0) }) + self.state.layersById = Self.layersById(for: profile) self.state.sequenceDetector.configure(sequences: profile?.sequenceMappings ?? []) - self.state.applyProfileIndex(MappingProfileIndex(profile: profile)) + self.rebuildLayerActivatorMap(profile: profile) self.syncLatencySettings(for: profile) return nil } @@ -940,9 +1056,7 @@ class MappingEngine: ObservableObject { } else { cleanup = nil } - state.lock.unlock() - - _ = OuraRingCommandCenter.shared.centerRing() + state.lock.unlock() if nowLocked { inputSimulator.releaseAllModifiers() @@ -1448,91 +1562,6 @@ class MappingEngine: ObservableObject { } } - nonisolated private func enqueueControllerInputEvent(_ event: ControllerInputEvent) { - // Coalesce high-rate touchpad movement so a bursty transport can't backlog - // the serial pollingQueue and replay the swipe path. All other events keep - // their 1:1 routing. - if case .touchpadMoved(let delta) = event { - enqueueCoalescedTouchpadMovement(delta) - return - } - switch ControllerInputEventRouting.queue(for: event) { - case .input: - inputQueue.async { [weak self] in - self?.handleControllerInputEvent(event) - } - case .polling: - pollingQueue.async { [weak self] in - self?.handleControllerInputEvent(event) - } - } - } - - /// Coalesces high-rate touchpad movement into a single net delta per drain. - /// - /// Bursty transports — BT→USB bridge dongles, and even a wired DualSense at - /// its native high report rate — deliver touchpad samples faster than the - /// serial `pollingQueue` can post Quartz mouse-moves. Enqueuing one block per - /// sample backlogs the queue, so the queued moves drain late and the cursor - /// re-traces the swipe path ("laggy + repeating paths"). Summing the deltas - /// and draining once per scheduled flush preserves total displacement, - /// eliminates the backlog, and lowers latency. Under normal (non-bursty) - /// input each sample drains before the next arrives, so per-sample behavior - /// is unchanged. - nonisolated private func enqueueCoalescedTouchpadMovement(_ delta: CGPoint) { - let shouldSchedule: Bool = state.lock.withLock { () -> Bool in - state.coalescedTouchpadDelta.x += delta.x - state.coalescedTouchpadDelta.y += delta.y - if state.touchpadFlushScheduled { return false } - state.touchpadFlushScheduled = true - return true - } - guard shouldSchedule else { return } - pollingQueue.async { [weak self] in - guard let self else { return } - let summed: CGPoint = self.state.lock.withLock { () -> CGPoint in - let accumulated = self.state.coalescedTouchpadDelta - self.state.coalescedTouchpadDelta = .zero - self.state.touchpadFlushScheduled = false - return accumulated - } - self.processTouchpadMovement(summed) - } - } - - nonisolated private func handleControllerInputEvent(_ event: ControllerInputEvent) { - switch event { - case .buttonPressed(let button): - handleButtonPressed(button) - case .buttonReleased(let button, let holdDuration): - handleButtonReleased(button, holdDuration: holdDuration) - case .chordDetected(let buttons): - handleChord(buttons) - case .touchpadMoved(let delta): - processTouchpadMovement(delta) - case .steamLeftTouchpadMoved(let delta): - processSteamLeftTouchpadScroll(delta) - case .appleTVRemoteCircularScroll(let angleDelta): - processAppleTVRemoteCircularScroll(angleDelta) - case .touchpadGesture(let gesture): - processTouchpadGesture(gesture) - case .touchpadTap: - processTouchpadTap() - case .controllerButtonTap(let button): - processTapGesture(button) - case .touchpadTwoFingerTap: - processTouchpadTwoFingerTap() - case .touchpadLongTap: - processTouchpadLongTap() - case .touchpadTwoFingerLongTap: - processTouchpadTwoFingerLongTap() - case .touchpadRegionTap(let region): - processTouchpadRegionEvent(region, trigger: .touch) - case .motionGesture(let gestureType): - processMotionGesture(gestureType) - } - } - // MARK: - Control func enable() { @@ -1551,15 +1580,21 @@ class MappingEngine: ObservableObject { // MARK: - Remote Controller Relay nonisolated func handleRemoteControllerButtonPressed(_ button: ControllerButton) { - enqueueControllerInputEvent(.buttonPressed(button)) + inputQueue.async { [weak self] in + self?.handleButtonPressed(button) + } } nonisolated func handleRemoteControllerButtonReleased(_ button: ControllerButton, holdDuration: TimeInterval) { - enqueueControllerInputEvent(.buttonReleased(button, holdDuration: holdDuration)) + inputQueue.async { [weak self] in + self?.handleButtonReleased(button, holdDuration: holdDuration) + } } nonisolated func handleRemoteControllerChord(_ buttons: Set) { - enqueueControllerInputEvent(.chordDetected(buttons)) + inputQueue.async { [weak self] in + self?.handleChord(buttons) + } } nonisolated func resetRemoteControllerInputState() { diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngineState.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngineState.swift index e583f351..dab49428 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngineState.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingEngineState.swift @@ -75,13 +75,6 @@ extension MappingEngine { var lastJoystickSampleTime: TimeInterval = 0 var smoothedTouchpadDelta: CGPoint = .zero var lastTouchpadSampleTime: TimeInterval = 0 - // Touchpad movement coalescing: a burst of high-rate touchpad samples is - // summed into one net delta and applied once per scheduled flush, so a - // bursty transport (BT→USB bridge dongle, or a wired DualSense at its - // native high report rate) can't backlog the serial pollingQueue and - // replay the swipe path. See MappingEngine.enqueueCoalescedTouchpadMovement. - var coalescedTouchpadDelta: CGPoint = .zero - var touchpadFlushScheduled: Bool = false var smoothedTouchpadCenterDelta: CGPoint = .zero var smoothedTouchpadDistanceDelta: Double = 0 var lastTouchpadGestureSampleTime: TimeInterval = 0 @@ -202,8 +195,6 @@ extension MappingEngine { lastJoystickSampleTime = 0 smoothedTouchpadDelta = .zero lastTouchpadSampleTime = 0 - coalescedTouchpadDelta = .zero - touchpadFlushScheduled = false smoothedTouchpadCenterDelta = .zero smoothedTouchpadDistanceDelta = 0 lastTouchpadGestureSampleTime = 0 diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingProfileIndex.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingProfileIndex.swift deleted file mode 100644 index 1a3189ad..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/MappingProfileIndex.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -/// Profile-derived lookup tables used on the input queue. Keeping this as a -/// value type makes the expensive/semantic part of MappingEngine's profile sync -/// testable without constructing the engine. -struct MappingProfileIndex { - let chordParticipantButtons: Set - let sequenceParticipantButtons: Set - let chordLookup: [Set: ChordMapping] - let layersById: [UUID: Layer] - let layerActivatorMap: [ControllerButton: UUID] - - init(profile: Profile?) { - let chords = profile?.chordMappings ?? [] - chordParticipantButtons = Self.expandedParticipantButtons(for: chords.flatMap { $0.buttons }) - sequenceParticipantButtons = Self.expandedParticipantButtons(for: (profile?.sequenceMappings ?? []).flatMap { $0.steps }) - chordLookup = Dictionary(chords.map { ($0.buttons, $0) }, uniquingKeysWith: { _, last in last }) - layersById = Self.layersById(for: profile) - layerActivatorMap = Self.layerActivatorMap(for: profile) - } - - static func expandedParticipantButtons(for buttons: [ControllerButton]) -> Set { - Set(buttons.flatMap { button in - [button] + button.physicalEquivalentButtons - }) - } - - private static func layersById(for profile: Profile?) -> [UUID: Layer] { - Dictionary((profile?.layers ?? []).map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - } - - private static func layerActivatorMap(for profile: Profile?) -> [ControllerButton: UUID] { - guard let profile else { return [:] } - var result: [ControllerButton: UUID] = [:] - for layer in profile.layers { - if let activatorButton = layer.activatorButton { - result[activatorButton] = layer.id - } - } - return result - } -} - -extension MappingEngine.EngineState { - /// Applies profile-derived caches. Caller MUST already hold `lock`. - func applyProfileIndex(_ index: MappingProfileIndex) { - chordParticipantButtons = index.chordParticipantButtons - sequenceParticipantButtons = index.sequenceParticipantButtons - chordLookup = index.chordLookup - layersById = index.layersById - layerActivatorMap = index.layerActivatorMap - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift index 0cc57f48..76ae28b8 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Mapping/TouchpadInputHandler.swift @@ -84,21 +84,11 @@ extension MappingEngine { guard magnitude > deadzone else { return } let sensitivity = Config.touchpadNativeScale * settings.touchpadSensitivityMultiplier - let analogPrecisionMultiplier: Double - if settings.analogPrecisionTriggerMode == .off { - analogPrecisionMultiplier = 1.0 - } else { - let controllerSnapshot = controllerService.snapshot() - analogPrecisionMultiplier = settings.analogPrecisionMultiplier( - leftTrigger: Double(controllerSnapshot.leftTrigger), - rightTrigger: Double(controllerSnapshot.rightTrigger) - ) - } - let dx = Double(smoothedDelta.x) * sensitivity * analogPrecisionMultiplier - var dy = -Double(smoothedDelta.y) * sensitivity * analogPrecisionMultiplier + let dx = Double(smoothedDelta.x) * sensitivity + var dy = -Double(smoothedDelta.y) * sensitivity - if settings.leftStick.invertMouseY { + if settings.invertMouseY { dy = -dy } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraAutoRecenterMonitor.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraAutoRecenterMonitor.swift deleted file mode 100644 index f4f3b463..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraAutoRecenterMonitor.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -/// Detects a stale motion center and proposes a snap recenter. -/// -/// Physics: at true rest the accelerometer reads pure gravity, so during raw -/// stillness the angle between the current gravity vector and the stored -/// neutral is a direct measure of center staleness. The old soft-recenter -/// path can't recover a badly stale center because its gate requires small -/// PROJECTED input — which a stale center manufactures even at dead rest. -/// This monitor gates on raw stillness only, so it engages exactly when the -/// soft path deadlocks. -/// -/// Thresholds mined from the full live trace (2026-07-06, 22.9k rest -/// readings): drift-at-rest is bimodal — median 0.2° when healthy vs 50-90° -/// when stale, with almost no mass at 8-25° — and 62% of rest readings in the -/// 60s before a spurious double tap showed >15° drift (vs 17% baseline). -/// Rest |a| measured 0.995-1.023g, so the magnitude band rejects windows of -/// smooth non-gravity acceleration. -/// -/// Kill switch: defaults write ~/Library/Preferences/KevinTang.XboxControllerMapper.plist \ -/// ouraAutoRecenterDisabled -bool true -struct OuraAutoRecenterMonitor { - static let defaultsDisableKey = "ouraAutoRecenterDisabled" - - var enabled: Bool = !UserDefaults.standard.bool(forKey: OuraAutoRecenterMonitor.defaultsDisableKey) - var staleAngleDegrees: Double = 15 - var confirmDuration: CFTimeInterval = 1.5 - var cooldown: CFTimeInterval = 5.0 - - private static let stillDeltaThreshold = 0.02 - private static let pairEpsilon: CFTimeInterval = 0.001 - private static let magnitudeBand = 0.90...1.10 - - private var previousSample: OuraMotionSample? - private var stillStart: CFAbsoluteTime? - private var sumX = 0.0 - private var sumY = 0.0 - private var sumZ = 0.0 - private var sampleCount = 0 - private var lastSnapTime: CFAbsoluteTime = -.greatestFiniteMagnitude - private var holdOffUntil: CFAbsoluteTime = -.greatestFiniteMagnitude - - mutating func reset() { - previousSample = nil - clearStillRun() - lastSnapTime = -.greatestFiniteMagnitude - holdOffUntil = -.greatestFiniteMagnitude - } - - /// Defer snapping until after gesture activity settles — tap ring-down is - /// not rest, and a snap inside the suppression window would fight the - /// gesture pipeline. - mutating func holdOff(until timestamp: CFAbsoluteTime) { - holdOffUntil = max(holdOffUntil, timestamp) - } - - /// Feed every raw sample. Returns a replacement neutral (mean gravity over - /// the still window) plus the measured drift when the center is provably - /// stale; nil otherwise. - mutating func register( - _ sample: OuraMotionSample, - neutral: OuraMotionSample? - ) -> (neutral: OuraMotionSample, driftDegrees: Double)? { - defer { previousSample = sample } - guard enabled, let neutral else { - clearStillRun() - return nil - } - guard let previous = previousSample else { return nil } - - // Both samples of one BLE frame share a near-identical timestamp — - // they contribute to the gravity mean but not to stillness timing. - guard sample.timestamp - previous.timestamp >= Self.pairEpsilon else { - if stillStart != nil { - accumulate(sample) - } - return nil - } - - let delta = distance(sample, previous) - guard delta < Self.stillDeltaThreshold else { - clearStillRun() - return nil - } - - if stillStart == nil { - stillStart = sample.timestamp - } - accumulate(sample) - - guard let stillStart, - sample.timestamp - stillStart >= confirmDuration, - sample.timestamp >= holdOffUntil, - sample.timestamp - lastSnapTime >= cooldown, - sampleCount > 0 else { - return nil - } - - let mean = OuraMotionSample( - x: sumX / Double(sampleCount), - y: sumY / Double(sampleCount), - z: sumZ / Double(sampleCount), - timestamp: sample.timestamp - ) - guard Self.magnitudeBand.contains(magnitude(mean)) else { return nil } - guard let drift = angleDegrees(mean, neutral), drift >= staleAngleDegrees else { return nil } - - lastSnapTime = sample.timestamp - clearStillRun() - return (mean, drift) - } - - private mutating func clearStillRun() { - stillStart = nil - sumX = 0 - sumY = 0 - sumZ = 0 - sampleCount = 0 - } - - private mutating func accumulate(_ sample: OuraMotionSample) { - sumX += sample.x - sumY += sample.y - sumZ += sample.z - sampleCount += 1 - } - - private func distance(_ a: OuraMotionSample, _ b: OuraMotionSample) -> Double { - let dx = a.x - b.x - let dy = a.y - b.y - let dz = a.z - b.z - return (dx * dx + dy * dy + dz * dz).squareRoot() - } - - private func magnitude(_ sample: OuraMotionSample) -> Double { - (sample.x * sample.x + sample.y * sample.y + sample.z * sample.z).squareRoot() - } - - private func angleDegrees(_ a: OuraMotionSample, _ b: OuraMotionSample) -> Double? { - let la = magnitude(a) - let lb = magnitude(b) - guard la > 0.0001, lb > 0.0001 else { return nil } - let dot = (a.x * b.x + a.y * b.y + a.z * b.z) / (la * lb) - return acos(min(1.0, max(-1.0, dot))) * 180.0 / .pi - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraGestureEventClassifier.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraGestureEventClassifier.swift deleted file mode 100644 index 757134e4..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraGestureEventClassifier.swift +++ /dev/null @@ -1,279 +0,0 @@ -import CoreGraphics -import CoreML -import Foundation - -// ML gesture-event path for the Oura ring: a jerk-impulse candidate detector -// feeds 0.64s windows of raw motion to a tiny Core ML classifier -// (Resources/OuraGestureClassifier.mlpackage, trained in OuraGestureModel/). -// The classifier replaces the geometric flick recognizer — ground truth from -// the 2026-07-05 labeled session showed flicks are unrecoverable by -// thresholds (the gravity projection never "returns to start", and taps shake -// px/py as hard as flicks) while the classifier separates them cleanly. -// Tap events still flow through the deterministic OuraTapSequenceRecognizer. -// -// Kill switch: defaults write ~/Library/Preferences/KevinTang.XboxControllerMapper.plist \ -// ouraGestureClassifierDisabled -bool true (falls back to the heuristic path) - -enum OuraGestureEvent: String { - case tap - case flickUp = "flick-up" - case flickDown = "flick-down" - case flickLeft = "flick-left" - case flickRight = "flick-right" - case noise - - // Must match OuraGestureModel/model.py CLASSES order. - static let classOrder: [OuraGestureEvent] = [.tap, .flickUp, .flickDown, .flickLeft, .flickRight, .noise] - - var directionalFlick: OuraDirectionalFlick? { - switch self { - case .flickUp: return .up - case .flickDown: return .down - case .flickLeft: return .left - case .flickRight: return .right - case .tap, .noise: return nil - } - } -} - -// Mirrors OuraGestureModel/build_dataset.py jerk_peaks: local maxima of the -// inter-sample |Δxyz| (samples <1ms apart are frame pairs and skipped), -// confirmed one jerk-series entry later, with a minimum peak separation. -struct OuraImpulseDetector { - static let threshold = 0.35 - static let minimumSeparation: CFTimeInterval = 0.18 - - private var previousSample: OuraMotionSample? - private var jerkPrevious: (time: CFAbsoluteTime, jerk: Double)? - private var jerkBefore: (time: CFAbsoluteTime, jerk: Double)? - private var lastPeakTime: CFAbsoluteTime = -.greatestFiniteMagnitude - - mutating func reset() { - previousSample = nil - jerkPrevious = nil - jerkBefore = nil - lastPeakTime = -.greatestFiniteMagnitude - } - - /// Returns the timestamp of a newly confirmed impulse peak, if any. - mutating func register(_ sample: OuraMotionSample) -> CFAbsoluteTime? { - guard let previous = previousSample else { - previousSample = sample - return nil - } - // Frame pairs (<1ms apart) contribute no jerk entry, but the cursor - // still advances — the next jerk is measured against the pair's - // second sample, matching build_dataset.py's extraction. - previousSample = sample - guard sample.timestamp - previous.timestamp >= 0.001 else { return nil } - - let dx = sample.x - previous.x - let dy = sample.y - previous.y - let dz = sample.z - previous.z - let entry = (time: sample.timestamp, jerk: (dx * dx + dy * dy + dz * dz).squareRoot()) - - var confirmed: CFAbsoluteTime? - if let candidate = jerkPrevious, let before = jerkBefore, - candidate.jerk >= Self.threshold, - candidate.jerk >= before.jerk, - candidate.jerk >= entry.jerk, - candidate.time - lastPeakTime >= Self.minimumSeparation { - lastPeakTime = candidate.time - confirmed = candidate.time - } - jerkBefore = jerkPrevious - jerkPrevious = entry - return confirmed - } -} - -// Rolling history of raw motion, resampled into the classifier's fixed -// window. Mirrors OuraGestureModel/build_dataset.py extract_window. -struct OuraMotionWindowBuffer { - struct Entry { - let timestamp: CFAbsoluteTime - let x: Double - let y: Double - let z: Double - let px: Double - let py: Double - } - - static let preSpan: CFTimeInterval = 0.26 - // 0.38 → 0.24 (overnight search 2026-07-06): the shorter window scored - // BETTER (v1 98%, v2 94% end-to-end) and cuts every gesture's - // classification latency by 0.14s. Must match OuraGestureModel - // build_dataset.WINDOW_POST. - static let postSpan: CFTimeInterval = 0.24 - static let steps = 32 - private static let retention: CFTimeInterval = 3.0 - private static let minimumCoverage = 0.6 - - private(set) var entries: [Entry] = [] - - mutating func reset() { - entries.removeAll() - } - - mutating func append(_ sample: OuraMotionSample, projected: CGPoint) { - entries.append(Entry(timestamp: sample.timestamp, x: sample.x, y: sample.y, z: sample.z, - px: Double(projected.x), py: Double(projected.y))) - if let newest = entries.last?.timestamp, - let oldest = entries.first?.timestamp, - newest - oldest > Self.retention * 2 { - let cutoff = newest - Self.retention - entries.removeAll { $0.timestamp < cutoff } - } - } - - func entriesAfter(_ time: CFAbsoluteTime) -> ArraySlice { - guard let start = entries.firstIndex(where: { $0.timestamp >= time }) else { return [] } - return entries[start...] - } - - /// 32×5 window of [x, y, z, px, py] resampled around `center`, or nil when - /// the buffer covers less than 60% of the span. - func window(around center: CFAbsoluteTime) -> [[Double]]? { - let lo = center - Self.preSpan - let hi = center + Self.postSpan - guard let firstInside = entries.firstIndex(where: { $0.timestamp >= lo }) else { return nil } - let inside = entries[firstInside...].prefix { $0.timestamp <= hi } - guard let coverageStart = inside.first?.timestamp, - let coverageEnd = inside.last?.timestamp, - min(coverageEnd, hi) - max(coverageStart, lo) >= Self.minimumCoverage * (hi - lo) else { - return nil - } - - var window: [[Double]] = [] - window.reserveCapacity(Self.steps) - var j = max(firstInside, 1) - for step in 0.. URL? { - var candidates: [Bundle] = [.main, Bundle(for: OuraGestureEventClassifier.self)] - for bundle in Bundle.allBundles where bundle.bundleURL.pathExtension == "xctest" { - let appBundleURL = bundle.bundleURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - if let appBundle = Bundle(url: appBundleURL) { - candidates.append(appBundle) - } - } - for bundle in candidates { - if let url = bundle.url(forResource: "OuraGestureClassifier", withExtension: "mlmodelc") { - return url - } - } - return nil - } - - func classify(window: [[Double]]) -> (event: OuraGestureEvent, confidence: Double, tapProbability: Double)? { - lock.lock() - let model = self.model - lock.unlock() - guard let model, - window.count == OuraMotionWindowBuffer.steps, - window.allSatisfy({ $0.count == 5 }) else { - return nil - } - guard let input = try? MLMultiArray(shape: [1, NSNumber(value: OuraMotionWindowBuffer.steps), 5], - dataType: .float32) else { return nil } - for (step, row) in window.enumerated() { - for (channel, value) in row.enumerated() { - input[step * 5 + channel] = NSNumber(value: Float(value)) - } - } - guard let output = try? model.prediction( - from: MLDictionaryFeatureProvider(dictionary: ["window": MLFeatureValue(multiArray: input)])), - let logits = output.featureValue(for: "logits")?.multiArrayValue else { - return nil - } - var bestIndex = 0 - var bestValue = -Double.infinity - var expSum = 0.0 - var values: [Double] = [] - for index in 0.. bestValue { - bestValue = value - bestIndex = index - } - } - for value in values { - expSum += exp(value - bestValue) - } - guard bestIndex < OuraGestureEvent.classOrder.count, !values.isEmpty, expSum > 0 else { return nil } - // classOrder[0] == .tap — its softmax probability drives the tap-lean - // rule (borderline windows where noise narrowly outranks tap). - let tapProbability = exp(values[0] - bestValue) / expSum - return (OuraGestureEvent.classOrder[bestIndex], 1.0 / expSum, tapProbability) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraGestureRecognizers.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraGestureRecognizers.swift deleted file mode 100644 index 93120cf9..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraGestureRecognizers.swift +++ /dev/null @@ -1,239 +0,0 @@ -import CoreGraphics -import Foundation - -enum OuraDirectionalFlick: Equatable { - case up - case down - case left - case right - - var button: ControllerButton { - switch self { - case .up: return .ouraFlickUp - case .down: return .ouraFlickDown - case .left: return .ouraFlickLeft - case .right: return .ouraFlickRight - } - } - - var diagnosticName: String { - switch self { - case .up: return "up" - case .down: return "down" - case .left: return "left" - case .right: return "right" - } - } -} - -// holdDuration/settleDuration/drift/step tuned offline against the 2026-07-05 -// labeled session (Tools/oura-calibration/tune_gestures.py), same pass as the -// OuraTapDetector thresholds. -struct OuraTapHoldRecognizer { - // 0.36 → 0.6 (2026-07-06): a tap followed by the hand naturally resting - // was firing as a hold; 0.6s of stillness makes holds deliberate. Must - // stay under the tap-sequence resolution (~0.77s) or the hold can never - // fire — resolution cancels the candidate. - private static let holdDuration: CFTimeInterval = 0.6 - private static let settleDuration: CFTimeInterval = 0.09 - // Stillness tightened 2026-07-06 (live: 42% of taps fired as holds): the - // tuner's loose 0.44/0.45 drift bounds tolerated a gently MOVING hand. - // 0.25g + 0.35s of real stillness keeps prompted holds at 11/12 while - // zeroing spurious holds in tap trials (v2 replay 197→199/214). - private static let minimumStillDuration: CFTimeInterval = 0.35 - private static let maximumHoldDuration: CFTimeInterval = 1.2 - private static let maximumAnchorDrift = 0.25 - private static let maximumSampleStep = 0.25 - - private var candidate: Candidate? - private var previousSample: OuraMotionSample? - - mutating func reset() { - candidate = nil - previousSample = nil - } - - mutating func registerTap(at timestamp: CFAbsoluteTime, sample: OuraMotionSample?) { - guard let sample else { - reset() - return - } - candidate = Candidate(startTime: timestamp) - previousSample = sample - } - - mutating func cancel() { - candidate = nil - } - - mutating func registerMotion(_ sample: OuraMotionSample) -> Bool { - defer { previousSample = sample } - guard var candidate else { return false } - - let elapsed = sample.timestamp - candidate.startTime - guard elapsed >= 0 else { return false } - guard elapsed <= Self.maximumHoldDuration else { - self.candidate = nil - return false - } - - guard elapsed >= Self.settleDuration else { - self.candidate = candidate - return false - } - - if candidate.anchor == nil { - candidate.anchor = sample - candidate.anchorTime = sample.timestamp - self.candidate = candidate - return false - } - - guard let anchor = candidate.anchor, let anchorTime = candidate.anchorTime else { - self.candidate = nil - return false - } - - if distance(sample, anchor) > Self.maximumAnchorDrift { - self.candidate = nil - return false - } - if let previousSample, previousSample.timestamp >= anchorTime, - distance(sample, previousSample) > Self.maximumSampleStep { - self.candidate = nil - return false - } - - guard elapsed >= Self.holdDuration, - sample.timestamp - anchorTime >= Self.minimumStillDuration else { - self.candidate = candidate - return false - } - self.candidate = nil - return true - } - - private func distance(_ a: OuraMotionSample, _ b: OuraMotionSample) -> Double { - let x = a.x - b.x - let y = a.y - b.y - let z = a.z - b.z - return sqrt(x * x + y * y + z * z) - } - - private struct Candidate { - let startTime: CFAbsoluteTime - var anchor: OuraMotionSample? - var anchorTime: CFAbsoluteTime? - } -} - -struct OuraDirectionalFlickRecognizer { - private static let returnDistanceThreshold = 0.24 - private static let minimumSnapDistance = 0.48 - private static let minimumSnapVelocity = 2.4 - private static let axisDominanceRatio = 1.55 - private static let maximumSnapDuration: CFTimeInterval = 0.32 - private static let maximumReturnDuration: CFTimeInterval = 0.45 - private static let cooldown: CFTimeInterval = 0.65 - - private var previousPoint: CGPoint? - private var previousTimestamp: CFAbsoluteTime? - private var candidate: Candidate? - private var cooldownUntil: CFAbsoluteTime = 0 - - mutating func reset() { - previousPoint = nil - previousTimestamp = nil - candidate = nil - cooldownUntil = 0 - } - - mutating func register(projectedInput point: CGPoint, timestamp: CFAbsoluteTime) -> OuraDirectionalFlick? { - defer { - previousPoint = point - previousTimestamp = timestamp - } - - guard timestamp >= cooldownUntil else { return nil } - if let candidate { - return update(candidate: candidate, point: point, timestamp: timestamp) - } - guard let previousPoint, let previousTimestamp else { return nil } - - let dt = timestamp - previousTimestamp - guard dt > 0, dt <= Self.maximumSnapDuration else { return nil } - - let dx = point.x - previousPoint.x - let dy = point.y - previousPoint.y - let snapDistance = hypot(dx, dy) - guard snapDistance >= Self.minimumSnapDistance, - snapDistance / dt >= Self.minimumSnapVelocity, - let direction = dominantDirection(dx: dx, dy: dy) else { - return nil - } - - self.candidate = Candidate( - direction: direction, - startTime: previousTimestamp, - peakTime: timestamp, - start: previousPoint, - peak: point - ) - return nil - } - - private mutating func update( - candidate: Candidate, - point: CGPoint, - timestamp: CFAbsoluteTime - ) -> OuraDirectionalFlick? { - let elapsedSincePeak = timestamp - candidate.peakTime - guard elapsedSincePeak <= Self.maximumReturnDuration else { - self.candidate = nil - return nil - } - - let currentCandidate: Candidate - if distance(point, candidate.start) > distance(candidate.peak, candidate.start) { - currentCandidate = Candidate( - direction: candidate.direction, - startTime: candidate.startTime, - peakTime: timestamp, - start: candidate.start, - peak: point - ) - self.candidate = currentCandidate - } else { - currentCandidate = candidate - } - - guard distance(point, currentCandidate.start) <= Self.returnDistanceThreshold else { return nil } - self.candidate = nil - cooldownUntil = timestamp + Self.cooldown - return currentCandidate.direction - } - - private func dominantDirection(dx: CGFloat, dy: CGFloat) -> OuraDirectionalFlick? { - let absX = abs(dx) - let absY = abs(dy) - if absX > absY * Self.axisDominanceRatio { - return dx > 0 ? .right : .left - } - if absY > absX * Self.axisDominanceRatio { - return dy > 0 ? .up : .down - } - return nil - } - - private func distance(_ a: CGPoint, _ b: CGPoint) -> Double { - hypot(Double(a.x - b.x), Double(a.y - b.y)) - } - - private struct Candidate { - let direction: OuraDirectionalFlick - let startTime: CFAbsoluteTime - let peakTime: CFAbsoluteTime - let start: CGPoint - let peak: CGPoint - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraMotionMapper.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraMotionMapper.swift deleted file mode 100644 index 7236128b..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraMotionMapper.swift +++ /dev/null @@ -1,500 +0,0 @@ -import CoreGraphics -import Foundation - -struct OuraMotionSample: Equatable { - let x: Double - let y: Double - let z: Double - let timestamp: CFAbsoluteTime -} - -struct OuraMotionMappingResult { - let centeredSample: OuraMotionSample - let projectedInput: CGPoint - let stick: CGPoint - let didEstablishCenter: Bool - /// Set when the auto-recenter monitor snapped a stale center on this - /// sample: the drift angle that triggered it. Nil on normal samples. - var autoRecenterDriftDegrees: Double? - - init( - centeredSample: OuraMotionSample, - projectedInput: CGPoint, - stick: CGPoint, - didEstablishCenter: Bool, - autoRecenterDriftDegrees: Double? = nil - ) { - self.centeredSample = centeredSample - self.projectedInput = projectedInput - self.stick = stick - self.didEstablishCenter = didEstablishCenter - self.autoRecenterDriftDegrees = autoRecenterDriftDegrees - } -} - -struct OuraMotionMapper { - var settings: OuraMotionSettings { - didSet { - if oldValue.targetStick != settings.targetStick || - oldValue.enabled != settings.enabled || - oldValue.orientation != settings.orientation { - reset() - } - } - } - - private var smoothedStick: CGPoint = .zero - private var neutralSample: OuraMotionSample? - private var screenPlaneBasis: OuraScreenPlaneBasis? - private var previousRawSample: OuraMotionSample? - private var lowMotionStartTime: CFAbsoluteTime? - var autoRecenterMonitor = OuraAutoRecenterMonitor() - - private static let minimumOutputMagnitude = 0.18 - private static let softRecenterInputThreshold = 0.08 - private static let softRecenterRawDeltaThreshold = 0.020 - private static let softRecenterDelay: CFTimeInterval = 2.0 - private static let softRecenterAlpha = 0.008 - - init(settings: OuraMotionSettings = .default) { - self.settings = settings - } - - mutating func reset() { - smoothedStick = .zero - neutralSample = nil - screenPlaneBasis = nil - previousRawSample = nil - lowMotionStartTime = nil - autoRecenterMonitor.reset() - } - - mutating func stickPosition(for sample: OuraMotionSample) -> CGPoint { - guard settings.enabled, settings.motionOutputEnabled else { - return zeroOutput() - } - - let input = projectedInput(for: sample) - return stickPosition(forProjectedInput: input) - } - - mutating func mappingResult(forRawSample sample: OuraMotionSample) -> OuraMotionMappingResult { - guard settings.enabled else { - smoothedStick = .zero - return OuraMotionMappingResult( - centeredSample: sample, - projectedInput: .zero, - stick: .zero, - didEstablishCenter: false - ) - } - - // Snap recenter before centering this sample, so a provably stale - // neutral is replaced and the sample below is measured against the - // fresh one. Runs regardless of motionOutputEnabled — a stale center - // degrades gesture windows (px/py drift) even with the stick paused. - var autoRecenterDrift: Double? - if let snap = autoRecenterMonitor.register(sample, neutral: neutralSample) { - neutralSample = snap.neutral - if let normalized = OuraVector3(snap.neutral).normalized { - screenPlaneBasis = OuraScreenPlaneBasis(neutral: normalized) - } - smoothedStick = .zero - lowMotionStartTime = nil - autoRecenterDrift = snap.driftDegrees - } - - let centeredResult = centeredSample(for: sample) - let projectedInput: CGPoint - switch settings.orientation { - case .screenPlane: - projectedInput = screenPlaneInput(for: sample, centeredSample: centeredResult.sample) - updateSoftRecenter(rawSample: sample, projectedInput: projectedInput) - case .fingerToScreen, .legacyXY: - projectedInput = self.projectedInput(for: centeredResult.sample) - } - - return OuraMotionMappingResult( - centeredSample: centeredResult.sample, - projectedInput: projectedInput, - stick: settings.motionOutputEnabled ? stickPosition(forProjectedInput: projectedInput) : zeroOutput(), - didEstablishCenter: centeredResult.didEstablishCenter, - autoRecenterDriftDegrees: autoRecenterDrift - ) - } - - func projectedInput(for sample: OuraMotionSample) -> CGPoint { - let projected: CGPoint - switch settings.orientation { - case .screenPlane: - projected = CGPoint(x: sample.x, y: sample.y) - case .fingerToScreen: - projected = CGPoint(x: sample.x, y: sample.z) - case .legacyXY: - projected = CGPoint(x: sample.x, y: sample.y) - } - - return applyInversion(to: projected) - } - - private mutating func stickPosition(forProjectedInput input: CGPoint) -> CGPoint { - let inputGain = 1.4 + settings.sensitivity * 4.6 - let rawX = input.x * inputGain - let rawY = input.y * inputGain - - let magnitude = min(1.0, hypot(rawX, rawY)) - guard magnitude > settings.deadzone else { - return smooth(.zero) - } - - let xDirectionalBoost = rawX < 0 ? settings.leftTiltBoost : 1.0 - let x = rawX * settings.horizontalBoost * xDirectionalBoost - let y = rawY - let boostedMagnitude = min(1.0, hypot(x, y)) - let normalizedMagnitude = (magnitude - settings.deadzone) / (1.0 - settings.deadzone) - let boostedNormalizedMagnitude = max( - normalizedMagnitude, - (boostedMagnitude - settings.deadzone) / (1.0 - settings.deadzone) - ) - let outputMagnitude = Self.minimumOutputMagnitude + boostedNormalizedMagnitude * (1.0 - Self.minimumOutputMagnitude) - let scale = outputMagnitude / max(hypot(x, y), 0.0001) - return smooth(CGPoint(x: x * scale, y: y * scale)) - } - - private mutating func centeredSample(for sample: OuraMotionSample) -> (sample: OuraMotionSample, didEstablishCenter: Bool) { - guard let neutralSample else { - self.neutralSample = sample - return ( - OuraMotionSample(x: 0, y: 0, z: 0, timestamp: sample.timestamp), - true - ) - } - - return ( - OuraMotionSample( - x: sample.x - neutralSample.x, - y: sample.y - neutralSample.y, - z: sample.z - neutralSample.z, - timestamp: sample.timestamp - ), - false - ) - } - - private mutating func screenPlaneInput(for sample: OuraMotionSample, centeredSample: OuraMotionSample) -> CGPoint { - guard let current = OuraVector3(sample).normalized else { - return .zero - } - if screenPlaneBasis == nil { - screenPlaneBasis = OuraScreenPlaneBasis(neutral: current) - return .zero - } - guard let screenPlaneBasis else { return .zero } - - let centered = OuraVector3(centeredSample) - let planar = centered.projected(perpendicularTo: screenPlaneBasis.neutral) - return applyInversion(to: CGPoint( - x: -planar.dot(screenPlaneBasis.right), - y: -planar.dot(screenPlaneBasis.up) - )) - } - - private mutating func updateSoftRecenter(rawSample sample: OuraMotionSample, projectedInput: CGPoint) { - defer { previousRawSample = sample } - guard let previousRawSample, let neutralSample else { return } - - let inputMagnitude = Double(hypot(projectedInput.x, projectedInput.y)) - let rawDelta = hypot3( - sample.x - previousRawSample.x, - sample.y - previousRawSample.y, - sample.z - previousRawSample.z - ) - guard inputMagnitude < Self.softRecenterInputThreshold, - rawDelta < Self.softRecenterRawDeltaThreshold else { - lowMotionStartTime = nil - return - } - - if lowMotionStartTime == nil { - lowMotionStartTime = sample.timestamp - return - } - guard let lowMotionStartTime, - sample.timestamp - lowMotionStartTime >= Self.softRecenterDelay else { - return - } - - let alpha = Self.softRecenterAlpha - let updatedNeutral = OuraMotionSample( - x: neutralSample.x + (sample.x - neutralSample.x) * alpha, - y: neutralSample.y + (sample.y - neutralSample.y) * alpha, - z: neutralSample.z + (sample.z - neutralSample.z) * alpha, - timestamp: sample.timestamp - ) - self.neutralSample = updatedNeutral - if let neutral = OuraVector3(updatedNeutral).normalized { - screenPlaneBasis = OuraScreenPlaneBasis(neutral: neutral) - } - } - - private func hypot3(_ x: Double, _ y: Double, _ z: Double) -> Double { - sqrt(x * x + y * y + z * z) - } - - private func applyInversion(to point: CGPoint) -> CGPoint { - CGPoint( - x: settings.invertX ? -point.x : point.x, - y: settings.invertY ? -point.y : point.y - ) - } - - private mutating func smooth(_ raw: CGPoint) -> CGPoint { - let smoothing = min(1.0, max(0.0, settings.smoothing)) - let immediate = 1.0 - smoothing - smoothedStick = CGPoint( - x: smoothedStick.x * smoothing + raw.x * immediate, - y: smoothedStick.y * smoothing + raw.y * immediate - ) - return smoothedStick - } - - private mutating func zeroOutput() -> CGPoint { - smoothedStick = .zero - return .zero - } -} - -private struct OuraScreenPlaneBasis { - let neutral: OuraVector3 - let right: OuraVector3 - let up: OuraVector3 - - init(neutral: OuraVector3) { - let normalizedNeutral = neutral.normalized ?? OuraVector3(x: 0, y: 1, z: 0) - self.neutral = normalizedNeutral - - let rightAxis = OuraVector3(x: 1, y: 0, z: 0) - let upAxis = OuraVector3(x: 0, y: 0, z: 1) - let fallbackAxis = OuraVector3(x: 0, y: 1, z: 0) - - let projectedRight = Self.projectedAxis( - rightAxis, - normal: normalizedNeutral, - fallbacks: [upAxis, fallbackAxis] - ) - self.right = projectedRight - - let projectedUp = (upAxis.projected(perpendicularTo: normalizedNeutral) - projectedRight * upAxis.dot(projectedRight)).normalized - var resolvedUp: OuraVector3 - if let projectedUp { - resolvedUp = projectedUp - } else { - resolvedUp = Self.projectedAxis( - fallbackAxis, - normal: normalizedNeutral, - fallbacks: [upAxis, normalizedNeutral.cross(projectedRight)] - ) - } - // The pitch response is proportional to the neutral's y-component in - // ring frame, which changes sign as the finger crosses level — so - // centering with the finger level-or-raised inverted up/down (Kevin - // 2026-07-06: cursor only correct when centering pointed slightly - // down). Anchor the up axis to the negative-y convention so every - // centering pose behaves like the working one. - if normalizedNeutral.y > 0 { - resolvedUp = resolvedUp * -1 - } - self.up = resolvedUp - } - - private static func projectedAxis(_ axis: OuraVector3, normal: OuraVector3, fallbacks: [OuraVector3]) -> OuraVector3 { - let candidates = [axis] + fallbacks - for candidate in candidates { - if let projected = candidate.projected(perpendicularTo: normal).normalized { - return projected - } - } - return OuraVector3(x: 1, y: 0, z: 0) - } -} - -private struct OuraVector3: Equatable { - let x: Double - let y: Double - let z: Double - - init(x: Double, y: Double, z: Double) { - self.x = x - self.y = y - self.z = z - } - - init(_ sample: OuraMotionSample) { - self.init(x: sample.x, y: sample.y, z: sample.z) - } - - var length: Double { - sqrt(x * x + y * y + z * z) - } - - var normalized: OuraVector3? { - let length = length - guard length > 0.0001 else { return nil } - return self / length - } - - func dot(_ other: OuraVector3) -> Double { - x * other.x + y * other.y + z * other.z - } - - func cross(_ other: OuraVector3) -> OuraVector3 { - OuraVector3( - x: y * other.z - z * other.y, - y: z * other.x - x * other.z, - z: x * other.y - y * other.x - ) - } - - func projected(perpendicularTo normal: OuraVector3) -> OuraVector3 { - self - normal * dot(normal) - } - - static func + (lhs: OuraVector3, rhs: OuraVector3) -> OuraVector3 { - OuraVector3(x: lhs.x + rhs.x, y: lhs.y + rhs.y, z: lhs.z + rhs.z) - } - - static func - (lhs: OuraVector3, rhs: OuraVector3) -> OuraVector3 { - OuraVector3(x: lhs.x - rhs.x, y: lhs.y - rhs.y, z: lhs.z - rhs.z) - } - - static func * (lhs: OuraVector3, rhs: Double) -> OuraVector3 { - OuraVector3(x: lhs.x * rhs, y: lhs.y * rhs, z: lhs.z * rhs) - } - - static func / (lhs: OuraVector3, rhs: Double) -> OuraVector3 { - OuraVector3(x: lhs.x / rhs, y: lhs.y / rhs, z: lhs.z / rhs) - } -} - -// Thresholds tuned offline against the 2026-07-05 labeled gesture session -// (104 prompted trials) via Tools/oura-calibration/tune_gestures.py — the -// looser quiet-lead-in gate plus the longer refractory/confirm windows took -// tap+hold accuracy from 54% to 85% on ground truth with zero false taps -// during cursor motion. Re-tune against a fresh session before hand-editing. -struct OuraTapDetector { - private var sampleBeforePrevious: OuraMotionSample? - private var previousSample: OuraMotionSample? - private var pendingTap: PendingTap? - private var lastTapTime: CFAbsoluteTime = 0 - - mutating func reset() { - sampleBeforePrevious = nil - previousSample = nil - pendingTap = nil - lastTapTime = 0 - } - - mutating func register(_ sample: OuraMotionSample) -> Bool { - defer { - sampleBeforePrevious = previousSample - previousSample = sample - } - if let pendingTap { - if confirmsTap(candidate: pendingTap, with: sample) { - self.pendingTap = nil - lastTapTime = sample.timestamp - return true - } - if sample.timestamp - pendingTap.sample.timestamp > 0.145 { - self.pendingTap = nil - } - } - - guard let previousSample else { return false } - guard sample.timestamp - lastTapTime > 0.18 else { return false } - - let dt = sample.timestamp - previousSample.timestamp - guard dt > 0, dt < 0.2 else { return false } - - let delta = OuraMotionDelta( - x: sample.x - previousSample.x, - y: sample.y - previousSample.y, - z: sample.z - previousSample.z - ) - let jerk = delta.magnitude - let magnitude = hypot3(sample.x, sample.y, sample.z) - let previousMagnitude = hypot3(previousSample.x, previousSample.y, previousSample.z) - let magnitudeDelta = abs(magnitude - previousMagnitude) - guard let sampleBeforePrevious else { return false } - let leadInJerk = OuraMotionDelta( - x: previousSample.x - sampleBeforePrevious.x, - y: previousSample.y - sampleBeforePrevious.y, - z: previousSample.z - sampleBeforePrevious.z - ).magnitude - let quietLeadIn = leadInJerk < 1.63 || jerk > max(1.35, leadInJerk * 2.0) - let sharpPeak = jerk > 0.44 && magnitude > 1.064 && magnitudeDelta > 0.05 - - if quietLeadIn && sharpPeak { - pendingTap = PendingTap( - previousSample: previousSample, - sample: sample, - delta: delta, - peakMagnitude: magnitude - ) - } - return false - } - - private func confirmsTap(candidate: PendingTap, with sample: OuraMotionSample) -> Bool { - let dt = sample.timestamp - candidate.sample.timestamp - guard dt > 0, dt <= 0.145 else { return false } - - let followDelta = OuraMotionDelta( - x: sample.x - candidate.sample.x, - y: sample.y - candidate.sample.y, - z: sample.z - candidate.sample.z - ) - let magnitude = hypot3(sample.x, sample.y, sample.z) - let peakDrop = candidate.peakMagnitude - magnitude - let reversal = candidate.delta.dot(followDelta) < -0.02 - let settledDistance = hypot3( - sample.x - candidate.previousSample.x, - sample.y - candidate.previousSample.y, - sample.z - candidate.previousSample.z - ) - - return reversal && - (peakDrop > 0.13 || settledDistance < candidate.delta.magnitude * 0.92) - } - - private func hypot3(_ x: Double, _ y: Double, _ z: Double) -> Double { - sqrt(x * x + y * y + z * z) - } - - private struct PendingTap { - let previousSample: OuraMotionSample - let sample: OuraMotionSample - let delta: OuraMotionDelta - let peakMagnitude: Double - } - - private struct OuraMotionDelta { - let x: Double - let y: Double - let z: Double - - init(x: Double, y: Double, z: Double) { - self.x = x - self.y = y - self.z = z - } - - var magnitude: Double { - sqrt(x * x + y * y + z * z) - } - - func dot(_ other: OuraMotionDelta) -> Double { - x * other.x + y * other.y + z * other.z - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraMotionTrace.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraMotionTrace.swift deleted file mode 100644 index 7a7ec490..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraMotionTrace.swift +++ /dev/null @@ -1,138 +0,0 @@ -import CoreGraphics -import Foundation - -// Full-rate motion trace for the gesture-labeling harness (Tools/oura-calibration). -// Unlike the throttled diagnostic log, this records every accelerometer sample plus -// every recognizer event, as ndjson, so prompted capture sessions can be joined with -// the browser-side gesture labels by wall-clock time. -// -// Enable: defaults write KevinTang.XboxControllerMapper ouraMotionTraceLogging -bool true -// Output: /tmp/controllerkeys-oura-motion-trace.ndjson -// -// Line shapes ("t" = unix seconds at write, "ct" = CFAbsoluteTime the recognizers saw; -// note both samples of one BLE frame carry near-identical "ct" — the analyzer -// reconstructs nominal spacing from frame pairing): -// {"type":"sample","t":…,"ct":…,"x":…,"y":…,"z":…,"px":…,"py":…} -// {"type":"event","t":…,"ct":…,"name":"tap-detected","detail":"…"} - -nonisolated enum OuraMotionTraceFormat { - static func sampleLine( - wallTime: TimeInterval, - sample: OuraMotionSample, - projected: CGPoint - ) -> String { - jsonLine([ - "type": "sample", - "t": wallTime, - "ct": sample.timestamp, - "x": sample.x, - "y": sample.y, - "z": sample.z, - "px": Double(projected.x), - "py": Double(projected.y) - ]) - } - - static func eventLine( - wallTime: TimeInterval, - timestamp: CFAbsoluteTime, - name: String, - detail: String? = nil - ) -> String { - var object: [String: Any] = [ - "type": "event", - "t": wallTime, - "ct": timestamp, - "name": name - ] - if let detail { - object["detail"] = detail - } - return jsonLine(object) - } - - private static func jsonLine(_ object: [String: Any]) -> String { - guard JSONSerialization.isValidJSONObject(object), - let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), - let line = String(data: data, encoding: .utf8) else { - return "{}" - } - return line - } -} - -// nonisolated: internally NSLock-synchronized and must not inherit the -// project's default MainActor isolation. XCTest teardown can otherwise trip the -// isolated-deinit back-deploy path when OuraRingInputService is released. -nonisolated final class OuraMotionTraceWriter { - static let defaultsKey = "ouraMotionTraceLogging" - static let traceURL = URL(fileURLWithPath: "/tmp/controllerkeys-oura-motion-trace.ndjson") - - private let lock = NSLock() - private var handle: FileHandle? - private var openFailed = false - - private var isEnabled: Bool { - UserDefaults.standard.bool(forKey: Self.defaultsKey) - } - - func recordSample(_ sample: OuraMotionSample, projected: CGPoint) { - guard isEnabled else { return } - write(OuraMotionTraceFormat.sampleLine( - wallTime: Date().timeIntervalSince1970, - sample: sample, - projected: projected - )) - } - - func recordEvent( - _ name: String, - detail: String? = nil, - timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - ) { - guard isEnabled else { return } - write(OuraMotionTraceFormat.eventLine( - wallTime: Date().timeIntervalSince1970, - timestamp: timestamp, - name: name, - detail: detail - )) - } - - func close() { - lock.lock() - defer { lock.unlock() } - try? handle?.close() - handle = nil - openFailed = false - } - - private func write(_ line: String) { - guard let data = (line + "\n").data(using: .utf8) else { return } - lock.lock() - defer { lock.unlock() } - if handle == nil, !openFailed { - let path = Self.traceURL.path - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - if let opened = try? FileHandle(forWritingTo: Self.traceURL) { - _ = try? opened.seekToEnd() - handle = opened - } else { - openFailed = true - NSLog("[ControllerKeys][Oura] motion trace open failed at %@", path) - return - } - } - guard let handle else { return } - do { - try handle.write(contentsOf: data) - } catch { - NSLog("[ControllerKeys][Oura] motion trace write failed: %@", error.localizedDescription) - try? handle.close() - self.handle = nil - openFailed = true - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraRingInputService.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraRingInputService.swift deleted file mode 100644 index 492b5760..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraRingInputService.swift +++ /dev/null @@ -1,1336 +0,0 @@ -import Combine -import CommonCrypto -import CoreBluetooth -import CoreGraphics -import Foundation - -final class OuraRingCommandCenter { - static let shared = OuraRingCommandCenter() - - private let lock = NSLock() - private var centerHandler: (() -> Void)? - private var toggleMotionHandler: (() -> Void)? - - private init() {} - - func install(center: @escaping () -> Void, toggleMotion: @escaping () -> Void) { - lock.lock() - centerHandler = center - toggleMotionHandler = toggleMotion - lock.unlock() - } - - #if DEBUG - func resetHandlersForTesting() { - lock.lock() - centerHandler = nil - toggleMotionHandler = nil - lock.unlock() - } - #endif - - func centerRing() -> Bool { - lock.lock() - let handler = centerHandler - lock.unlock() - return run(handler) - } - - func toggleMotionOutput() -> Bool { - lock.lock() - let handler = toggleMotionHandler - lock.unlock() - return run(handler) - } - - private func run(_ handler: (() -> Void)?) -> Bool { - guard let handler else { return false } - DispatchQueue.main.async(execute: handler) - return true - } -} - -enum OuraRingConnectionStatus: Equatable { - case disabled - case bluetoothUnavailable(String) - case scanning - case connecting(String) - case connected(String) - case adopting - case authenticating - case authenticated(String) - case authFailed(String) - case disconnected - - var displayName: String { - switch self { - case .disabled: return "Disabled" - case .bluetoothUnavailable(let reason): return "Bluetooth unavailable: \(reason)" - case .scanning: return "Scanning" - case .connecting(let name): return "Connecting to \(name)" - case .connected(let name): return "Connected to \(name)" - case .adopting: return "Adopting reset ring" - case .authenticating: return "Authenticating" - case .authenticated(let name): return "Authenticated: \(name)" - case .authFailed(let reason): return "Auth failed: \(reason)" - case .disconnected: return "Disconnected" - } - } -} - -private extension CBPeripheralState { - var diagnosticName: String { - switch self { - case .disconnected: - return "disconnected" - case .connecting: - return "connecting" - case .connected: - return "connected" - case .disconnecting: - return "disconnecting" - @unknown default: - return "unknown" - } - } -} - -@MainActor -final class OuraRingInputService: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate { - @Published private(set) var status: OuraRingConnectionStatus = .disabled - @Published private(set) var lastDiagnosticLine: String = "" - - private let controllerService: ControllerService - private let profileManager: ProfileManager - private var cancellables = Set() - - private var centralManager: CBCentralManager? - private var peripheral: CBPeripheral? - private var writeCharacteristic: CBCharacteristic? - private var notifyCharacteristic: CBCharacteristic? - private var currentSettings: OuraMotionSettings = .default - private var motionMapper = OuraMotionMapper(settings: .default) - private var tapDetector = OuraTapDetector() - private var authKey: Data? - private var pendingAdoptionKey: Data? - private var tapReleaseWorkItem: DispatchWorkItem? - private var tapSequenceWorkItem: DispatchWorkItem? - private var connectTimeoutWorkItem: DispatchWorkItem? - private var realtimeRefreshTimer: Timer? - private var disconnectWithoutRestartPeripheralID: UUID? - private var tapSequence = OuraTapSequenceRecognizer() - private var tapMotionSuppressor = OuraTapMotionSuppressor() - private var tapHoldRecognizer = OuraTapHoldRecognizer() - private var flickRecognizer = OuraDirectionalFlickRecognizer() - private var isMigratingLegacyDeadzone = false - private var connectAttemptStartTime: CFAbsoluteTime? - private var lastAccelerometerTapTime: CFAbsoluteTime = 0 - private var lastMotionDiagnosticTime: CFAbsoluteTime = 0 - private var suppressTapDetectionUntil: CFAbsoluteTime = 0 - private let motionTrace = OuraMotionTraceWriter() - - // ML gesture-event path (OuraGestureEventClassifier.swift); falls back to - // the heuristic recognizers until the model loads or when the - // ouraGestureClassifierDisabled default is set. - private var streamingActivity: NSObjectProtocol? - private var motionWatchdogTimer: Timer? - private var lastMotionSampleWallTime: CFAbsoluteTime = 0 - private var stallSticksReleased = false - private var lastStallRearmTime: CFAbsoluteTime = 0 - private let gestureClassifier = OuraGestureEventClassifier() - private var motionWindowBuffer = OuraMotionWindowBuffer() - private var impulseDetector = OuraImpulseDetector() - private var pendingClassificationPeaks: [CFAbsoluteTime] = [] - private var flickClassificationCooldownUntil: CFAbsoluteTime = 0 - private var tapHoldFedThrough: CFAbsoluteTime = -.greatestFiniteMagnitude - private let flickClassificationCooldown: CFTimeInterval = 0.65 - private let flickConfidenceThreshold = 0.5 - private let flickMidSequenceConfidenceThreshold = 0.85 - private let postResolveClassificationCooldown: CFTimeInterval = 0.35 - private let postHoldClassificationCooldown: CFTimeInterval = 0.6 - private let tapDetectionMargin: CFTimeInterval = 0.20 - private let tapLeanProbabilityThreshold = 0.45 - private let tapCorroborationFloor = 0.10 - private let tapCorroborationWindow: CFTimeInterval = 0.12 - private var heuristicTapFires: [CFAbsoluteTime] = [] - private var useMLGesturePath: Bool { - gestureClassifier.isAvailable && - !UserDefaults.standard.bool(forKey: "ouraGestureClassifierDisabled") - } - - private let keychainService = "com.controllerkeys.oura-ring" - private let authKeyAccount = "oura-auth-key-v1" - private let legacyDefaultDeadzone = 0.08 - private let connectAttemptTimeout: CFTimeInterval = 20.0 - private let realtimeAccelerometerDurationMinutes: UInt16 = 10 - private let realtimeRefreshInterval: TimeInterval = 9 * 60 - private let tapMotionSuppressionDuration = OuraTapSequenceRecognizer.sequenceWindow + 0.20 - private let tapMotionPostActionSuppressionDuration: CFTimeInterval = 0.18 - private let accelerometerTapRefractory: CFTimeInterval = 0.16 - private var loggedNearbyPeripheralIDs: Set = [] - private var diagnosticLogURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Logs", isDirectory: true) - .appendingPathComponent("ControllerKeys-Oura.log") - } - - init(controllerService: ControllerService, profileManager: ProfileManager) { - self.controllerService = controllerService - self.profileManager = profileManager - super.init() - - OuraRingCommandCenter.shared.install( - center: { [weak self] in self?.resetMotionCenter() }, - toggleMotion: { [weak self] in self?.toggleMotionOutputEnabled() } - ) - - profileManager.$activeProfile - .map { $0?.joystickSettings.ouraMotion ?? .default } - .removeDuplicates() - .sink { [weak self] settings in - self?.apply(settings) - } - .store(in: &cancellables) - } - - func startIfEnabled() { - guard currentSettings.enabled, !AppRuntime.isRunningTests else { return } - gestureClassifier.loadIfNeeded() - if centralManager == nil { - centralManager = CBCentralManager(delegate: self, queue: .main) - } else if centralManager?.state == .poweredOn { - scanForRing() - } - } - - func stop() { - stopInternal(status: .disabled) - } - - func forgetRingKeyAndReconnect() { - KeychainService.deletePassword(key: authKeyAccount, service: keychainService) - authKey = nil - pendingAdoptionKey = nil - appendDiagnostic("forgot stored Oura auth key") - if currentSettings.enabled { - stopInternal(status: .disconnected) - startIfEnabled() - } - } - - func resetMotionCenter() { - tapSequenceWorkItem?.cancel() - tapSequenceWorkItem = nil - tapSequence.reset() - tapHoldRecognizer.reset() - flickRecognizer.reset() - motionMapper.reset() - tapDetector.reset() - // Deliberately NOT resetMLGestureState(): the window buffer and - // impulse detector track raw motion, which recentering doesn't change, - // and clearing them blacks out detection for ~1.4s after every - // recenter (Kevin recenters constantly during normal use). The 0.75s - // center suppression below already guards against the recenter jolt. - pendingClassificationPeaks.removeAll() - releaseOuraMotionSticks() - appendDiagnostic("motion center will reset on next accelerometer sample") - } - - func setMotionOutputEnabled(_ enabled: Bool) { - guard var newSettings = profileManager.activeProfile?.joystickSettings else { return } - guard newSettings.ouraMotion.motionOutputEnabled != enabled else { - if !enabled { - releaseOuraMotionSticks() - } - return - } - newSettings.ouraMotion.motionOutputEnabled = enabled - profileManager.updateJoystickSettings(newSettings) - if !enabled { - releaseOuraMotionSticks() - } - } - - func toggleMotionOutputEnabled() { - setMotionOutputEnabled(!currentSettings.motionOutputEnabled) - resetMotionCenter() - } - - func pauseMotionOutput() { - setMotionOutputEnabled(false) - } - - private func apply(_ settings: OuraMotionSettings) { - if shouldMigrateLegacyDeadzone(settings) { - migrateLegacyDeadzone() - return - } - - let oldSettings = currentSettings - currentSettings = settings - motionMapper.settings = settings - if oldSettings.enabled != settings.enabled || oldSettings.targetStick != settings.targetStick { - resetMotionCenter() - } else if oldSettings.motionOutputEnabled != settings.motionOutputEnabled { - releaseOuraMotionSticks() - appendDiagnostic(settings.motionOutputEnabled ? "motion output resumed" : "motion output paused") - } - if settings.enabled { - startIfEnabled() - } else { - stopInternal(status: .disabled) - } - } - - private func shouldMigrateLegacyDeadzone(_ settings: OuraMotionSettings) -> Bool { - settings.enabled && - !isMigratingLegacyDeadzone && - abs(settings.deadzone - legacyDefaultDeadzone) < 0.000_001 - } - - private func migrateLegacyDeadzone() { - guard var newSettings = profileManager.activeProfile?.joystickSettings else { return } - isMigratingLegacyDeadzone = true - newSettings.ouraMotion.deadzone = OuraMotionSettings.default.deadzone - profileManager.updateJoystickSettings(newSettings) - isMigratingLegacyDeadzone = false - appendDiagnostic("migrated Oura deadzone to \(String(format: "%.2f", OuraMotionSettings.default.deadzone))") - } - - private func stopInternal(status newStatus: OuraRingConnectionStatus) { - clearConnectAttempt() - disconnectWithoutRestartPeripheralID = nil - realtimeRefreshTimer?.invalidate() - realtimeRefreshTimer = nil - endStreamingActivity() - stopMotionWatchdog() - resetInputSessionForConnectionLoss() - motionTrace.close() - controllerService.setOuraRingConnected(false) - - if centralManager?.isScanning == true { - centralManager?.stopScan() - } - if peripheral != nil, writeCharacteristic != nil { - write(OuraRingProtocol.stopRealtimeCommand()) - } - if let peripheral { - centralManager?.cancelPeripheralConnection(peripheral) - } - peripheral = nil - writeCharacteristic = nil - notifyCharacteristic = nil - status = newStatus - } - - func resetInputSessionForConnectionLoss() { - tapReleaseWorkItem?.cancel() - tapReleaseWorkItem = nil - tapSequenceWorkItem?.cancel() - tapSequenceWorkItem = nil - tapSequence.reset() - tapMotionSuppressor.reset() - tapHoldRecognizer.reset() - flickRecognizer.reset() - motionMapper.reset() - tapDetector.reset() - resetMLGestureState() - controllerService.releaseOuraRingInputs() - } - - private func scanForRing() { - guard currentSettings.enabled, let centralManager, centralManager.state == .poweredOn else { return } - - let connected = centralManager.retrieveConnectedPeripherals(withServices: [OuraRingProtocol.serviceUUID]) - if let ring = connected.first { - appendDiagnostic("using already-connected Oura service peripheral \(displayName(for: ring))") - connect(to: ring) - return - } - - status = .scanning - loggedNearbyPeripheralIDs.removeAll() - appendDiagnostic("scanning for Oura ring by advertised service or local name") - centralManager.scanForPeripherals( - withServices: nil, - options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] - ) - } - - private func connect(to peripheral: CBPeripheral) { - let now = CFAbsoluteTimeGetCurrent() - if let existingPeripheral = self.peripheral, - existingPeripheral.identifier == peripheral.identifier { - switch existingPeripheral.state { - case .connected: - appendDiagnostic("already connected to Oura candidate \(displayName(for: existingPeripheral))") - return - case .connecting where !connectionAttemptTimedOut(now: now): - appendDiagnostic("already connecting to Oura candidate \(displayName(for: existingPeripheral))") - return - case .connecting: - appendDiagnostic("retrying stale Oura connection to \(displayName(for: peripheral))") - centralManager?.cancelPeripheralConnection(existingPeripheral) - clearConnectAttempt() - case .disconnecting: - appendDiagnostic("retrying after disconnecting Oura peripheral \(displayName(for: peripheral))") - centralManager?.cancelPeripheralConnection(existingPeripheral) - case .disconnected: - appendDiagnostic("retrying disconnected Oura peripheral \(displayName(for: peripheral))") - @unknown default: - appendDiagnostic("retrying unknown-state Oura peripheral \(displayName(for: peripheral))") - } - self.peripheral = nil - } else if let existingPeripheral = self.peripheral { - switch existingPeripheral.state { - case .connected: - appendDiagnostic( - "ignored Oura candidate while connected \(displayName(for: existingPeripheral))" - ) - return - case .connecting where !connectionAttemptTimedOut(now: now): - appendDiagnostic( - "ignored Oura candidate while connecting \(displayName(for: existingPeripheral))" - ) - return - case .connecting: - appendDiagnostic("replacing stale connecting Oura peripheral with \(displayName(for: peripheral))") - centralManager?.cancelPeripheralConnection(existingPeripheral) - clearConnectAttempt() - self.peripheral = nil - case .disconnecting: - appendDiagnostic("replacing disconnecting Oura peripheral with \(displayName(for: peripheral))") - centralManager?.cancelPeripheralConnection(existingPeripheral) - self.peripheral = nil - case .disconnected: - appendDiagnostic("replacing stale Oura peripheral with \(displayName(for: peripheral))") - self.peripheral = nil - @unknown default: - appendDiagnostic("replacing unknown-state Oura peripheral with \(displayName(for: peripheral))") - self.peripheral = nil - } - } - self.peripheral = peripheral - peripheral.delegate = self - status = .connecting(displayName(for: peripheral)) - centralManager?.stopScan() - appendDiagnostic("connecting to Oura candidate \(displayName(for: peripheral))") - connectAttemptStartTime = now - scheduleConnectTimeout(for: peripheral) - centralManager?.connect(peripheral, options: nil) - } - - private func connectionAttemptTimedOut(now: CFAbsoluteTime) -> Bool { - guard let connectAttemptStartTime else { return false } - return now - connectAttemptStartTime >= connectAttemptTimeout - } - - private func clearConnectAttempt() { - connectTimeoutWorkItem?.cancel() - connectTimeoutWorkItem = nil - connectAttemptStartTime = nil - } - - func failCurrentConnection(_ reason: String) { - appendDiagnostic("connection failed: \(reason)") - status = .authFailed(reason) - clearConnectAttempt() - disconnectWithoutRestartPeripheralID = nil - pendingAdoptionKey = nil - realtimeRefreshTimer?.invalidate() - realtimeRefreshTimer = nil - endStreamingActivity() - stopMotionWatchdog() - resetInputSessionForConnectionLoss() - motionTrace.close() - controllerService.setOuraRingConnected(false) - - guard let peripheral else { - writeCharacteristic = nil - notifyCharacteristic = nil - return - } - - disconnectWithoutRestartPeripheralID = peripheral.identifier - if writeCharacteristic != nil { - write(OuraRingProtocol.stopRealtimeCommand()) - } - centralManager?.cancelPeripheralConnection(peripheral) - writeCharacteristic = nil - notifyCharacteristic = nil - } - - func handleBluetoothUnavailable(_ reason: String) { - appendDiagnostic("bluetooth unavailable: \(reason)") - clearConnectAttempt() - disconnectWithoutRestartPeripheralID = nil - pendingAdoptionKey = nil - realtimeRefreshTimer?.invalidate() - realtimeRefreshTimer = nil - endStreamingActivity() - stopMotionWatchdog() - resetInputSessionForConnectionLoss() - motionTrace.close() - if centralManager?.isScanning == true { - centralManager?.stopScan() - } - peripheral = nil - writeCharacteristic = nil - notifyCharacteristic = nil - controllerService.setOuraRingConnected(false) - status = .bluetoothUnavailable(reason) - } - - private func scheduleConnectTimeout(for peripheral: CBPeripheral) { - connectTimeoutWorkItem?.cancel() - let peripheralID = peripheral.identifier - let peripheralName = displayName(for: peripheral) - let workItem = DispatchWorkItem { [weak self, weak peripheral] in - guard let self else { return } - guard self.peripheral?.identifier == peripheralID, - self.peripheral?.state == .connecting else { return } - - self.appendDiagnostic("connect timeout for Oura candidate \(peripheralName); restarting scan") - if let peripheral { - self.centralManager?.cancelPeripheralConnection(peripheral) - } - self.clearConnectAttempt() - self.peripheral = nil - self.status = .disconnected - if self.currentSettings.enabled { - self.scanForRing() - } - } - connectTimeoutWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + connectAttemptTimeout, execute: workItem) - } - - private func authenticateOrAdopt() { - guard writeCharacteristic != nil else { return } - if let key = loadAuthKey() { - authKey = key - status = .authenticating - write(OuraRingProtocol.nonceCommand()) - return - } - - guard currentSettings.adoptResetRing else { - failCurrentConnection("no stored key; reset the ring or enable reset-ring adoption") - return - } - - guard let newKey = OuraRingProtocol.newAuthKey() else { - failCurrentConnection("could not generate auth key") - return - } - - pendingAdoptionKey = newKey - status = .adopting - write(OuraRingProtocol.installKeyCommand(newKey)) - } - - private func finishAuthentication(peripheral: CBPeripheral) { - status = .authenticated(displayName(for: peripheral)) - controllerService.setOuraRingConnected(true) - write(OuraRingProtocol.enableNotificationsCommand()) - write(OuraRingProtocol.readFeatureCommand(OuraRingProtocol.tapToTagFeature)) - write(OuraRingProtocol.enableFeatureCommand(OuraRingProtocol.tapToTagFeature, value: 0x03)) - write(OuraRingProtocol.subscribeFeatureCommand(OuraRingProtocol.tapToTagFeature, value: 0x02)) - startRealtimeAccelerometer() - } - - private func write(_ data: Data) { - guard let peripheral, let writeCharacteristic else { return } - appendDiagnostic("tx \(data.ouraHexString)") - peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse) - } - - private func startRealtimeAccelerometer() { - write(OuraRingProtocol.startAccelerometerCommand(durationMinutes: realtimeAccelerometerDurationMinutes)) - realtimeRefreshTimer?.invalidate() - realtimeRefreshTimer = Timer.scheduledTimer(withTimeInterval: realtimeRefreshInterval, repeats: true) { [weak self] _ in - Task { @MainActor in - guard let self, self.currentSettings.enabled, self.writeCharacteristic != nil else { return } - self.write(OuraRingProtocol.startAccelerometerCommand(durationMinutes: self.realtimeAccelerometerDurationMinutes)) - } - } - if let realtimeRefreshTimer { - RunLoop.main.add(realtimeRefreshTimer, forMode: .common) - } - beginStreamingActivity() - startMotionWatchdog() - probeTapToTagFeatureIfEnabled() - } - - // Research probe (defaults: ouraTapFeatureProbe): the protocol spec lists - // feature 0x07 "Tap-to-tag" — an ON-RING tap detector running at the - // sensor's native rate, which would beat our 48 Hz BLE reconstruction at - // the physics level. The decoder already handles its push frame - // (2f 02 28 07 → .tap, source "tap feature"); nobody has found the enable - // command. Try the live-HR enable pattern (feature 0x02 uses - // 2F 02 20 02 / 2F 03 22 02 03 / 2F 03 26 02 02) transposed to 0x07. - // Watch ~/Library/Logs/ControllerKeys-Oura.log for "tap-candidate … tap - // feature" lines and "rx unknown" frames after tapping. - private func probeTapToTagFeatureIfEnabled() { - guard UserDefaults.standard.bool(forKey: "ouraTapFeatureProbe") else { return } - // Round 2: the round-1 triplet drew ack 27 07 01 but no tap pushes — - // iterate the mode bytes and echo the ring's own connect announcement - // (07 01 00) as a command. - // Round 3: arm tap pushes via the realtime command's feature BITMASK - // (accelerometer = 0x20; rounds 1-2 showed the 2f family only reads - // feature 07). Each frame keeps 0x20 set so the stream survives; the - // stall watchdog re-arms plain 0x20 within 2s if the ring rejects one. - let candidates: [[UInt8]] = [ - [0x06, 0x07, 0x21, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00], - [0x06, 0x07, 0x22, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00], - [0x06, 0x07, 0x24, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00], - [0x06, 0x07, 0x28, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00], - [0x06, 0x07, 0x27, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00] - ] - for (index, frame) in candidates.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5 * Double(index + 1)) { [weak self] in - guard let self, self.writeCharacteristic != nil else { return } - self.appendDiagnostic("tap-to-tag probe tx \(Data(frame).ouraHexString)") - self.write(Data(frame)) - } - } - } - - // App Nap freezes BLE delivery and the realtime-refresh timer as soon as - // ControllerKeys is not the active app — the ring's cursor/gestures die - // until the app is foregrounded again (observed 2026-07-06: trace went - // from ~50 samples/s to 0 the moment another app took focus). Hold a - // user-initiated activity while the ring streams; allow idle system sleep. - private func beginStreamingActivity() { - guard streamingActivity == nil else { return } - streamingActivity = ProcessInfo.processInfo.beginActivity( - options: .userInitiatedAllowingIdleSystemSleep, - reason: "Oura ring realtime motion streaming" - ) - } - - private func endStreamingActivity() { - if let streamingActivity { - ProcessInfo.processInfo.endActivity(streamingActivity) - self.streamingActivity = nil - } - } - - // If the stream dies mid-deflection (BLE dropout, ring pausing realtime), - // the virtual stick would stay latched and the cursor drifts. The watchdog - // releases the sticks AND actively re-arms the realtime accelerometer: - // live traces showed the stream silently dying for 6-84s stretches (seven - // gaps in 45 min) with the 9-minute refresh timer as the only revival — - // re-arming every 2s during a stall turns those into ~2-3s blips. - private func startMotionWatchdog() { - guard motionWatchdogTimer == nil else { return } - let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor in - guard let self else { return } - guard self.lastMotionSampleWallTime > 0 else { return } - let now = CFAbsoluteTimeGetCurrent() - let stalledFor = now - self.lastMotionSampleWallTime - guard stalledFor > 1.2 else { return } - if !self.stallSticksReleased { - self.stallSticksReleased = true - self.releaseOuraMotionSticks() - self.appendDiagnostic("motion stream stalled — sticks released by watchdog") - } - if now - self.lastStallRearmTime > 2.0, self.peripheral != nil, self.writeCharacteristic != nil { - self.lastStallRearmTime = now - self.write(OuraRingProtocol.startAccelerometerCommand( - durationMinutes: self.realtimeAccelerometerDurationMinutes)) - self.appendDiagnostic(String(format: "motion stream stalled %.1fs — re-arming realtime accelerometer", stalledFor)) - } - } - } - RunLoop.main.add(timer, forMode: .common) - motionWatchdogTimer = timer - } - - private func stopMotionWatchdog() { - motionWatchdogTimer?.invalidate() - motionWatchdogTimer = nil - lastMotionSampleWallTime = 0 - stallSticksReleased = false - lastStallRearmTime = 0 - } - - private func releaseOuraMotionSticks() { - for side in JoystickSide.allCases { - controllerService.updateOuraRingStick(.zero, side: side) - } - } - - private func releaseOuraGestureButtons() { - for button in ControllerButton.ouraRingButtons { - controllerService.handleButton(button, pressed: false) - } - } - - private func handle(_ event: OuraRingDecodedEvent, from peripheral: CBPeripheral) { - switch event { - case .nonce(let nonce): - guard let authKey, let command = OuraRingProtocol.authProofCommand(nonce: nonce, key: authKey) else { - failCurrentConnection("could not build auth proof") - return - } - write(command) - case .authStatus(let authStatus): - switch authStatus { - case .success: - finishAuthentication(peripheral: peripheral) - case .inFactoryReset where currentSettings.adoptResetRing: - guard let newKey = OuraRingProtocol.newAuthKey() else { - failCurrentConnection("could not generate reset-ring key") - return - } - pendingAdoptionKey = newKey - status = .adopting - write(OuraRingProtocol.installKeyCommand(newKey)) - case .wrongKey, .inFactoryReset, .notOriginalDevice: - failCurrentConnection(authStatus.displayName) - } - case .keyInstallStatus(let success): - guard success, let key = pendingAdoptionKey else { - failCurrentConnection("ring rejected auth key install") - return - } - saveAuthKey(key) - authKey = key - pendingAdoptionKey = nil - status = .authenticating - write(OuraRingProtocol.nonceCommand()) - case .tap: - handleTapCandidate(at: CFAbsoluteTimeGetCurrent(), source: "tap feature") - case .motion(let sample): - applyMotion(sample) - case .unknown(let hex): - appendDiagnostic("rx unknown \(hex)") - } - } - - private func applyMotion(_ sample: OuraMotionSample) { - lastMotionSampleWallTime = CFAbsoluteTimeGetCurrent() - stallSticksReleased = false - let result = motionMapper.mappingResult(forRawSample: sample) - if result.didEstablishCenter { - appendDiagnostic(String(format: "motion center %.2f %.2f %.2f", sample.x, sample.y, sample.z)) - } - - let centeredSample = result.centeredSample - let projectedInput = result.projectedInput - let stick = result.stick - motionTrace.recordSample(sample, projected: projectedInput) - if result.didEstablishCenter { - suppressTapDetectionUntil = max(suppressTapDetectionUntil, sample.timestamp + 0.75) - motionTrace.recordEvent("center", timestamp: sample.timestamp) - } - if let drift = result.autoRecenterDriftDegrees { - suppressTapDetectionUntil = max(suppressTapDetectionUntil, sample.timestamp + 0.75) - motionTrace.recordEvent("auto-recenter", detail: String(format: "%.0f", drift), timestamp: sample.timestamp) - appendDiagnostic(String(format: "auto recenter: %.0f° drift at rest", drift)) - } - if useMLGesturePath { - applyMotionGesturesML(sample, projectedInput: projectedInput) - } else { - let detectedTap = sample.timestamp >= suppressTapDetectionUntil && tapDetector.register(sample) - if detectedTap { - motionTrace.recordEvent("tap-detected", timestamp: sample.timestamp) - } - if detectedTap { - handleAccelerometerTapCandidate(at: sample.timestamp, sample: sample) - } else { - handleTapHoldCandidate(sample) - handleDirectionalFlickCandidate(projectedInput: projectedInput, timestamp: sample.timestamp) - } - } - let outputStick = tapMotionSuppressor.isSuppressed(at: sample.timestamp) ? .zero : stick - controllerService.updateOuraRingStick(outputStick, side: currentSettings.targetStick) - - if sample.timestamp - lastMotionDiagnosticTime > 0.25 { - lastMotionDiagnosticTime = sample.timestamp - appendDiagnostic(String( - format: "motion raw %.2f %.2f %.2f centered %.2f %.2f %.2f input %.2f %.2f stick %.2f %.2f", - sample.x, sample.y, sample.z, - centeredSample.x, centeredSample.y, centeredSample.z, - projectedInput.x, projectedInput.y, - outputStick.x, outputStick.y - )) - } - } - - private func handleAccelerometerTapCandidate(at timestamp: CFAbsoluteTime, sample: OuraMotionSample?) { - guard timestamp - lastAccelerometerTapTime > accelerometerTapRefractory else { return } - - lastAccelerometerTapTime = timestamp - handleTapCandidate(at: timestamp, source: "accelerometer spike", sample: sample) - } - - private func handleTapCandidate(at timestamp: CFAbsoluteTime, source: String, sample: OuraMotionSample? = nil) { - motionTrace.recordEvent("tap-candidate", detail: source, timestamp: timestamp) - switch tapSequence.registerTap(at: timestamp) { - case .duplicate: - return - case .pending(let count): - if count == 1 { - tapHoldRecognizer.registerTap(at: timestamp, sample: sample) - } else { - tapHoldRecognizer.cancel() - } - suppressMotionForTap(at: timestamp, duration: tapMotionSuppressionDuration) - appendDiagnostic("\(count)x tap pending from \(source)") - scheduleTapSequenceResolution() - case .completed(let count): - tapHoldRecognizer.cancel() - tapSequenceWorkItem?.cancel() - tapSequenceWorkItem = nil - suppressMotionForTap(at: timestamp, duration: tapMotionPostActionSuppressionDuration) - performTapSequenceAction(.tapCount(count)) - } - } - - private func handleTapHoldCandidate(_ sample: OuraMotionSample) { - guard tapHoldRecognizer.registerMotion(sample) else { return } - motionTrace.recordEvent("tap-hold", timestamp: sample.timestamp) - // The hold grew out of a pending tap — swallow that tap regardless of - // whether a hold action is bound, or it also resolves as a phantom - // single 0.67s later. The cooldown suppresses the release ghost (the - // hand moving again after the hold classifies as a fresh tap). - tapSequenceWorkItem?.cancel() - tapSequenceWorkItem = nil - tapSequence.reset() - flickClassificationCooldownUntil = max( - flickClassificationCooldownUntil, - sample.timestamp + postHoldClassificationCooldown - ) - if fireGestureButton(.ouraTapHold) { - suppressMotionForTap(at: sample.timestamp, duration: tapMotionPostActionSuppressionDuration) - appendDiagnostic("tap hold resolved") - } else { - appendDiagnostic("tap hold detected without mapping") - } - } - - private func handleDirectionalFlickCandidate(projectedInput: CGPoint, timestamp: CFAbsoluteTime) { - guard !tapMotionSuppressor.isSuppressed(at: timestamp), - let flick = flickRecognizer.register(projectedInput: projectedInput, timestamp: timestamp) else { - return - } - - motionTrace.recordEvent("flick", detail: flick.diagnosticName, timestamp: timestamp) - if fireGestureButton(flick.button) { - suppressMotionForTap(at: timestamp, duration: tapMotionPostActionSuppressionDuration) - appendDiagnostic("flick \(flick.diagnosticName) resolved") - } else { - appendDiagnostic("flick \(flick.diagnosticName) detected without mapping") - } - } - - // MARK: - ML gesture path - - private func applyMotionGesturesML(_ sample: OuraMotionSample, projectedInput: CGPoint) { - motionWindowBuffer.append(sample, projected: projectedInput) - - // The tuned heuristic detector runs in parallel as a corroborator: it - // never fires actions itself (alone it ghost-fires too much — 1219 - // fires/hour live), but its shape verdict breaks ties on borderline - // ML windows in classifyImpulsePeak. Live measurement: +179 real - // taps/hour recovered, zero cost on both labeled sessions. - if sample.timestamp >= suppressTapDetectionUntil, tapDetector.register(sample) { - heuristicTapFires.append(sample.timestamp) - if heuristicTapFires.count > 16 { - heuristicTapFires.removeFirst(heuristicTapFires.count - 16) - } - } - - while let peak = pendingClassificationPeaks.first, - sample.timestamp >= peak + OuraMotionWindowBuffer.postSpan { - pendingClassificationPeaks.removeFirst() - classifyImpulsePeak(peak, now: sample.timestamp) - } - - if let peak = impulseDetector.register(sample), - peak >= suppressTapDetectionUntil, - peak >= flickClassificationCooldownUntil { - pendingClassificationPeaks.append(peak) - } - - // The hold recognizer consumes the motion stream continuously; skip - // samples the retroactive catch-up in handleMLTapCandidate already fed. - if sample.timestamp > tapHoldFedThrough { - tapHoldFedThrough = sample.timestamp - handleTapHoldCandidate(sample) - } - } - - private func classifyImpulsePeak(_ peak: CFAbsoluteTime, now: CFAbsoluteTime) { - // The enqueue-time cooldown check can't catch peaks that were already - // queued when a flick fired — a flick's secondary spikes arrive within - // ~0.3s and would double-fire it. Anything inside the cooldown is the - // previous flick's echo; drop it outright. - guard peak >= flickClassificationCooldownUntil else { return } - guard let window = motionWindowBuffer.window(around: peak), - var (event, confidence, tapProbability) = gestureClassifier.classify(window: window) else { - return - } - // Tap-lean rule: light/casual taps sometimes lose the argmax to noise - // by a hair (live: 70 borderline windows in 40 min with P(tap) ≥ 0.3). - // Firing tap at P(tap) ≥ 0.45 recovers them at zero cost on both - // labeled sessions (noise trials stay clean). - if event == .noise { - if tapProbability >= tapLeanProbabilityThreshold { - event = .tap - } else if tapProbability >= tapCorroborationFloor, - heuristicTapFires.contains(where: { abs($0 - peak) <= tapCorroborationWindow }) { - // Two weak signals agreeing: a borderline ML window plus the - // shape detector firing for the same instant is a tap. - event = .tap - } - } - motionTrace.recordEvent("ml-class", - detail: "\(event.rawValue) \(String(format: "%.2f", confidence))", timestamp: peak) - switch event { - case .noise: - break - case .tap: - handleMLTapCandidate(at: peak) - case .flickUp, .flickDown, .flickLeft, .flickRight: - guard let flick = event.directionalFlick else { return } - // The v0 model can't cleanly separate casual flicks from - // cursor-motion impulses (measured confidences overlap; Kevin's - // real flicks ranged 0.35-1.00). 0.5 keeps most real flicks and - // drops the low tail; retraining with cursor-navigation negatives - // is the durable fix. - guard confidence >= flickConfidenceThreshold else { - appendDiagnostic("flick \(flick.diagnosticName) ignored (ml, conf \(String(format: "%.2f", confidence)))") - return - } - // A pending tap registered within the flick's own window is the - // flick's outbound spike — consume it so it can't also resolve as - // a phantom tap. But hand-settle after a tap CHAIN classifies as - // flick-down at ~0.65-0.75 confidence (real flicks score 0.9+ on - // the merged model), and consuming there was eating 3x/5x counts — - // interrupting a pending sequence needs the higher bar. - if let lastTap = tapSequence.lastPendingTapTime, - peak - lastTap <= OuraMotionWindowBuffer.preSpan + OuraMotionWindowBuffer.postSpan { - guard confidence >= flickMidSequenceConfidenceThreshold else { - appendDiagnostic("flick \(flick.diagnosticName) ignored mid-sequence (ml, conf \(String(format: "%.2f", confidence)))") - return - } - tapSequenceWorkItem?.cancel() - tapSequenceWorkItem = nil - tapSequence.reset() - tapHoldRecognizer.cancel() - } - flickClassificationCooldownUntil = peak + flickClassificationCooldown - motionTrace.recordEvent("flick", detail: flick.diagnosticName, timestamp: now) - if fireGestureButton(flick.button) { - tapHoldRecognizer.cancel() - suppressMotionForTap(at: now, duration: tapMotionPostActionSuppressionDuration) - appendDiagnostic("flick \(flick.diagnosticName) resolved (ml)") - } else { - appendDiagnostic("flick \(flick.diagnosticName) detected without mapping (ml)") - } - } - } - - private func handleMLTapCandidate(at peak: CFAbsoluteTime) { - motionTrace.recordEvent("tap-candidate", detail: "ml classifier", timestamp: peak) - switch tapSequence.registerTap(at: peak) { - case .duplicate: - return - case .pending(let count): - if count == 1 { - startTapHoldRetroactively(from: peak) - } else { - tapHoldRecognizer.cancel() - } - suppressMotionForTap(at: peak, duration: tapMotionSuppressionDuration) - appendDiagnostic("\(count)x tap pending from ml classifier") - scheduleMLTapSequenceResolution(anchoredAt: peak) - case .completed(let count): - tapHoldRecognizer.cancel() - tapSequenceWorkItem?.cancel() - tapSequenceWorkItem = nil - suppressMotionForTap(at: peak, duration: tapMotionPostActionSuppressionDuration) - performTapSequenceAction(.tapCount(count)) - } - } - - // Classification arrives ~0.4s after the tap peak; anchor the hold - // candidate back at the peak and replay the buffered samples since, so - // hold timing (settle at +0.09s, ring-down drift checks) matches the - // heuristic path. - private func startTapHoldRetroactively(from peak: CFAbsoluteTime) { - let history = motionWindowBuffer.entriesAfter(peak) - guard let anchor = history.first else { return } - tapHoldRecognizer.registerTap(at: peak, sample: OuraMotionSample( - x: anchor.x, y: anchor.y, z: anchor.z, timestamp: anchor.timestamp)) - for entry in history.dropFirst() { - tapHoldFedThrough = max(tapHoldFedThrough, entry.timestamp) - handleTapHoldCandidate(OuraMotionSample( - x: entry.x, y: entry.y, z: entry.z, timestamp: entry.timestamp)) - } - } - - // A candidate mid-classification may extend the tap sequence, so the - // resolution timer defers until the pending window completes. - // - // Anchored at the tap PEAK, not the classification instant: a second - // tap's peak must physically land within sequenceWindow of the first, and - // tapDetectionMargin covers its detector/enqueue lag — anchoring at - // classification time was adding the ~0.42s window lag to every - // single-tap resolution (Kevin: "single tap takes too long to show up"). - private func scheduleMLTapSequenceResolution(anchoredAt peak: CFAbsoluteTime) { - tapSequenceWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in self?.resolveMLTapSequence() } - tapSequenceWorkItem = workItem - let deadline = peak + OuraTapSequenceRecognizer.sequenceWindow + tapDetectionMargin + 0.02 - let delay = max(0.05, deadline - CFAbsoluteTimeGetCurrent()) - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) - } - - private func resolveMLTapSequence() { - if let pending = pendingClassificationPeaks.first { - let delay = max(0.05, pending + OuraMotionWindowBuffer.postSpan + 0.05 - CFAbsoluteTimeGetCurrent()) - let workItem = DispatchWorkItem { [weak self] in self?.resolveMLTapSequence() } - tapSequenceWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) - return - } - guard let action = tapSequence.resolvePending(at: CFAbsoluteTimeGetCurrent()) else { return } - logEchoGuardDowngradeIfNeeded() - performTapSequenceAction(action) - } - - private func logEchoGuardDowngradeIfNeeded() { - guard let gap = tapSequence.lastResolutionEchoGap else { return } - motionTrace.recordEvent("echo-dropped", detail: String(format: "%.3f", gap)) - appendDiagnostic(String(format: "trailing tap %.2fs after previous dropped as settle echo", gap)) - } - - private func resetMLGestureState() { - motionWindowBuffer.reset() - impulseDetector.reset() - pendingClassificationPeaks.removeAll() - heuristicTapFires.removeAll() - flickClassificationCooldownUntil = 0 - tapHoldFedThrough = -.greatestFiniteMagnitude - } - - private func scheduleTapSequenceResolution() { - tapSequenceWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - guard let action = self.tapSequence.resolvePending(at: CFAbsoluteTimeGetCurrent()) else { return } - self.logEchoGuardDowngradeIfNeeded() - self.performTapSequenceAction(action) - } - tapSequenceWorkItem = workItem - DispatchQueue.main.asyncAfter( - deadline: .now() + OuraTapSequenceRecognizer.sequenceWindow + 0.02, - execute: workItem - ) - } - - private func performTapSequenceAction(_ action: OuraTapSequenceResolvedAction) { - tapSequenceWorkItem = nil - tapHoldRecognizer.cancel() - // Brief classification cooldown after a resolved sequence: the hand - // settling after the last tap sheds ghost impulses that would start a - // phantom follow-up sequence or tap-hold (five-tap trials went 4/12 → - // 11/12 in replay with this). Reuses the flick cooldown gate, which - // drops any queued peak inside the window at classification time. - flickClassificationCooldownUntil = max( - flickClassificationCooldownUntil, - CFAbsoluteTimeGetCurrent() + postResolveClassificationCooldown - ) - suppressMotionForTap(at: CFAbsoluteTimeGetCurrent(), duration: tapMotionPostActionSuppressionDuration) - switch action { - case .tapCount(let count): - motionTrace.recordEvent("tap-resolved", detail: String(count)) - appendDiagnostic("\(count)x tap resolved") - switch count { - case 1: - fireGestureButton(.ouraTap) - case 2: - fireGestureButton(.ouraDoubleTap, fallback: { [weak self] in - self?.appendDiagnostic("double tap recentered motion") - self?.resetMotionCenter() - }) - case 3: - fireGestureButton(.ouraTripleTap, fallback: { [weak self] in - self?.appendDiagnostic("triple tap toggled motion output") - self?.toggleMotionOutputEnabled() - }) - case 5: - fireGestureButton(.ouraFiveTap) - default: - appendDiagnostic("\(count)x tap ignored") - } - } - } - - private func suppressMotionForTap(at timestamp: CFAbsoluteTime, duration: CFTimeInterval) { - tapMotionSuppressor.suppress(at: timestamp, duration: duration) - // Tap ring-down can pass the raw-stillness bar briefly — keep the - // auto-recenter monitor out of gesture windows. - motionMapper.autoRecenterMonitor.holdOff(until: timestamp + max(duration, 1.0)) - releaseOuraMotionSticks() - } - - @discardableResult - private func fireGestureButton(_ button: ControllerButton, fallback: (() -> Void)? = nil) -> Bool { - if !hasExplicitGestureBinding(for: button) { - fallback?() - // Still surface the gesture in the Buttons-tab input timeline — - // the engine resolves it as unmapped, logs "(unmapped)", and - // executes nothing. Without this, recognized-but-unbound gestures - // are invisible, which reads as "the ring isn't working". - controllerService.handleButton(button, pressed: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.07) { [weak self] in - self?.controllerService.handleButton(button, pressed: false) - } - return false - } - - tapReleaseWorkItem?.cancel() - controllerService.handleButton(button, pressed: true) - - let workItem = DispatchWorkItem { [weak self] in - self?.controllerService.handleButton(button, pressed: false) - } - tapReleaseWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.07, execute: workItem) - return true - } - - private func hasExplicitGestureBinding(for button: ControllerButton) -> Bool { - guard let profile = profileManager.activeProfile else { return false } - return ButtonMappingResolutionPolicy.hasExplicitBinding(for: button, profile: profile) - } - - private func loadAuthKey() -> Data? { - guard let stored = KeychainService.retrievePassword(key: authKeyAccount, service: keychainService), - let data = Data(base64Encoded: stored), - data.count == kCCKeySizeAES128 else { - return nil - } - return data - } - - private func saveAuthKey(_ key: Data) { - KeychainService.storePassword(key.base64EncodedString(), key: authKeyAccount, service: keychainService) - } - - private func displayName(for peripheral: CBPeripheral) -> String { - peripheral.name?.isEmpty == false ? peripheral.name! : "Oura Ring" - } - - private func displayName(for peripheral: CBPeripheral, advertisementData: [String: Any]) -> String { - if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String, - !localName.isEmpty { - return localName - } - return displayName(for: peripheral) - } - - private func isTrackedPeripheral(_ peripheral: CBPeripheral) -> Bool { - self.peripheral?.identifier == peripheral.identifier - } - - private func appendDiagnostic(_ message: String) { - guard currentSettings.diagnosticsEnabled else { return } - lastDiagnosticLine = message - NSLog("[ControllerKeys][Oura] %@", message) - let timestampFormatter = ISO8601DateFormatter() - timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let timestamp = timestampFormatter.string(from: Date()) - let line = "\(timestamp) \(message)\n" - guard let data = line.data(using: .utf8) else { return } - do { - try FileManager.default.createDirectory( - at: diagnosticLogURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if FileManager.default.fileExists(atPath: diagnosticLogURL.path) { - let handle = try FileHandle(forWritingTo: diagnosticLogURL) - try handle.seekToEnd() - try handle.write(contentsOf: data) - try handle.close() - } else { - try data.write(to: diagnosticLogURL, options: .atomic) - } - } catch { - NSLog("[ControllerKeys][Oura] diagnostic file write failed: %@", error.localizedDescription) - } - } - - // MARK: - CBCentralManagerDelegate - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - guard currentSettings.enabled else { - stopInternal(status: .disabled) - return - } - switch central.state { - case .poweredOn: - scanForRing() - case .unauthorized: - handleBluetoothUnavailable("permission denied") - case .poweredOff: - handleBluetoothUnavailable("powered off") - case .unsupported: - handleBluetoothUnavailable("unsupported") - case .resetting, .unknown: - handleBluetoothUnavailable("not ready") - @unknown default: - handleBluetoothUnavailable("unknown state") - } - } - - func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - let match = OuraRingScanMatcher.match( - peripheralName: peripheral.name, - advertisementData: advertisementData - ) - if match == nil, currentSettings.diagnosticsEnabled, !loggedNearbyPeripheralIDs.contains(peripheral.identifier) { - loggedNearbyPeripheralIDs.insert(peripheral.identifier) - let name = displayName(for: peripheral, advertisementData: advertisementData) - if name != "Oura Ring" { - appendDiagnostic("nearby BLE \(name), rssi \(RSSI)") - } - } - guard let match else { return } - appendDiagnostic("discovered Oura candidate \(displayName(for: peripheral, advertisementData: advertisementData)) via \(match), rssi \(RSSI)") - connect(to: peripheral) - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - guard isTrackedPeripheral(peripheral) else { - appendDiagnostic("ignored stale Oura connect for \(displayName(for: peripheral))") - central.cancelPeripheralConnection(peripheral) - return - } - clearConnectAttempt() - status = .connected(displayName(for: peripheral)) - peripheral.discoverServices([OuraRingProtocol.serviceUUID]) - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - guard isTrackedPeripheral(peripheral) else { - appendDiagnostic("ignored stale Oura connect failure for \(displayName(for: peripheral))") - return - } - clearConnectAttempt() - appendDiagnostic("connect failed \(error?.localizedDescription ?? "unknown error")") - self.peripheral = nil - status = .disconnected - if currentSettings.enabled { - scanForRing() - } - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - appendDiagnostic("disconnected \(error?.localizedDescription ?? "no error")") - guard isTrackedPeripheral(peripheral) else { - appendDiagnostic("ignored stale Oura disconnect for \(displayName(for: peripheral))") - return - } - let shouldPreserveFailureStatus = disconnectWithoutRestartPeripheralID == peripheral.identifier - disconnectWithoutRestartPeripheralID = nil - realtimeRefreshTimer?.invalidate() - realtimeRefreshTimer = nil - endStreamingActivity() - stopMotionWatchdog() - motionTrace.close() - clearConnectAttempt() - resetInputSessionForConnectionLoss() - self.peripheral = nil - writeCharacteristic = nil - notifyCharacteristic = nil - controllerService.setOuraRingConnected(false) - if !shouldPreserveFailureStatus { - status = .disconnected - } - if currentSettings.enabled && !shouldPreserveFailureStatus { - scanForRing() - } - } - - // MARK: - CBPeripheralDelegate - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - guard isTrackedPeripheral(peripheral) else { - appendDiagnostic("ignored stale Oura service discovery for \(displayName(for: peripheral))") - return - } - guard error == nil, let services = peripheral.services else { - failCurrentConnection(error?.localizedDescription ?? "service discovery failed") - return - } - - guard let service = services.first(where: { $0.uuid == OuraRingProtocol.serviceUUID }) else { - failCurrentConnection("Oura service not found") - return - } - - peripheral.discoverCharacteristics( - [OuraRingProtocol.writeCharacteristicUUID, OuraRingProtocol.notifyCharacteristicUUID], - for: service - ) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - guard isTrackedPeripheral(peripheral) else { - appendDiagnostic("ignored stale Oura characteristic discovery for \(displayName(for: peripheral))") - return - } - guard error == nil, let characteristics = service.characteristics else { - failCurrentConnection(error?.localizedDescription ?? "characteristic discovery failed") - return - } - - for characteristic in characteristics { - if characteristic.uuid == OuraRingProtocol.writeCharacteristicUUID { - writeCharacteristic = characteristic - } else if characteristic.uuid == OuraRingProtocol.notifyCharacteristicUUID { - notifyCharacteristic = characteristic - peripheral.setNotifyValue(true, for: characteristic) - } - } - - if writeCharacteristic != nil, notifyCharacteristic != nil { - authenticateOrAdopt() - } else { - failCurrentConnection("Oura characteristics not found") - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard isTrackedPeripheral(peripheral) else { - appendDiagnostic("ignored stale Oura notification for \(displayName(for: peripheral))") - return - } - guard error == nil, characteristic.uuid == OuraRingProtocol.notifyCharacteristicUUID, let data = characteristic.value else { - if let error { - appendDiagnostic("notify error \(error.localizedDescription)") - } - return - } - - if data.first != OuraRingProtocol.realtimeAccelerometerResponseTag { - appendDiagnostic("rx \(data.ouraHexString)") - } - for event in OuraRingPacketDecoder.decode(data) { - handle(event, from: peripheral) - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraRingProtocol.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraRingProtocol.swift deleted file mode 100644 index 36f86f5b..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraRingProtocol.swift +++ /dev/null @@ -1,324 +0,0 @@ -import CommonCrypto -import CoreBluetooth -import Foundation -import Security - -enum OuraRingProtocol { - static let serviceUUID = CBUUID(string: "98ED0001-A541-11E4-B6A0-0002A5D5C51B") - static let writeCharacteristicUUID = CBUUID(string: "98ED0002-A541-11E4-B6A0-0002A5D5C51B") - static let notifyCharacteristicUUID = CBUUID(string: "98ED0003-A541-11E4-B6A0-0002A5D5C51B") - - static let tapToTagFeature: UInt8 = 0x07 - static let realtimeAccelerometerBitmask: UInt32 = 0x20 - static let realtimeAccelerometerResponseTag: UInt8 = 0x33 - static let accelerometerCountsPerG = 1000.0 - - static func nonceCommand() -> Data { - Data([0x2f, 0x01, 0x2b]) - } - - static func installKeyCommand(_ key: Data) -> Data { - var data = Data([0x24, UInt8(key.count)]) - data.append(key) - return data - } - - static func authProofCommand(nonce: Data, key: Data) -> Data? { - guard nonce.count == 15, key.count == kCCKeySizeAES128 else { return nil } - var block = Data() - block.append(nonce) - block.append(0x01) - block.append(Data(repeating: 0x10, count: 16)) - guard let encrypted = aes128ECBEncrypt(block, key: key), encrypted.count >= 16 else { return nil } - var command = Data([0x2f, 0x11, 0x2d]) - command.append(encrypted.prefix(16)) - return command - } - - static func readFeatureCommand(_ feature: UInt8) -> Data { - Data([0x2f, 0x02, 0x20, feature]) - } - - static func enableFeatureCommand(_ feature: UInt8, value: UInt8) -> Data { - Data([0x2f, 0x03, 0x22, feature, value]) - } - - static func subscribeFeatureCommand(_ feature: UInt8, value: UInt8) -> Data { - Data([0x2f, 0x03, 0x26, feature, value]) - } - - static func enableNotificationsCommand() -> Data { - Data([0x1c, 0x01, 0x3f]) - } - - static func startAccelerometerCommand(durationMinutes: UInt16, delay: UInt8 = 0) -> Data { - var data = Data([0x06, 0x07]) - data.append(littleEndianBytes(realtimeAccelerometerBitmask)) - data.append(littleEndianBytes(durationMinutes)) - data.append(delay) - return data - } - - static func stopRealtimeCommand() -> Data { - Data([0x06, 0x04, 0x00, 0x00, 0x00, 0x00]) - } - - static func newAuthKey() -> Data? { - var bytes = [UInt8](repeating: 0, count: kCCKeySizeAES128) - let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard result == errSecSuccess else { return nil } - return Data(bytes) - } - - private static func aes128ECBEncrypt(_ data: Data, key: Data) -> Data? { - guard key.count == kCCKeySizeAES128 else { return nil } - - let outputCapacity = data.count + kCCBlockSizeAES128 - var output = [UInt8](repeating: 0, count: outputCapacity) - var outputLength = 0 - - let status = output.withUnsafeMutableBytes { outputBytes in - data.withUnsafeBytes { dataBytes in - key.withUnsafeBytes { keyBytes in - CCCrypt( - CCOperation(kCCEncrypt), - CCAlgorithm(kCCAlgorithmAES), - CCOptions(kCCOptionECBMode), - keyBytes.baseAddress, - key.count, - nil, - dataBytes.baseAddress, - data.count, - outputBytes.baseAddress, - outputCapacity, - &outputLength - ) - } - } - } - - guard status == kCCSuccess else { return nil } - return Data(output.prefix(outputLength)) - } - - private static func littleEndianBytes(_ value: UInt32) -> Data { - var littleEndian = value.littleEndian - return Data(bytes: &littleEndian, count: MemoryLayout.size) - } - - private static func littleEndianBytes(_ value: UInt16) -> Data { - var littleEndian = value.littleEndian - return Data(bytes: &littleEndian, count: MemoryLayout.size) - } -} - -enum OuraAuthStatus: UInt8 { - case success = 0 - case wrongKey = 1 - case inFactoryReset = 2 - case notOriginalDevice = 3 - - var displayName: String { - switch self { - case .success: return "authenticated" - case .wrongKey: return "wrong auth key" - case .inFactoryReset: return "factory reset" - case .notOriginalDevice: return "not original onboarded device" - } - } -} - -struct OuraRingFrame: Equatable { - let op: UInt8 - let payload: Data -} - -enum OuraRingDecodedEvent: Equatable { - case nonce(Data) - case authStatus(OuraAuthStatus) - case keyInstallStatus(success: Bool) - case tap - case motion(OuraMotionSample) - case unknown(String) -} - -enum OuraRingScanMatcher { - static func match(peripheralName: String?, advertisementData: [String: Any]) -> String? { - if advertisedServiceUUIDs(in: advertisementData).contains(OuraRingProtocol.serviceUUID) { - return "service" - } - if let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String, - isOuraName(localName) { - return "local name" - } - if let peripheralName, isOuraName(peripheralName) { - return "peripheral name" - } - return nil - } - - static func isOuraName(_ name: String) -> Bool { - let lowered = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return lowered.contains("oura") || lowered.contains("ōura") - } - - private static func advertisedServiceUUIDs(in advertisementData: [String: Any]) -> Set { - var uuids = Set() - for key in [ - CBAdvertisementDataServiceUUIDsKey, - CBAdvertisementDataOverflowServiceUUIDsKey, - CBAdvertisementDataSolicitedServiceUUIDsKey - ] { - if let values = advertisementData[key] as? [CBUUID] { - uuids.formUnion(values) - } - } - if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] { - uuids.formUnion(serviceData.keys) - } - return uuids - } -} - -enum OuraRingPacketDecoder { - static func decode(_ data: Data) -> [OuraRingDecodedEvent] { - let frames = parseFrames(data) - var events: [OuraRingDecodedEvent] = [] - - for frame in frames { - events.append(contentsOf: decode(frame)) - } - - if events.isEmpty, let sample = motionSample(from: [UInt8](data)) { - events.append(.motion(sample)) - } - - if events.isEmpty { - events.append(.unknown(data.ouraHexString)) - } - - return events - } - - static func parseFrames(_ data: Data) -> [OuraRingFrame] { - let bytes = [UInt8](data) - var frames: [OuraRingFrame] = [] - var offset = 0 - - while offset + 2 <= bytes.count { - let op = bytes[offset] - let length = Int(bytes[offset + 1]) - let payloadStart = offset + 2 - - if op == OuraRingProtocol.realtimeAccelerometerResponseTag { - frames.append(OuraRingFrame(op: op, payload: Data(bytes[payloadStart.. [OuraRingDecodedEvent] { - switch frame.op { - case 0x25: - return [.keyInstallStatus(success: frame.payload.first == 0x00)] - case 0x2f: - return decodeSecureFrame(frame.payload) - case OuraRingProtocol.realtimeAccelerometerResponseTag: - return decodeAccelerometerFrame(frame.payload) - default: - if let sample = motionSample(from: [frame.op, UInt8(frame.payload.count)] + [UInt8](frame.payload)) { - return [.motion(sample)] - } - return [.unknown(Data([frame.op, UInt8(frame.payload.count)] + [UInt8](frame.payload)).ouraHexString)] - } - } - - private static func decodeSecureFrame(_ payload: Data) -> [OuraRingDecodedEvent] { - let bytes = [UInt8](payload) - guard let command = bytes.first else { return [] } - let body = Array(bytes.dropFirst()) - - switch command { - case 0x2c where body.count == 15: - return [.nonce(Data(body))] - case 0x2e where body.count >= 1: - let status = OuraAuthStatus(rawValue: body[0]).map { OuraRingDecodedEvent.authStatus($0) } - return [status ?? .unknown(payload.ouraHexString)] - case 0x28: - return decodeFeaturePush(body, originalPayload: payload) - default: - if let sample = motionSample(from: bytes) { - return [.motion(sample)] - } - return [.unknown(payload.ouraHexString)] - } - } - - private static func decodeFeaturePush(_ body: [UInt8], originalPayload: Data) -> [OuraRingDecodedEvent] { - guard let feature = body.first else { return [.unknown(originalPayload.ouraHexString)] } - if feature == OuraRingProtocol.tapToTagFeature { - return [.tap] - } - if let sample = motionSample(from: body) { - return [.motion(sample)] - } - return [.unknown(originalPayload.ouraHexString)] - } - - private static func decodeAccelerometerFrame(_ payload: Data) -> [OuraRingDecodedEvent] { - let bytes = [UInt8](payload) - guard bytes.count >= 8 else { return [] } - - var events: [OuraRingDecodedEvent] = [] - if let sample = accelerometerSample(from: bytes, offset: 2) { - events.append(.motion(sample)) - } - if bytes.count >= 14, let sample = accelerometerSample(from: bytes, offset: 8) { - events.append(.motion(sample)) - } - return events - } - - private static func accelerometerSample(from bytes: [UInt8], offset: Int) -> OuraMotionSample? { - guard offset + 5 < bytes.count else { return nil } - let x = signedInt16(from: bytes, offset: offset) - let y = signedInt16(from: bytes, offset: offset + 2) - let z = signedInt16(from: bytes, offset: offset + 4) - return OuraMotionSample( - x: Double(x) / OuraRingProtocol.accelerometerCountsPerG, - y: Double(y) / OuraRingProtocol.accelerometerCountsPerG, - z: Double(z) / OuraRingProtocol.accelerometerCountsPerG, - timestamp: CFAbsoluteTimeGetCurrent() - ) - } - - private static func signedInt16(from bytes: [UInt8], offset: Int) -> Int16 { - Int16(bitPattern: UInt16(bytes[offset]) | (UInt16(bytes[offset + 1]) << 8)) - } - - private static func motionSample(from bytes: [UInt8]) -> OuraMotionSample? { - guard let recordStart = bytes.firstIndex(of: 0x47), recordStart + 9 < bytes.count else { return nil } - let x = normalizedMotionAxis(bytes[recordStart + 7]) - let y = normalizedMotionAxis(bytes[recordStart + 8]) - let z = normalizedMotionAxis(bytes[recordStart + 9]) - return OuraMotionSample(x: x, y: y, z: z, timestamp: CFAbsoluteTimeGetCurrent()) - } - - private static func normalizedMotionAxis(_ byte: UInt8) -> Double { - let value = Double(Int8(bitPattern: byte)) * 8.0 - return max(-1.0, min(1.0, value / 512.0)) - } -} - -extension Data { - var ouraHexString: String { - map { String(format: "%02x", $0) }.joined(separator: " ") - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraTapSequenceRecognizer.swift b/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraTapSequenceRecognizer.swift deleted file mode 100644 index c01e6b6f..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Services/Oura/OuraTapSequenceRecognizer.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation - -enum OuraTapSequenceImmediateAction: Equatable { - case pending(Int) - case duplicate - case completed(Int) -} - -enum OuraTapSequenceResolvedAction: Equatable { - case tapCount(Int) -} - -struct OuraTapSequenceRecognizer { - // 0.65 → 0.75 (2026-07-06): Kevin's live 3x/5x chains showed inter-tap - // gaps up to ~0.7s splitting sequences; +0.1s window costs the same in - // resolution latency. - static let sequenceWindow: CFTimeInterval = 0.75 - private static let duplicateWindow: CFTimeInterval = 0.09 - private static let maximumTapCount = 5 - - // Echo guard (2026-07-06): a tap's hand-settle rebound lands ~0.18-0.30s - // after the real tap and is physically a second contact — across all three - // labeled sessions it matches real chain taps on every measurable feature - // (peak jerk, classifier confidence, inter-tap gap), so no per-tap gate can - // separate them; even retraining with these windows as noise negatives - // left them classifying as tap at 0.65-1.00. When enabled, at resolution - // time a trailing tap closer than this to its predecessor is dropped as - // settle UNLESS the preceding gap was just as fast (an established 3x/5x - // machine-gun rhythm earns its trailing tap) — double tap then requires a - // deliberate two-beat rhythm, like the macOS double-click-speed setting. - // DISABLED by default: Kevin's echo misfires only occur when the motion - // center is stale (well-centered sessions are clean), and his natural - // doubles gap ~0.29s — inside any guard that would catch the echoes. - // Enable to trade fast doubles for echo immunity: - // defaults write KevinTang.XboxControllerMapper - // ouraDoubleTapMinGap -float 0.35 (0 or unset = disabled) - static let defaultEchoGuardGap: CFTimeInterval = - UserDefaults.standard.double(forKey: "ouraDoubleTapMinGap") - - var echoGuardGap: CFTimeInterval = OuraTapSequenceRecognizer.defaultEchoGuardGap - - /// Gap of the trailing tap dropped as settle echo by the last - /// `resolvePending` call, for diagnostics. Nil when nothing was dropped. - private(set) var lastResolutionEchoGap: CFTimeInterval? - - private var tapTimes: [CFAbsoluteTime] = [] - private var lastAcceptedTapTime: CFAbsoluteTime? - - var hasPendingTaps: Bool { - !tapTimes.isEmpty - } - - var lastPendingTapTime: CFAbsoluteTime? { - tapTimes.last - } - - mutating func reset() { - tapTimes = [] - lastAcceptedTapTime = nil - lastResolutionEchoGap = nil - } - - mutating func registerTap(at timestamp: CFAbsoluteTime) -> OuraTapSequenceImmediateAction { - if let lastAcceptedTapTime, timestamp - lastAcceptedTapTime < Self.duplicateWindow { - return .duplicate - } - - if let last = tapTimes.last, timestamp - last <= Self.sequenceWindow { - tapTimes.append(timestamp) - } else { - tapTimes = [timestamp] - } - lastAcceptedTapTime = timestamp - - if tapTimes.count >= Self.maximumTapCount { - let completedCount = tapTimes.count - tapTimes = [] - return .completed(completedCount) - } - return .pending(tapTimes.count) - } - - mutating func resolvePending(at timestamp: CFAbsoluteTime) -> OuraTapSequenceResolvedAction? { - lastResolutionEchoGap = nil - guard let last = tapTimes.last, timestamp - last >= Self.sequenceWindow else { return nil } - - var times = tapTimes - tapTimes = [] - - if echoGuardGap > 0, times.count >= 2 { - let trailingGap = times[times.count - 1] - times[times.count - 2] - let precedingGap = times.count >= 3 - ? times[times.count - 2] - times[times.count - 3] - : CFTimeInterval.greatestFiniteMagnitude - if trailingGap < echoGuardGap, precedingGap >= echoGuardGap { - times.removeLast() - lastResolutionEchoGap = trailingGap - } - } - - guard !times.isEmpty else { return nil } - return .tapCount(times.count) - } -} - -struct OuraTapMotionSuppressor { - private var suppressUntil: CFAbsoluteTime = 0 - - mutating func reset() { - suppressUntil = 0 - } - - mutating func suppress(at timestamp: CFAbsoluteTime, duration: CFTimeInterval) { - suppressUntil = max(suppressUntil, timestamp + duration) - } - - func isSuppressed(at timestamp: CFAbsoluteTime) -> Bool { - timestamp < suppressUntil - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Permissions/PermissionsManager.swift b/XboxControllerMapper/XboxControllerMapper/Services/Permissions/PermissionsManager.swift index 78957df4..4993e56e 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Permissions/PermissionsManager.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Permissions/PermissionsManager.swift @@ -90,7 +90,6 @@ final class PermissionsManager: ObservableObject { var requestBluetoothAction: (@MainActor () -> Void)? private var pollTimer: Timer? - private var pollers = 0 private init() { refresh() @@ -131,26 +130,18 @@ final class PermissionsManager: ObservableObject { /// Begins ~1s polling of TCC state. Used while the onboarding wizard (or the /// revoked-permission banner) is on screen so cards flip to "Granted ✓" the /// instant the user toggles a switch in System Settings — no relaunch, no - /// guessing. **Reference-counted** — pair every startPolling() with exactly - /// one stopPolling(). With the shared manager, two overlapping views - /// (onboarding sheet + revoked-permission banner) share one timer; the first - /// to disappear must not kill it for the one still on screen. + /// guessing. Idempotent. func startPolling() { - pollers += 1 guard pollTimer == nil else { return } refresh() let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in - // Already fires on RunLoop.main; hop via Task to match the canonical - // block (MainActor.assumeIsolated requires macOS 14). - Task { @MainActor in self?.refresh() } + MainActor.assumeIsolated { self?.refresh() } } RunLoop.main.add(timer, forMode: .common) pollTimer = timer } func stopPolling() { - pollers = max(0, pollers - 1) - guard pollers == 0 else { return } pollTimer?.invalidate() pollTimer = nil } @@ -167,15 +158,11 @@ final class PermissionsManager: ObservableObject { openSettings("com.apple.preference.security?Privacy_Accessibility") } - /// Triggers the Input Monitoring system prompt (which should register the - /// app in the list on first run), then deep-links to the pane for repeat - /// visits. The pane open is delayed slightly so macOS can finish processing - /// the prompt/registration path before System Settings renders the list. + /// Triggers the Input Monitoring system prompt (and adds the app to the + /// list) the first time, then deep-links to the pane for repeat visits. func requestInputMonitoring() { _ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - self?.openSettings("com.apple.preference.security?Privacy_ListenEvent") - } + openSettings("com.apple.preference.security?Privacy_ListenEvent") } /// Starts the Bluetooth battery monitor, which constructs the diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileImportSafetyAuditor.swift b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileImportSafetyAuditor.swift index bc7a9812..7cf98920 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileImportSafetyAuditor.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileImportSafetyAuditor.swift @@ -84,7 +84,7 @@ enum ProfileImportSafetyAuditor { // Explicit cases (no `default`): adding a new SystemCommand // variant must surface here as a compile error so the auditor // doesn't silently drop a new execution surface. - case .switchProfile, .launchApp, .openLink, .obsWebSocket, .centerOuraRing, .toggleOuraMotion: + case .switchProfile, .launchApp, .openLink, .obsWebSocket: break } } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+MappingsLayers.swift b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+MappingsLayers.swift index 8f2debf9..660d418f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+MappingsLayers.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager+MappingsLayers.swift @@ -10,7 +10,6 @@ extension ProfileManager { targetProfile.buttonMappings[button] = mapping targetProfile.updateDPadPresetIfNeeded(afterChanging: button) - targetProfile.updateOuraMotionOutputModeIfNeeded(afterChanging: button) updateProfile(targetProfile) } @@ -19,7 +18,6 @@ extension ProfileManager { targetProfile.buttonMappings.removeValue(forKey: button) targetProfile.updateDPadPresetIfNeeded(afterChanging: button) - targetProfile.updateOuraMotionOutputModeIfNeeded(afterChanging: button) updateProfile(targetProfile) } @@ -49,26 +47,16 @@ extension ProfileManager { updateProfile(targetProfile) } - func setOuraMotionOutputMode(_ mode: OuraMotionOutputMode, in profile: Profile? = nil) { - guard var targetProfile = profile ?? activeProfile else { return } - - targetProfile.setOuraMotionOutputMode(mode) - updateProfile(targetProfile) - } - func setStickDirectionPreset(_ preset: StickDirectionPreset, side: JoystickSide, in profile: Profile? = nil) { guard var targetProfile = profile ?? activeProfile else { return } preset.apply(to: &targetProfile.buttonMappings, side: side) switch side { case .left: - targetProfile.joystickSettings.leftStick.mode = .custom + targetProfile.joystickSettings.leftStickMode = .custom case .right: - targetProfile.joystickSettings.rightStick.mode = .custom + targetProfile.joystickSettings.rightStickMode = .custom } - targetProfile.updateOuraMotionOutputModeIfNeeded( - afterChanging: ControllerButton.joystickDirectionButton(side: side, direction: .up) - ) updateProfile(targetProfile) } @@ -102,8 +90,6 @@ extension ProfileManager { targetProfile.updateDPadPresetIfNeeded(afterChanging: button1) targetProfile.updateDPadPresetIfNeeded(afterChanging: button2) - targetProfile.updateOuraMotionOutputModeIfNeeded(afterChanging: button1) - targetProfile.updateOuraMotionOutputModeIfNeeded(afterChanging: button2) updateProfile(targetProfile) } diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager.swift b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager.swift index 3c87d87c..74906f3c 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Profile/ProfileManager.swift @@ -400,83 +400,38 @@ class ProfileManager: ObservableObject { guard var targetProfile = profile ?? activeProfile else { return } targetProfile.joystickSettings = settings - targetProfile.reconcileOuraMotionOutputModeWithCurrentRouting() updateProfile(targetProfile) } /// Sets the stick mode for one side at the given scope. - /// - Parameter layerId: When nil, writes the profile-level default (i.e. `JoystickSettings.leftStick`/`rightStick` mode). - /// When non-nil, writes that layer's per-side override mode. Pass `mode = nil` together with a layer id to clear the - /// mode override so the layer falls back to the profile default for mode (other override fields are untouched). + /// - Parameter layerId: When nil, writes the profile-level default (i.e. `JoystickSettings.leftStickMode`/`rightStickMode`). + /// When non-nil, writes that layer's per-side override. Pass `mode = nil` together with a layer id to clear the override + /// so the layer falls back to the profile default. func setStickMode(_ mode: StickMode?, side: JoystickSide, layerId: UUID?, in profile: Profile? = nil) { guard var targetProfile = profile ?? activeProfile else { return } if let layerId { guard let layerIndex = targetProfile.layers.firstIndex(where: { $0.id == layerId }) else { return } - Self.mutateLayerStickOverride(&targetProfile.layers[layerIndex], side: side) { $0.mode = mode } + switch side { + case .left: + targetProfile.layers[layerIndex].leftStickModeOverride = mode + case .right: + targetProfile.layers[layerIndex].rightStickModeOverride = mode + } } else { // Profile-level write: a nil mode is meaningless here (the profile always has a concrete mode), // so callers must pass a real StickMode for layer-id-less writes. guard let mode else { return } switch side { case .left: - targetProfile.joystickSettings.leftStick.mode = mode + targetProfile.joystickSettings.leftStickMode = mode case .right: - targetProfile.joystickSettings.rightStick.mode = mode + targetProfile.joystickSettings.rightStickMode = mode } - targetProfile.reconcileOuraMotionOutputModeWithCurrentRouting() - } - updateProfile(targetProfile) - } - - /// Sets (or clears, with `value == nil`) a single field of a layer's per-side stick - /// tuning override. Clearing a field makes that field fall through to the base stick; - /// when the override becomes fully empty it is dropped so the layer cleanly inherits. - func setLayerStickOverride( - _ keyPath: WritableKeyPath, - _ value: T?, - side: JoystickSide, - layerId: UUID, - in profile: Profile? = nil - ) { - guard var targetProfile = profile ?? activeProfile, - let layerIndex = targetProfile.layers.firstIndex(where: { $0.id == layerId }) else { return } - Self.mutateLayerStickOverride(&targetProfile.layers[layerIndex], side: side) { $0[keyPath: keyPath] = value } - updateProfile(targetProfile) - } - - /// Clears a layer's entire per-side stick override so it fully inherits the base stick. - func clearLayerStickOverride(side: JoystickSide, layerId: UUID, in profile: Profile? = nil) { - guard var targetProfile = profile ?? activeProfile, - let layerIndex = targetProfile.layers.firstIndex(where: { $0.id == layerId }) else { return } - switch side { - case .left: - targetProfile.layers[layerIndex].leftStickTuning = nil - case .right: - targetProfile.layers[layerIndex].rightStickTuning = nil } updateProfile(targetProfile) } - /// Get-or-create the side's override, apply `mutate`, and drop it again if it - /// ends up empty (so "inherit everything" is represented as a nil override). - private static func mutateLayerStickOverride( - _ layer: inout Layer, - side: JoystickSide, - _ mutate: (inout StickTuningOverride) -> Void - ) { - switch side { - case .left: - var override = layer.leftStickTuning ?? StickTuningOverride() - mutate(&override) - layer.leftStickTuning = override.isEmpty ? nil : override - case .right: - var override = layer.rightStickTuning ?? StickTuningOverride() - mutate(&override) - layer.rightStickTuning = override.isEmpty ? nil : override - } - } - // MARK: - DualSense LED Settings func updateDualSenseLEDSettings(_ settings: DualSenseLEDSettings, in profile: Profile? = nil) { diff --git a/XboxControllerMapper/XboxControllerMapper/Services/Scripting/ScriptEngine.swift b/XboxControllerMapper/XboxControllerMapper/Services/Scripting/ScriptEngine.swift index 822fd9c6..67feea38 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/Scripting/ScriptEngine.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/Scripting/ScriptEngine.swift @@ -6,12 +6,7 @@ import AppKit /// Thread-safe atomic boolean for timeout tracking between the timer (global queue) and /// script execution (inputQueue). Replaces the previous UnsafeMutablePointer which /// had no synchronization and could leak on exceptions. -/// -/// nonisolated: the project's default MainActor isolation gives classes an -/// isolated deinit, and the back-deploy shim intermittently crashes (bad free -/// in malloc) when the instance deallocates off-main — which this one does, -/// on inputQueue/global. Same failure signature as OuraGestureEventClassifier. -nonisolated private final class AtomicBool { +private final class AtomicBool { private var _value: Bool private let lock = NSLock() diff --git a/XboxControllerMapper/XboxControllerMapper/Services/UI/OnScreenKeyboardManager.swift b/XboxControllerMapper/XboxControllerMapper/Services/UI/OnScreenKeyboardManager.swift index 841a1025..aa68fa56 100644 --- a/XboxControllerMapper/XboxControllerMapper/Services/UI/OnScreenKeyboardManager.swift +++ b/XboxControllerMapper/XboxControllerMapper/Services/UI/OnScreenKeyboardManager.swift @@ -15,7 +15,7 @@ enum KeyboardNavigationItem: Hashable { @MainActor class OnScreenKeyboardManager: ObservableObject { static let shared = OnScreenKeyboardManager() - private static let cursorHiddenDefaultsKey = Config.onScreenKeyboardCursorHiddenDefaultsKey + private static let cursorHiddenDefaultsKey = "onScreenKeyboardCursorHidden" @Published private(set) var isVisible = false @Published private(set) var typingBuffer: String = "" diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/ControllerTypeProviding.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/ControllerTypeProviding.swift index 6c101660..b170064a 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/ControllerTypeProviding.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/ControllerTypeProviding.swift @@ -1,19 +1,26 @@ import SwiftUI -/// Shared controller presentation snapshot for views that render button labels, -/// icons, or controller-specific sections. Conform any view that holds a -/// `ControllerService` (e.g. via `@EnvironmentObject`) and capture -/// `controllerPresentationState` once per render before deriving flags from it. +/// Shared controller-type flags for views that render button labels, icons, +/// or controller-specific sections. Conform any view that holds a +/// `ControllerService` (e.g. via `@EnvironmentObject`) to get these for free +/// instead of redeclaring them per view. /// -/// Use `presentationState.isPlayStation` for label/icon style decisions -/// (PS-style labels apply to DualSense, DualSense Edge, and DualShock alike). -/// Use hardware-specific flags only for hardware-specific features. +/// Use `isPlayStation` for label/icon style decisions (PS-style labels apply +/// to DualSense, DualSense Edge, and DualShock alike). Use `isDualSense` / +/// `isDualSenseEdge` only for hardware-specific features (touchpad, mic +/// button, paddles). protocol ControllerTypeProviding { var controllerService: ControllerService { get } } extension ControllerTypeProviding { - var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } + /// True for any PlayStation controller (DualSense or DualShock) - used for PS-style labels + var isPlayStation: Bool { controllerService.threadSafeIsPlayStation } + var isDualSense: Bool { controllerService.threadSafeIsDualSense } + var isDualSenseEdge: Bool { controllerService.threadSafeIsDualSenseEdge } + var isDualShock: Bool { controllerService.threadSafeIsDualShock } + var isXboxElite: Bool { controllerService.threadSafeIsXboxElite } + var isSteamController: Bool { controllerService.threadSafeIsSteamController } + var isNintendo: Bool { controllerService.threadSafeIsNintendo } + var isAppleTVRemote: Bool { controllerService.threadSafeIsAppleTVRemote } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/InputLogView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/InputLogView.swift index 6ff05be4..741984e2 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/InputLogView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/InputLogView.swift @@ -1,16 +1,14 @@ import SwiftUI struct InputLogView: View, ControllerTypeProviding { - @EnvironmentObject var inputLogService: InputLogService - @EnvironmentObject var controllerService: ControllerService + @EnvironmentObject var inputLogService: InputLogService + @EnvironmentObject var controllerService: ControllerService - var body: some View { - let presentationState = controllerPresentationState - - HStack(spacing: 10) { - HStack(spacing: 6) { - Image(systemName: "waveform.path") - .font(.system(size: 11, weight: .semibold)) + var body: some View { + HStack(spacing: 10) { + HStack(spacing: 6) { + Image(systemName: "waveform.path") + .font(.system(size: 11, weight: .semibold)) Text("Timeline") .font(.system(size: 11, weight: .semibold)) } @@ -27,18 +25,14 @@ struct InputLogView: View, ControllerTypeProviding { } .frame(maxWidth: .infinity, minHeight: 46) } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(inputLogService.entries) { entry in - LogEntryView( - entry: entry, - isLast: entry.id == inputLogService.entries.last?.id, - presentationState: presentationState - ) - } - } - .frame(minHeight: 46) - } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(inputLogService.entries) { entry in + LogEntryView(entry: entry, isLast: entry.id == inputLogService.entries.last?.id, isPlayStation: isPlayStation, isNintendo: isNintendo, isSteamController: isSteamController, isAppleTVRemote: isAppleTVRemote) + } + } + .frame(minHeight: 46) + } .frame(maxWidth: .infinity, minHeight: 46, alignment: .leading) } } @@ -56,32 +50,27 @@ struct InputLogView: View, ControllerTypeProviding { } private struct LogEntryView: View, Equatable { - let entry: InputLogEntry - let isLast: Bool - let presentationState: ControllerPresentationState + let entry: InputLogEntry + let isLast: Bool + let isPlayStation: Bool // True for DualSense/DualShock - used for PS-style labels + let isNintendo: Bool + let isSteamController: Bool + let isAppleTVRemote: Bool - static func == (lhs: LogEntryView, rhs: LogEntryView) -> Bool { - lhs.isLast == rhs.isLast - && lhs.entry == rhs.entry - && lhs.presentationState == rhs.presentationState - } + static func == (lhs: LogEntryView, rhs: LogEntryView) -> Bool { + lhs.isLast == rhs.isLast && lhs.entry == rhs.entry && lhs.isPlayStation == rhs.isPlayStation && lhs.isNintendo == rhs.isNintendo && lhs.isSteamController == rhs.isSteamController && lhs.isAppleTVRemote == rhs.isAppleTVRemote + } - var body: some View { - HStack(spacing: 0) { + var body: some View { + HStack(spacing: 0) { VStack(spacing: 6) { - // Top: Button(s) + Type - HStack(spacing: 4) { - ForEach(entry.buttons, id: \.self) { button in - ButtonIconView( - button: button, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote - ) - } + // Top: Button(s) + Type + HStack(spacing: 4) { + ForEach(entry.buttons, id: \.self) { button in + ButtonIconView(button: button, isDualSense: isPlayStation, isNintendo: isNintendo, isSteamController: isSteamController, isAppleTVRemote: isAppleTVRemote) + } - if entry.type != .singlePress { + if entry.type != .singlePress { Text(entry.type.rawValue) .font(.system(size: 10, weight: .bold)) .padding(.horizontal, 6) @@ -121,21 +110,13 @@ private struct LogEntryView: View, Equatable { .accessibilityLabel(accessibilityDescription) // Use simple opacity transition - complex spring animations block input handling .transition(.opacity) - } + } - private var accessibilityDescription: String { - let buttonNames = entry.buttons - .map { - $0.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo, - forAppleTVRemote: presentationState.isAppleTVRemote - ) - } - .joined(separator: " plus ") - let typeDescription = entry.type == .singlePress ? "" : ", \(entry.type.rawValue)" - return "\(buttonNames)\(typeDescription): \(entry.actionDescription)" - } + private var accessibilityDescription: String { + let buttonNames = entry.buttons.map { $0.displayName(forDualSense: isPlayStation, forNintendo: isNintendo, forAppleTVRemote: isAppleTVRemote) }.joined(separator: " plus ") + let typeDescription = entry.type == .singlePress ? "" : ", \(entry.type.rawValue)" + return "\(buttonNames)\(typeDescription): \(entry.actionDescription)" + } private func badgeColor(for type: InputEventType) -> Color { switch type { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Components/StreamOverlayView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Components/StreamOverlayView.swift index 8c77014e..aac7b921 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Components/StreamOverlayView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Components/StreamOverlayView.swift @@ -18,30 +18,26 @@ struct StreamOverlayView: View { private let graphicWidth: CGFloat = 200 private var isAppleTVRemote: Bool { - visualDescriptor.isAppleTVRemote + controllerService.threadSafeIsAppleTVRemote } - private var isOuraRing: Bool { - visualDescriptor.isOuraRing - } - /// Resolved from the connected controller so the overlay always matches /// the active hardware (previously this was hardcoded to Xbox vs /// PlayStation only). - private var visualDescriptor: ControllerVisualDescriptor { - ControllerVisualDescriptor.active(using: controllerService) - } - private var minimapStyle: ControllerMinimapStyle { - visualDescriptor.minimapStyle ?? .xbox + if controllerService.threadSafeIsSteamController { return .steam } + if controllerService.threadSafeIsDualShock { return .dualShock } + if controllerService.threadSafeIsDualSenseEdge { return .dualSenseEdge } + if controllerService.threadSafeIsPlayStation { return .dualSense } + if controllerService.threadSafeIsNintendo { return .nintendo } + if controllerService.threadSafeIsXboxElite { return .xboxElite } + return .xbox } var body: some View { VStack(spacing: 4) { Group { - if isOuraRing { - ouraRingGraphic - } else if isAppleTVRemote { + if isAppleTVRemote { appleTVRemoteGraphic } else { controllerGraphic @@ -118,7 +114,12 @@ struct StreamOverlayView: View { ControllerAnalogOverlay( controllerService: controllerService, - descriptor: visualDescriptor, + isPlayStation: controllerService.threadSafeIsPlayStation, + isNintendo: controllerService.threadSafeIsNintendo, + isXboxElite: controllerService.threadSafeIsXboxElite, + isSteamController: controllerService.threadSafeIsSteamController, + isDualShock: controllerService.threadSafeIsDualShock, + isDualSenseEdge: controllerService.threadSafeIsDualSenseEdge, onButtonTap: { _ in } ) .frame(width: size.width, height: size.height) @@ -142,16 +143,6 @@ struct StreamOverlayView: View { .frame(width: graphicWidth, height: height) } - private var ouraRingGraphic: some View { - let size = OuraRingMinimapView.previewSize - let scale = graphicWidth / size.width - - return OuraRingMinimapView(isTapPressed: controllerService.activeButtons.contains { $0.isOuraRingOnly }) - .frame(width: size.width, height: size.height) - .scaleEffect(scale) - .frame(width: graphicWidth, height: (size.height * scale).rounded()) - } - // MARK: - Display Logic /// Shows held actions when modifiers are held, otherwise shows last single action. diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift index 8e249337..211ecb1e 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Macros/MacroListView.swift @@ -155,8 +155,8 @@ struct SharedMacroRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) - .help("Edit \(macro.name.isEmpty ? "Unnamed Macro" : macro.name) in shared library") - .accessibilityLabel("Edit \(macro.name.isEmpty ? "Unnamed Macro" : macro.name) in shared library") + .help("Edit in shared library") + .accessibilityLabel("Edit in shared library") } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -206,16 +206,16 @@ struct MacroRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) - .help("Edit \(macro.name.isEmpty ? "Unnamed Macro" : macro.name)") - .accessibilityLabel("Edit \(macro.name.isEmpty ? "Unnamed Macro" : macro.name)") + .help("Edit") + .accessibilityLabel("Edit") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) - .help("Delete \(macro.name.isEmpty ? "Unnamed Macro" : macro.name)") - .accessibilityLabel("Delete \(macro.name.isEmpty ? "Unnamed Macro" : macro.name)") + .help("Delete") + .accessibilityLabel("Delete") } } .padding(.horizontal, 12) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActionMappingEditor.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActionMappingEditor.swift index 1298586b..13d34e4f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActionMappingEditor.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActionMappingEditor.swift @@ -265,8 +265,6 @@ struct ActionMappingEditor: View { appFields case .link: linkFields - case .ring: - OuraRingSystemCommandPicker(selection: $state.ouraRingCommandOption) case .webhook: if showFullSystemCategories { webhookFields @@ -512,25 +510,6 @@ struct ActionMappingEditor: View { } } -struct OuraRingSystemCommandPicker: View { - @Binding var selection: OuraRingSystemCommandOption - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Picker("Ring Command", selection: $selection) { - ForEach(OuraRingSystemCommandOption.allCases) { option in - Text(option.displayName).tag(option) - } - } - .pickerStyle(.segmented) - - Text(selection.helpText) - .font(.caption) - .foregroundColor(.secondary) - } - } -} - struct ProfileSelectionPicker: View { @EnvironmentObject var profileManager: ProfileManager @Binding var selection: UUID? diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveChordsView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveChordsView.swift index 5773d407..87c69d71 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveChordsView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveChordsView.swift @@ -6,13 +6,7 @@ struct ActiveChordsView: View { @EnvironmentObject var profileManager: ProfileManager @Binding var editingChord: ChordMapping? - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - if let profile = profileManager.activeProfile, !profile.chordMappings.isEmpty { Divider() .background(Color.white.opacity(0.1)) @@ -27,13 +21,7 @@ struct ActiveChordsView: View { HStack(spacing: 10) { HStack(spacing: 2) { ForEach(Array(chord.buttons).sorted(by: { $0.category.chordDisplayOrder < $1.category.chordDisplayOrder }), id: \.self) { button in - ButtonIconView( - button: button, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote - ) + ButtonIconView(button: button, isDualSense: controllerService.threadSafeIsPlayStation, isNintendo: controllerService.threadSafeIsNintendo, isSteamController: controllerService.threadSafeIsSteamController, isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveSequencesView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveSequencesView.swift index 6a521461..5a38b42f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveSequencesView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ActiveSequencesView.swift @@ -6,13 +6,7 @@ struct ActiveSequencesView: View { @EnvironmentObject var profileManager: ProfileManager @Binding var editingSequence: SequenceMapping? - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - if let profile = profileManager.activeProfile, !profile.sequenceMappings.isEmpty { Divider() .background(Color.white.opacity(0.1)) @@ -33,13 +27,7 @@ struct ActiveSequencesView: View { .foregroundColor(.white.opacity(0.2)) .accessibilityHidden(true) } - ButtonIconView( - button: button, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote - ) + ButtonIconView(button: button, isDualSense: controllerService.threadSafeIsPlayStation, isNintendo: controllerService.threadSafeIsNintendo, isSteamController: controllerService.threadSafeIsSteamController, isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingsTab.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingsTab.swift index 6016ce9f..21050cfc 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingsTab.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ButtonMappingsTab.swift @@ -493,33 +493,32 @@ struct ButtonMappingsTab: View { } if controllerService.isConnected { - let presentationState = controllerService.threadSafeControllerPresentationState - if presentationState.isAppleTVRemote { + if controllerService.threadSafeIsAppleTVRemote { layouts.insert(.appleTVRemote) } - if presentationState.isSteamController { + if controllerService.threadSafeIsSteamController { layouts.insert(.steam) } - if presentationState.isNintendo { + if controllerService.threadSafeIsNintendo { layouts.insert(.nintendo) } - if presentationState.isXboxElite { + if controllerService.threadSafeIsXboxElite { layouts.insert(.xboxElite) } else if controllerService.connectedController?.extendedGamepad is GCXboxGamepad { layouts.insert(.xbox) } - if presentationState.isDualSenseEdge { + if controllerService.threadSafeIsDualSenseEdge { layouts.insert(.dualSenseEdge) - } else if presentationState.isDualSense { + } else if controllerService.threadSafeIsDualSense { layouts.insert(.dualSense) } - if presentationState.isDualShock { + if controllerService.threadSafeIsDualShock { layouts.insert(.dualShock) } // Small 8BitDo pads connected in D-input mode (the generic HID // path identifies them by SDL product name). In Switch mode they // are byte-perfect Pro Controller clones and land on .nintendo. - switch presentationState.eightBitDoModel { + switch controllerService.threadSafeEightBitDoMinimapModel { case .zero2: layouts.insert(.eightBitDoZero2) case .micro: layouts.insert(.eightBitDoMicro) case .lite2: layouts.insert(.eightBitDoLite2) @@ -531,9 +530,6 @@ struct ButtonMappingsTab: View { if controllerService.appleTVRemoteHIDDevice != nil || controllerService.appleTVRemoteHIDTouchDevice != nil { layouts.insert(.appleTVRemote) } - if controllerService.isOuraRingConnected { - layouts.insert(.ouraRing) - } return layouts } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordMappingSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordMappingSheet.swift index b623579c..ede69367 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordMappingSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordMappingSheet.swift @@ -42,7 +42,6 @@ struct ChordMappingSheet: View, ControllerTypeProviding { @State private var shellCommandText: String = "" @State private var shellRunInTerminal: Bool = true @State private var linkURL: String = "" - @State private var ouraRingCommandOption: OuraRingSystemCommandOption = .centerRing @State private var webhookURL: String = "" @State private var webhookMethod: HTTPMethod = .POST @State private var webhookBody: String = "" @@ -137,10 +136,8 @@ struct ChordMappingSheet: View, ControllerTypeProviding { dismiss() } - var body: some View { - let presentationState = controllerPresentationState - - ScrollView { + var body: some View { + ScrollView { VStack(spacing: 20) { Text(isEditing ? "Edit Chord" : "Add Chord") .font(.headline) @@ -152,188 +149,188 @@ struct ChordMappingSheet: View, ControllerTypeProviding { // Visual Controller Layout VStack(spacing: 10) { // Top Row: Triggers & Bumpers - if !presentationState.isAppleTVRemote { - HStack(spacing: 120) { - VStack(spacing: 8) { - toggleButton(.leftTrigger, presentationState: presentationState) - toggleButton(.leftBumper, presentationState: presentationState) - } + if !isAppleTVRemote { + HStack(spacing: 120) { + VStack(spacing: 8) { + toggleButton(.leftTrigger) + toggleButton(.leftBumper) + } - VStack(spacing: 8) { - toggleButton(.rightTrigger, presentationState: presentationState) - toggleButton(.rightBumper, presentationState: presentationState) - } + VStack(spacing: 8) { + toggleButton(.rightTrigger) + toggleButton(.rightBumper) } } + } // Middle Row: D-Pad, System, Face Buttons, Sticks HStack(alignment: .top, spacing: 40) { // Left Column: D-Pad & L3 Stick VStack(spacing: 25) { // D-Pad Cross (aligned with face buttons) - VStack(spacing: 2) { - toggleButton(.dpadUp, presentationState: presentationState) - HStack(spacing: 25) { - toggleButton(.dpadLeft, presentationState: presentationState) - toggleButton(.dpadRight, presentationState: presentationState) - } - toggleButton(.dpadDown, presentationState: presentationState) + VStack(spacing: 2) { + toggleButton(.dpadUp) + HStack(spacing: 25) { + toggleButton(.dpadLeft) + toggleButton(.dpadRight) + } + toggleButton(.dpadDown) + } + + if !isAppleTVRemote { + toggleButton(.leftThumbstick) } - if !presentationState.isAppleTVRemote { - toggleButton(.leftThumbstick, presentationState: presentationState) + if !isAppleTVRemote && !leftJoystickDirectionButtons.isEmpty { + JoystickDirectionSelectionGrid(side: .left, mode: joystickSettings.leftStickMode) { button in + toggleButton(button) } + } + } - if !presentationState.isAppleTVRemote && !leftJoystickDirectionButtons.isEmpty { - JoystickDirectionSelectionGrid(side: .left, mode: joystickSettings.leftStick.mode) { button in - toggleButton(button, presentationState: presentationState) + // Center Column: System Buttons + VStack(spacing: 15) { + toggleButton(.xbox) + if isAppleTVRemote { + HStack(spacing: 25) { + toggleButton(.view) + toggleButton(.menu) + toggleButton(.siri) + } + VStack(spacing: 8) { + HStack(spacing: 14) { + toggleButton(.appleTVRemotePower) + toggleButton(.appleTVRemoteVolumeUp) + } + HStack(spacing: 14) { + toggleButton(.appleTVRemoteVolumeDown) + toggleButton(.appleTVRemoteMute) + } } + } else { + HStack(spacing: 25) { + toggleButton(.view) + toggleButton(.menu) + } + // Show mic mute for DualSense, share for Xbox only + // DualShock 4's physical Share button maps to .view (buttonOptions), not .share + if isDualSense { + toggleButton(.micMute) + } else if !isDualShock { + toggleButton(.share) + } + // Touchpad button for PlayStation controllers (DualSense/DualShock) + if isPlayStation { + toggleButton(.touchpadButton) + } } } + .padding(.top, 15) - // Center Column: System Buttons - VStack(spacing: 15) { - toggleButton(.xbox, presentationState: presentationState) - if presentationState.isAppleTVRemote { - HStack(spacing: 25) { - toggleButton(.view, presentationState: presentationState) - toggleButton(.menu, presentationState: presentationState) - toggleButton(.siri, presentationState: presentationState) - } - VStack(spacing: 8) { - HStack(spacing: 14) { - toggleButton(.appleTVRemotePower, presentationState: presentationState) - toggleButton(.appleTVRemoteVolumeUp, presentationState: presentationState) - } - HStack(spacing: 14) { - toggleButton(.appleTVRemoteVolumeDown, presentationState: presentationState) - toggleButton(.appleTVRemoteMute, presentationState: presentationState) - } - } - } else { + // Right Column: Face Buttons & Stick + VStack(spacing: 25) { + // Face Buttons Diamond + if isAppleTVRemote { + VStack(spacing: 12) { + toggleButton(.touchpadButton) + toggleButton(.touchpadTap) + } + } else { + VStack(spacing: 2) { + toggleButton(.y) HStack(spacing: 25) { - toggleButton(.view, presentationState: presentationState) - toggleButton(.menu, presentationState: presentationState) - } - // Show mic mute for DualSense, share for Xbox only - // DualShock 4's physical Share button maps to .view (buttonOptions), not .share - if presentationState.isDualSense { - toggleButton(.micMute, presentationState: presentationState) - } else if !presentationState.isDualShock { - toggleButton(.share, presentationState: presentationState) - } - // Touchpad button for PlayStation controllers (DualSense/DualShock) - if presentationState.isPlayStation { - toggleButton(.touchpadButton, presentationState: presentationState) + toggleButton(.x) + toggleButton(.b) } + toggleButton(.a) } } - .padding(.top, 15) - // Right Column: Face Buttons & Stick - VStack(spacing: 25) { - // Face Buttons Diamond - if presentationState.isAppleTVRemote { - VStack(spacing: 12) { - toggleButton(.touchpadButton, presentationState: presentationState) - toggleButton(.touchpadTap, presentationState: presentationState) - } - } else { - VStack(spacing: 2) { - toggleButton(.y, presentationState: presentationState) - HStack(spacing: 25) { - toggleButton(.x, presentationState: presentationState) - toggleButton(.b, presentationState: presentationState) - } - toggleButton(.a, presentationState: presentationState) - } - } + if !isAppleTVRemote { + toggleButton(.rightThumbstick) + } - if !presentationState.isAppleTVRemote { - toggleButton(.rightThumbstick, presentationState: presentationState) + if !isAppleTVRemote && !rightJoystickDirectionButtons.isEmpty { + JoystickDirectionSelectionGrid(side: .right, mode: joystickSettings.rightStickMode) { button in + toggleButton(button) } + } + } + } - if !presentationState.isAppleTVRemote && !rightJoystickDirectionButtons.isEmpty { - JoystickDirectionSelectionGrid(side: .right, mode: joystickSettings.rightStick.mode) { button in - toggleButton(button, presentationState: presentationState) - } - } - } - } - - // Edge Controls (function buttons and paddles) - if presentationState.isDualSenseEdge { - VStack(spacing: 8) { + // Edge Controls (function buttons and paddles) + if isDualSenseEdge { + VStack(spacing: 8) { Text("EDGE CONTROLS") .font(.system(size: 10, weight: .bold)) .foregroundColor(.secondary) - // Function buttons row (above touchpad area) - HStack(spacing: 40) { - toggleButton(.leftFunction, presentationState: presentationState) - Text("Fn") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - toggleButton(.rightFunction, presentationState: presentationState) - } + // Function buttons row (above touchpad area) + HStack(spacing: 40) { + toggleButton(.leftFunction) + Text("Fn") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + toggleButton(.rightFunction) + } - // Paddles row (back of controller) - HStack(spacing: 40) { - toggleButton(.leftPaddle, presentationState: presentationState) - Text("Paddles") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - toggleButton(.rightPaddle, presentationState: presentationState) - } - } + // Paddles row (back of controller) + HStack(spacing: 40) { + toggleButton(.leftPaddle) + Text("Paddles") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + toggleButton(.rightPaddle) + } + } .padding(.top, 20) } - // Xbox Elite Controls (back paddles) - if presentationState.isXboxElite { - VStack(spacing: 12) { - Text(presentationState.isSteamController ? "STEAM GRIP BUTTONS" : "ELITE PADDLES") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.secondary) - - HStack(spacing: 40) { - toggleButton(.xboxPaddle1, presentationState: presentationState) - Text("Upper") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - toggleButton(.xboxPaddle2, presentationState: presentationState) - } + // Xbox Elite Controls (back paddles) + if isXboxElite { + VStack(spacing: 12) { + Text(isSteamController ? "STEAM GRIP BUTTONS" : "ELITE PADDLES") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.secondary) - HStack(spacing: 40) { - toggleButton(.xboxPaddle3, presentationState: presentationState) - Text("Lower") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - toggleButton(.xboxPaddle4, presentationState: presentationState) - } - } + HStack(spacing: 40) { + toggleButton(.xboxPaddle1) + Text("Upper") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + toggleButton(.xboxPaddle2) + } + + HStack(spacing: 40) { + toggleButton(.xboxPaddle3) + Text("Lower") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + toggleButton(.xboxPaddle4) + } + } .padding(.top, 20) } - if presentationState.isSteamController { - VStack(spacing: 12) { + if isSteamController { + VStack(spacing: 12) { Text("STEAM TOUCHPADS") .font(.system(size: 10, weight: .bold)) .foregroundColor(.secondary) - HStack(spacing: 40) { - VStack(spacing: 8) { - toggleButton(.leftTouchpadButton, presentationState: presentationState) - toggleButton(.leftTouchpadTap, presentationState: presentationState) - } - Text("Pads") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - VStack(spacing: 8) { - toggleButton(.rightTouchpadButton, presentationState: presentationState) - toggleButton(.rightTouchpadTap, presentationState: presentationState) - } - } + HStack(spacing: 40) { + VStack(spacing: 8) { + toggleButton(.leftTouchpadButton) + toggleButton(.leftTouchpadTap) + } + Text("Pads") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + VStack(spacing: 8) { + toggleButton(.rightTouchpadButton) + toggleButton(.rightTouchpadTap) + } + } } .padding(.top, 20) } @@ -597,8 +594,6 @@ struct ChordMappingSheet: View, ControllerTypeProviding { .font(.caption) .foregroundColor(.secondary) } - case .ring: - OuraRingSystemCommandPicker(selection: $ouraRingCommandOption) case .webhook: VStack(alignment: .leading, spacing: 8) { TextField("URL (e.g. https://api.example.com/webhook)", text: $webhookURL) @@ -709,8 +704,6 @@ struct ChordMappingSheet: View, ControllerTypeProviding { case .link: guard !linkURL.isEmpty else { return nil } return .openLink(url: linkURL) - case .ring: - return ouraRingCommandOption.systemCommand case .webhook: guard !webhookURL.isEmpty else { return nil } let headers = webhookHeaders.isEmpty ? nil : webhookHeaders @@ -752,8 +745,6 @@ struct ChordMappingSheet: View, ControllerTypeProviding { shellRunInTerminal = inTerminal case .openLink(let url): linkURL = url - case .centerOuraRing, .toggleOuraMotion: - ouraRingCommandOption = OuraRingSystemCommandOption(command: command) case .httpRequest(let url, let method, let headers, let body, let responseHandling): webhookURL = url webhookMethod = method @@ -775,39 +766,32 @@ struct ChordMappingSheet: View, ControllerTypeProviding { } } - @ViewBuilder - private func toggleButton( - _ button: ControllerButton, - presentationState: ControllerPresentationState - ) -> some View { - let scale: CGFloat = 1.3 - let conflictingChord = buttonConflicts[button] - let isConflicted = conflictingChord != nil - let isSelected = selectedButtons.contains(button) - let buttonName = button.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo, - forAppleTVRemote: presentationState.isAppleTVRemote - ) - - VStack(spacing: 2) { - Button(action: { + @ViewBuilder + private func toggleButton(_ button: ControllerButton) -> some View { + let scale: CGFloat = 1.3 + let conflictingChord = buttonConflicts[button] + let isConflicted = conflictingChord != nil + let isSelected = selectedButtons.contains(button) + let buttonName = button.displayName(forDualSense: isPlayStation, forNintendo: isNintendo, forAppleTVRemote: isAppleTVRemote) + + VStack(spacing: 2) { + Button(action: { if isSelected { selectedButtons.remove(button) } else if !isConflicted { selectedButtons.insert(button) } - }) { - ButtonIconView( - button: button, - isPressed: isSelected, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote - ) - .scaleEffect(scale) - .frame(width: buttonWidth(for: button) * scale, height: buttonHeight(for: button) * scale) + }) { + ButtonIconView( + button: button, + isPressed: isSelected, + isDualSense: isPlayStation, + isNintendo: isNintendo, + isSteamController: isSteamController, + isAppleTVRemote: isAppleTVRemote + ) + .scaleEffect(scale) + .frame(width: buttonWidth(for: button) * scale, height: buttonHeight(for: button) * scale) .opacity(isConflicted ? 0.3 : (isSelected ? 1.0 : 0.7)) .overlay { if isSelected { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordSequenceListViews.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordSequenceListViews.swift index bcd944d8..169053d5 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordSequenceListViews.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordSequenceListViews.swift @@ -115,16 +115,16 @@ struct ChordRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) - .help("Edit \(chord.buttonsDisplayString)") - .accessibilityLabel("Edit \(chord.buttonsDisplayString)") + .help("Edit") + .accessibilityLabel("Edit") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) - .help("Delete \(chord.buttonsDisplayString)") - .accessibilityLabel("Delete \(chord.buttonsDisplayString)") + .help("Delete") + .accessibilityLabel("Delete") } } .padding(.horizontal, 12) @@ -261,16 +261,16 @@ struct SequenceRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) - .help("Edit \(sequence.stepsDisplayString)") - .accessibilityLabel("Edit \(sequence.stepsDisplayString)") + .help("Edit") + .accessibilityLabel("Edit") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) - .help("Delete \(sequence.stepsDisplayString)") - .accessibilityLabel("Delete \(sequence.stepsDisplayString)") + .help("Delete") + .accessibilityLabel("Delete") } } .padding(.horizontal, 12) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordsTab.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordsTab.swift index f613542c..3cc275ce 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordsTab.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ChordsTab.swift @@ -7,13 +7,7 @@ struct ChordsTab: View { @Binding var showingChordSheet: Bool @Binding var editingChord: ChordMapping? - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - Form { Section { Button(action: { showingChordSheet = true }) { @@ -25,11 +19,11 @@ struct ChordsTab: View { if let profile = profileManager.activeProfile, !profile.chordMappings.isEmpty { ChordListView( chords: profile.chordMappings, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - onEdit: { chord in + isDualSense: controllerService.threadSafeIsPlayStation, + isNintendo: controllerService.threadSafeIsNintendo, + isSteamController: controllerService.threadSafeIsSteamController, + isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote, + onEdit: { chord in editingChord = chord }, onDelete: { chord in diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandPalette.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandPalette.swift deleted file mode 100644 index 2c64bb09..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommandPalette.swift +++ /dev/null @@ -1,408 +0,0 @@ -import SwiftUI -import Combine - -// MARK: - Destination model - -/// A single jump-target in the ⌘K command palette. Plain value type so the -/// matching logic (`CommandPaletteFilter`) stays pure and unit-testable without -/// any SwiftUI / app-state dependencies. -struct CommandPaletteDestination: Identifiable, Equatable { - /// What selecting this row does. Resolved by the host (`ContentView`). - enum Target: Equatable { - /// Switch the main window to a section tab (`MainWindowSection.rawValue`). - case section(Int) - /// Jump to the Buttons tab and open the mapping editor for this button. - case button(ControllerButton) - /// Open the Settings sheet. - case settings - } - - let id: String - let title: String - /// Secondary line — e.g. the current binding ("⌘ C") or the nav group. - let subtitle: String? - /// Trailing chip text grouping the row ("Map", "Automate", "Button"…). - let groupLabel: String - let systemImage: String - /// Extra search terms not shown in the title (synonyms, raw button name, - /// the bound shortcut) so typing "copy" can find a button bound to ⌘C. - let keywords: [String] - let target: Target - - /// Face / d-pad / bumper / trigger / stick / menu buttons present on - /// essentially every controller — always offered even when unmapped so the - /// palette can take you straight to binding them. - static let coreButtons: [ControllerButton] = [ - .a, .b, .x, .y, - .leftBumper, .rightBumper, .leftTrigger, .rightTrigger, - .dpadUp, .dpadDown, .dpadLeft, .dpadRight, - .leftThumbstick, .rightThumbstick, - .menu, .view, - .ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, .ouraTapHold, - .ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight - ] -} - -struct CommandPaletteDestinationProvider { - static func destinations( - visibleTabs: [CustomTabItem], - mappings: [ControllerButton: KeyMapping], - descriptor: ControllerVisualDescriptor - ) -> [CommandPaletteDestination] { - var destinations: [CommandPaletteDestination] = [] - - for tab in visibleTabs { - guard let section = MainWindowSection(rawValue: tab.tag) else { continue } - destinations.append(CommandPaletteDestination( - id: "section-\(tab.tag)", - title: tab.label, - subtitle: section.navGroup.rawValue, - groupLabel: section.navGroup.rawValue, - systemImage: tab.systemImage, - keywords: section.searchKeywords, - target: .section(tab.tag) - )) - } - - var seenButtons = Set() - func addButton(_ button: ControllerButton) { - guard !seenButtons.contains(button) else { return } - seenButtons.insert(button) - let binding = mappings[button]?.displayString - destinations.append(CommandPaletteDestination( - id: "button-\(button.rawValue)", - title: button.displayName( - forDualSense: descriptor.isPlayStation, - forNintendo: descriptor.isNintendo, - forAppleTVRemote: descriptor.isAppleTVRemote, - forEightBitDo: descriptor.eightBitDoModel != nil - ), - subtitle: binding ?? "Not mapped", - groupLabel: "Button", - systemImage: "gamecontroller", - keywords: [button.rawValue, binding].compactMap { $0 }, - target: .button(button) - )) - } - - for button in ControllerButton.allCases where mappings[button] != nil { addButton(button) } - for button in CommandPaletteDestination.coreButtons { addButton(button) } - - destinations.append(CommandPaletteDestination( - id: "settings", - title: "Settings", - subtitle: "Preferences, license, permissions", - groupLabel: "App", - systemImage: "gearshape", - keywords: ["preferences", "license", "permissions", "options"], - target: .settings - )) - - return destinations - } -} - -// MARK: - Pure matching/ranking (unit-tested) - -/// Ranking for the palette. Pure and deterministic — no SwiftUI, no globals — so -/// it can be exercised directly in tests. Lower score = better match. -enum CommandPaletteFilter { - /// Filtered + ranked results for `query`. An empty/whitespace query returns - /// every destination in its original order (the curated default list). - static func filter(_ items: [CommandPaletteDestination], query: String) -> [CommandPaletteDestination] { - let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return items } - - return items.enumerated() - .compactMap { index, item -> (item: CommandPaletteDestination, score: Int, index: Int)? in - guard let score = score(for: item, query: q) else { return nil } - return (item, score, index) - } - // Rank by score, then keep the original order as a stable tiebreak. - .sorted { lhs, rhs in - lhs.score != rhs.score ? lhs.score < rhs.score : lhs.index < rhs.index - } - .map(\.item) - } - - /// Best (lowest) score across an item's searchable fields, or `nil` if none - /// match. `query` must already be lowercased/trimmed. - static func score(for item: CommandPaletteDestination, query: String) -> Int? { - // (text, fieldWeight) — title matches outrank keyword/subtitle matches. - var fields: [(String, Int)] = [(item.title.lowercased(), 0)] - fields.append(contentsOf: item.keywords.map { ($0.lowercased(), 4) }) - fields.append((item.groupLabel.lowercased(), 6)) - if let subtitle = item.subtitle { fields.append((subtitle.lowercased(), 6)) } - - var best: Int? - for (text, weight) in fields { - guard let match = matchScore(text, query) else { continue } - let total = match + weight - if best == nil || total < best! { best = total } - } - return best - } - - /// Score a single field against the query (both lowercased). Tiers: - /// exact (0) < prefix (1) < substring (3) < subsequence/fuzzy (8). - static func matchScore(_ text: String, _ query: String) -> Int? { - if text == query { return 0 } - if text.hasPrefix(query) { return 1 } - if text.contains(query) { return 3 } - if isSubsequence(query, of: text) { return 8 } - return nil - } - - /// True if `needle`'s characters appear in `haystack` in order (gaps OK). - static func isSubsequence(_ needle: String, of haystack: String) -> Bool { - guard !needle.isEmpty else { return true } - var iterator = haystack.makeIterator() - var current = iterator.next() - for target in needle { - while let c = current, c != target { current = iterator.next() } - guard current != nil else { return false } - current = iterator.next() - } - return true - } -} - -// MARK: - Palette view - -/// Spotlight-style jump bar. Presented as an isolated sheet from `ContentView`; -/// `onSelect` hands the chosen destination back to the host to perform the -/// actual navigation. -struct CommandPaletteView: View { - let onSelect: (CommandPaletteDestination) -> Void - - @Environment(\.dismiss) private var dismiss - @StateObject private var model: CommandPaletteViewModel - @FocusState private var searchFocused: Bool - - init( - destinations: [CommandPaletteDestination], - onSelect: @escaping (CommandPaletteDestination) -> Void - ) { - self.onSelect = onSelect - _model = StateObject(wrappedValue: CommandPaletteViewModel(destinations: destinations)) - } - - private var results: [CommandPaletteDestination] { model.results } - private var selectedIndex: Int { model.selectedIndex } - - var body: some View { - VStack(spacing: 0) { - searchField - Divider().opacity(0.5) - resultsList - footer - } - .frame(width: 540, height: 460) - .background(.regularMaterial) - .onAppear { - searchFocused = true - // A focused TextField eats the arrow keys before SwiftUI's - // `.onKeyPress` can see them, so drive ↑/↓/return/esc from a - // window-local key monitor instead (same pattern ContentView uses - // for Home/End/PageUp/PageDown). - model.startKeyMonitor( - onActivate: { onSelect($0) }, - onCancel: { dismiss() } - ) - } - .onDisappear { model.stopKeyMonitor() } - .onChange(of: model.query) { _, _ in model.resetSelection() } - } - - // MARK: Subviews - - private var searchField: some View { - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.secondary) - TextField("Jump to a section, button, or shortcut…", text: $model.query) - .textFieldStyle(.plain) - .font(.system(size: 16)) - .focused($searchFocused) - .onSubmit { activateSelection() } - Text("esc") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.tertiary) - .padding(.horizontal, 6).padding(.vertical, 3) - .background(RoundedRectangle(cornerRadius: 5).fill(.quaternary)) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - } - - private var resultsList: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 2) { - if results.isEmpty { - Text("No matches") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.top, 36) - } else { - ForEach(Array(results.enumerated()), id: \.element.id) { index, destination in - row(destination, index: index) - .id(index) - } - } - } - .padding(8) - } - .onChange(of: selectedIndex) { _, newValue in - withAnimation(.easeOut(duration: 0.12)) { proxy.scrollTo(newValue, anchor: .center) } - } - } - } - - private func row(_ destination: CommandPaletteDestination, index: Int) -> some View { - let isSelected = index == selectedIndex - return HStack(spacing: 12) { - Image(systemName: destination.systemImage) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(isSelected ? Color.white : .secondary) - .frame(width: 26, height: 26) - .background( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(isSelected ? Color.accentColor.opacity(0.85) : Color.white.opacity(0.06)) - ) - - VStack(alignment: .leading, spacing: 1) { - Text(destination.title) - .font(.system(size: 13.5, weight: .medium)) - .foregroundStyle(.primary) - if let subtitle = destination.subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 11.5)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer(minLength: 8) - - Text(destination.groupLabel) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 7).padding(.vertical, 2) - .background(Capsule().fill(Color.white.opacity(0.07))) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(isSelected ? Color.accentColor.opacity(0.16) : Color.clear) - ) - .contentShape(Rectangle()) - .onTapGesture { onSelect(destination); dismiss() } - .onHover { hovering in if hovering { model.selectedIndex = index } } - } - - private var footer: some View { - HStack(spacing: 14) { - hint("return", "Open") - hint("↑↓", "Navigate") - Spacer() - Text("\(results.count) result\(results.count == 1 ? "" : "s")") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 16) - .padding(.vertical, 9) - .background(Color.black.opacity(0.12)) - .overlay(alignment: .top) { Divider().opacity(0.5) } - } - - private func hint(_ key: String, _ label: String) -> some View { - HStack(spacing: 5) { - Text(key) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5).padding(.vertical, 2) - .background(RoundedRectangle(cornerRadius: 4).fill(.quaternary)) - Text(label) - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - } - } - - // MARK: Selection - - /// Fallback path for `onSubmit` when the key monitor isn't active. - private func activateSelection() { - if let destination = model.highlighted() { onSelect(destination) } - dismiss() - } -} - -// MARK: - Palette view model - -/// Owns the palette's mutable state (query + highlighted row) and a window-local -/// key monitor. Lives as a class so the monitor closure can mutate selection and -/// SwiftUI still observes the change — a captured `View` struct couldn't. -final class CommandPaletteViewModel: ObservableObject { - @Published var query = "" - @Published var selectedIndex = 0 - - private let destinations: [CommandPaletteDestination] - private var keyMonitor: Any? - - init(destinations: [CommandPaletteDestination]) { - self.destinations = destinations - } - - var results: [CommandPaletteDestination] { - CommandPaletteFilter.filter(destinations, query: query) - } - - func resetSelection() { selectedIndex = 0 } - - func move(_ delta: Int) { - let count = results.count - guard count > 0 else { return } - selectedIndex = ((selectedIndex + delta) % count + count) % count - } - - /// The currently highlighted destination, if the selection is in range. - func highlighted() -> CommandPaletteDestination? { - let current = results - return current.indices.contains(selectedIndex) ? current[selectedIndex] : nil - } - - /// Installs a local key monitor so ↑/↓ navigate, return opens, and esc - /// cancels — even while the search field holds focus. Fires on the main - /// thread (AppKit local monitors do), so mutating `@Published` here is safe. - func startKeyMonitor( - onActivate: @escaping (CommandPaletteDestination) -> Void, - onCancel: @escaping () -> Void - ) { - guard keyMonitor == nil else { return } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self else { return event } - switch event.keyCode { - case 125: self.move(1); return nil // down arrow - case 126: self.move(-1); return nil // up arrow - case 36, 76: // return / keypad enter - if let destination = self.highlighted() { onActivate(destination) } - onCancel() - return nil - case 53: // escape - onCancel() - return nil - default: - return event - } - } - } - - func stopKeyMonitor() { - if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } - keyMonitor = nil - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommunityProfilesSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommunityProfilesSheet.swift index 2f615268..d53337d5 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommunityProfilesSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/CommunityProfilesSheet.swift @@ -379,6 +379,8 @@ struct CommunityProfileRow: View { } .buttonStyle(.plain) .disabled(isAlreadyImported) + .help(isSelected ? "Deselect \(profileInfo.displayName)" : "Select \(profileInfo.displayName)") + .accessibilityLabel(isSelected ? "Deselect \(profileInfo.displayName)" : "Select \(profileInfo.displayName)") // Profile name (clickable for preview) Text(profileInfo.displayName) @@ -825,23 +827,9 @@ struct PreviewMappingRow: View { let mapping: KeyMapping let profile: Profile - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - HStack(spacing: 12) { - ButtonIconView( - button: button, - isPressed: false, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - isEightBitDo: presentationState.eightBitDoModel != nil - ) + ButtonIconView(button: button, isPressed: false, isDualSense: controllerService.threadSafeIsPlayStation, isNintendo: controllerService.threadSafeIsNintendo, isSteamController: controllerService.threadSafeIsSteamController) .frame(width: 28, height: 28) // Show mappings with hint + actual shortcut side by side @@ -943,26 +931,12 @@ struct PreviewChordRow: View { let chord: ChordMapping let profile: Profile - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - HStack(spacing: 12) { // Button icons - match main chord list spacing HStack(spacing: 4) { ForEach(Array(chord.buttons).sorted(by: { $0.category.chordDisplayOrder < $1.category.chordDisplayOrder }), id: \.self) { button in - ButtonIconView( - button: button, - isPressed: false, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - isEightBitDo: presentationState.eightBitDoModel != nil - ) + ButtonIconView(button: button, isPressed: false, isDualSense: controllerService.threadSafeIsPlayStation, isNintendo: controllerService.threadSafeIsNintendo, isSteamController: controllerService.threadSafeIsSteamController) } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentToolbar.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentToolbar.swift index 08407f4c..cdd21209 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentToolbar.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentToolbar.swift @@ -9,7 +9,6 @@ struct ContentToolbar: View { @ObservedObject private var license = LicenseManager.shared @Binding var showingSettingsSheet: Bool @Binding var profileSidebarVisible: Bool - @Binding var showingCommandPalette: Bool var body: some View { HStack { @@ -24,27 +23,18 @@ struct ContentToolbar: View { .foregroundColor(.secondary) } - Button { - showingCommandPalette = true - } label: { - HStack(spacing: 5) { - Image(systemName: "magnifyingglass") - .font(.system(size: 10, weight: .semibold)) - Text("⌘K") - .font(.caption.bold()) + if !controllerService.isConnected { + Button { + ControllerSupportDumpService.runInteractiveDump() + } label: { + Image(systemName: "doc.text.magnifyingglass") + .foregroundColor(.secondary) } - .foregroundStyle(Color.white.opacity(0.6)) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Capsule(style: .continuous).fill(Color.white.opacity(0.08))) - .overlay( - Capsule(style: .continuous) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) - ) + .buttonStyle(.plain) + .hoverableIconButton() + .help("Controller Support Dump") + .accessibilityLabel("Controller Support Dump") } - .buttonStyle(.plain) - .help("Command Palette — jump to anything (⌘K)") - .accessibilityLabel("Command Palette") // Connection status — sits on the right, just left of the toggle HStack(spacing: 8) { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift index d57373a2..aee29d4f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ContentView.swift @@ -16,7 +16,6 @@ struct ContentView: View { @State private var showingSequenceSheet = false @State private var editingSequence: SequenceMapping? @State private var showingSettingsSheet = false - @State private var showingCommandPalette = false @AppStorage("hasShownTrialWelcome") private var hasShownTrialWelcome = false @State private var showingWelcome = false @AppStorage("hasCompletedPermissionsOnboarding") private var hasCompletedOnboarding = false @@ -35,14 +34,6 @@ struct ContentView: View { profileManager.activeProfile?.controllerPreviewLayout ?? .active } - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - - private var controllerVisualDescriptor: ControllerVisualDescriptor { - ControllerVisualDescriptor.resolved(previewLayout: controllerPreviewLayout, using: controllerService) - } - private var controllerPreviewLayoutBinding: Binding { Binding( get: { profileManager.activeProfile?.controllerPreviewLayout ?? .active }, @@ -78,8 +69,7 @@ struct ContentView: View { // Toolbar ContentToolbar( showingSettingsSheet: $showingSettingsSheet, - profileSidebarVisible: $profileSidebarVisible, - showingCommandPalette: $showingCommandPalette + profileSidebarVisible: $profileSidebarVisible ) .zIndex(1) // Keep above content @@ -131,9 +121,6 @@ struct ContentView: View { case 14: InputSettingsView() .scrollContentBackground(.hidden) - case 15: - RingSettingsView() - .scrollContentBackground(.hidden) case 2: JoystickSettingsView() .scrollContentBackground(.hidden) @@ -196,10 +183,10 @@ struct ContentView: View { }, set: { _ in } // Read-only: ButtonMappingSheet saves directly via ProfileManager (see saveMapping()) ), - isDualSense: controllerVisualDescriptor.isPlayStation, - isNintendo: controllerVisualDescriptor.isNintendo, - isSteamController: controllerVisualDescriptor.isSteamController, - isAppleTVRemote: controllerVisualDescriptor.isAppleTVRemote, + isDualSense: controllerPreviewLayout.isPlayStation(using: controllerService), + isNintendo: controllerPreviewLayout.isNintendo(using: controllerService), + isSteamController: controllerPreviewLayout.isSteamController(using: controllerService), + isAppleTVRemote: controllerPreviewLayout.isAppleTVRemote(using: controllerService), selectedLayerId: selectedLayerId ) } @@ -224,11 +211,6 @@ struct ContentView: View { .isolatedSheet(isPresented: $showingSettingsSheet) { SettingsSheet() } - .isolatedSheet(isPresented: $showingCommandPalette) { - CommandPaletteView(destinations: paletteDestinations) { destination in - applyPaletteNavigation(destination) - } - } .isolatedSheet(isPresented: $showingWelcome) { TrialWelcomeSheet { hasShownTrialWelcome = true @@ -304,16 +286,6 @@ struct ContentView: View { .hidden() .accessibilityHidden(true) ) - // ⌘K opens the command palette — a Spotlight-style jump bar to any - // section, controller button, or bound shortcut. Hidden button so the - // shortcut fires globally while the main window is key (same pattern as - // the tab/zoom shortcuts above). - .background( - Button("Command Palette") { showingCommandPalette = true } - .keyboardShortcut("k", modifiers: .command) - .hidden() - .accessibilityHidden(true) - ) // Pinch zoom now lives on the Buttons-tab canvas itself // (ButtonMappingsTab), anchored at the gesture location. .onAppear { installScrollKeyMonitor() } @@ -360,9 +332,21 @@ struct ContentView: View { .onChange(of: hiddenSectionTags) { _, _ in selectFirstVisibleTabIfNeeded() } - .onChange(of: controllerPresentationState) { _, _ in - selectFirstVisibleTabIfNeeded() - } + .onChange(of: controllerService.threadSafeIsPlayStation) { _, _ in + selectFirstVisibleTabIfNeeded() + } + .onChange(of: controllerService.threadSafeIsDualSense) { _, _ in + selectFirstVisibleTabIfNeeded() + } + .onChange(of: controllerService.threadSafeIsSteamController) { _, _ in + selectFirstVisibleTabIfNeeded() + } + .onChange(of: controllerService.threadSafeIsAppleTVRemote) { _, _ in + selectFirstVisibleTabIfNeeded() + } + .onChange(of: controllerService.threadSafeHasMotion) { _, _ in + selectFirstVisibleTabIfNeeded() + } .onDisappear { if let monitor = scrollKeyMonitor { NSEvent.removeMonitor(monitor) @@ -408,20 +392,19 @@ struct ContentView: View { // MARK: - Tab Navigation - /// Tab definitions for the custom tab bar. - private var customTabs: [CustomTabItem] { - let hiddenSections = MainWindowSection.hiddenSections(from: hiddenSectionTags) - let presentationState = controllerPresentationState - return MainWindowSection.visibleSections( - hiddenSections: hiddenSections, - isPlayStation: presentationState.isPlayStation, - isDualSense: presentationState.isDualSense, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - hasMotion: presentationState.hasMotion - ) - .map(\.tabItem) - } + /// Tab definitions for the custom tab bar. + private var customTabs: [CustomTabItem] { + let hiddenSections = MainWindowSection.hiddenSections(from: hiddenSectionTags) + return MainWindowSection.visibleSections( + hiddenSections: hiddenSections, + isPlayStation: controllerService.threadSafeIsPlayStation, + isDualSense: controllerService.threadSafeIsDualSense, + isSteamController: controllerService.threadSafeIsSteamController, + isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote, + hasMotion: controllerService.threadSafeHasMotion + ) + .map(\.tabItem) + } /// Ordered list of visible tab tags matching the TabView order. private var orderedTabTags: [Int] { @@ -447,41 +430,6 @@ struct ContentView: View { selectedTab = firstVisibleTag } - // MARK: - Command Palette (⌘K) - - /// Jump-targets for the ⌘K palette: every visible section, every mapped - /// button (plus the always-present core buttons), and Settings. Built fresh - /// each time the palette opens so it reflects the current controller and the - /// active profile's bindings. - private var paletteDestinations: [CommandPaletteDestination] { - CommandPaletteDestinationProvider.destinations( - visibleTabs: customTabs, - mappings: profileManager.activeProfile?.buttonMappings ?? [:], - descriptor: controllerVisualDescriptor - ) - } - - /// Performs the navigation for a palette selection. Section switches happen - /// immediately; anything that opens another sheet is deferred briefly so the - /// palette sheet has finished dismissing first (avoids the sheet-contention - /// described on `isolatedSheet`). - private func applyPaletteNavigation(_ destination: CommandPaletteDestination) { - switch destination.target { - case .section(let tag): - withAnimation(.easeInOut(duration: 0.16)) { selectedTab = tag } - case .button(let button): - // Surface the button on the Buttons canvas, then open its editor. - selectedTab = MainWindowSection.buttons.rawValue - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - configuringButton = button - } - case .settings: - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - showingSettingsSheet = true - } - } - } - // MARK: - Scroll Key Navigation (Home/End/PageUp/PageDown) private func installScrollKeyMonitor() { @@ -783,10 +731,6 @@ extension UUID: @retroactive Identifiable { let profileManager = ProfileManager() let appMonitor = AppMonitor() let inputLogService = InputLogService() - let ouraRingInputService = OuraRingInputService( - controllerService: controllerService, - profileManager: profileManager - ) let mappingEngine = MappingEngine( controllerService: controllerService, profileManager: profileManager, @@ -800,7 +744,6 @@ extension UUID: @retroactive Identifiable { .environmentObject(appMonitor) .environmentObject(mappingEngine) .environmentObject(inputLogService) - .environmentObject(ouraRingInputService) } // MARK: - Main Window Sections @@ -821,7 +764,6 @@ enum MainWindowSection: Int, CaseIterable, Identifiable { case wheel = 12 case history = 13 case input = 14 - case ring = 15 static let hiddenDefaultsKey = "hiddenMainWindowSectionTags" @@ -834,7 +776,6 @@ enum MainWindowSection: Int, CaseIterable, Identifiable { .scripts, .wheel, .input, - .ring, .joysticks, .touchpad, .leds, @@ -863,7 +804,6 @@ enum MainWindowSection: Int, CaseIterable, Identifiable { case .wheel: return "Wheel" case .history: return "History" case .input: return "Input" - case .ring: return "Ring" } } @@ -882,7 +822,7 @@ enum MainWindowSection: Int, CaseIterable, Identifiable { return .map case .macros, .scripts, .wheel, .keyboard: return .automate - case .input, .ring, .joysticks, .touchpad, .leds, .microphone: + case .input, .joysticks, .touchpad, .leds, .microphone: return .hardware case .stats, .history: return .activity @@ -899,7 +839,6 @@ enum MainWindowSection: Int, CaseIterable, Identifiable { case .scripts: return "curlybraces" case .wheel: return "circle.grid.cross" case .input: return "speedometer" - case .ring: return "circle.dashed" case .keyboard: return "keyboard" case .joysticks: return "circle.circle" case .touchpad: return "rectangle.and.hand.point.up.left" @@ -910,29 +849,6 @@ enum MainWindowSection: Int, CaseIterable, Identifiable { } } - /// Extra search terms for the ⌘K palette so a section is findable by what it - /// does, not just its label (e.g. "javascript" → Scripts, "latency" → Input). - var searchKeywords: [String] { - switch self { - case .buttons: return ["mappings", "bindings", "keys", "remap"] - case .chords: return ["combo", "combination", "simultaneous"] - case .sequences: return ["combo", "order", "series", "fighting"] - case .gestures: return ["motion", "gyro", "tilt", "aim"] - case .macros: return ["automation", "record", "replay", "sequence"] - case .scripts: return ["javascript", "js", "code", "automation", "engine"] - case .wheel: return ["radial", "menu", "command", "pie"] - case .input: return ["latency", "realtime", "timing", "mode"] - case .ring: return ["oura", "motion", "tilt", "tap", "flick", "bluetooth", "pairing", "calibration", "recenter"] - case .joysticks: return ["sticks", "deadzone", "sensitivity", "curve", "analog"] - case .touchpad: return ["trackpad", "gyro", "regions", "quadrants", "pad"] - case .leds: return ["lightbar", "color", "lighting", "rgb"] - case .microphone: return ["mic", "mute", "audio"] - case .keyboard: return ["on-screen", "osk", "swipe", "typing"] - case .stats: return ["usage", "wrapped", "analytics", "metrics"] - case .history: return ["snapshots", "undo", "versions", "restore"] - } - } - var tabItem: CustomTabItem { CustomTabItem( tag: rawValue, diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerAnalogOverlay.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerAnalogOverlay.swift index 6c734c2e..450235fa 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerAnalogOverlay.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerAnalogOverlay.swift @@ -10,7 +10,15 @@ import Combine /// values change, preventing cascading redraws of the mapping reference rows. struct ControllerAnalogOverlay: View { let controllerService: ControllerService - let descriptor: ControllerVisualDescriptor + let isPlayStation: Bool + let isNintendo: Bool + let isXboxElite: Bool + let isSteamController: Bool + var isDualShock: Bool = false + var isDualSenseEdge: Bool = false + /// Non-nil when previewing one of the small 8BitDo pads; selects the + /// dedicated silhouette + layout instead of the boolean style families. + var eightBitDoModel: EightBitDoMinimapModel? = nil /// Buttons the Elite/Steam back paddles currently resolve to (paddles /// can be hardware-assigned to act as another button; connector lines /// must anchor whatever the reference rows resolve to). Order: @@ -48,16 +56,17 @@ struct ControllerAnalogOverlay: View { @State private var batteryLevel: Float = -1 @State private var batteryState: GCDeviceBattery.State = .unknown - private var isPlayStation: Bool { descriptor.isPlayStation } - private var isNintendo: Bool { descriptor.isNintendo } - private var isXboxElite: Bool { descriptor.isXboxElite } - private var isSteamController: Bool { descriptor.isSteamController } - private var isDualShock: Bool { descriptor.isDualShock } - private var isDualSenseEdge: Bool { descriptor.isDualSenseEdge } - private var eightBitDoModel: EightBitDoMinimapModel? { descriptor.eightBitDoModel } - /// Resolved visual style for layout lookups. - var minimapStyle: ControllerMinimapStyle { descriptor.minimapStyle ?? .xbox } + var minimapStyle: ControllerMinimapStyle { + if let eightBitDoModel { return eightBitDoModel.minimapStyle } + if isSteamController { return .steam } + if isDualShock { return .dualShock } + if isDualSenseEdge { return .dualSenseEdge } + if isPlayStation { return .dualSense } + if isNintendo { return .nintendo } + if isXboxElite { return .xboxElite } + return .xbox + } /// Preview frame the overlay is laid out in (matches the body silhouette). private var frameSize: CGSize { minimapStyle.previewSize } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerMinimapLayout.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerMinimapLayout.swift index 9a45d1e9..cf35d68f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerMinimapLayout.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerMinimapLayout.swift @@ -14,7 +14,7 @@ import SwiftUI /// resolved visual style. /// Which of the small 8BitDo pads a minimap renders. Carried as one optional /// alongside the older boolean style flags (`isPlayStation` etc.). -enum EightBitDoMinimapModel: String, CaseIterable, Sendable { +enum EightBitDoMinimapModel: String, CaseIterable { case zero2 case micro case lite2 @@ -34,7 +34,7 @@ enum EightBitDoMinimapModel: String, CaseIterable, Sendable { } } -enum ControllerMinimapStyle: CaseIterable, Hashable, Sendable { +enum ControllerMinimapStyle { case xbox case xboxElite case dualSense diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerPairingHintView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerPairingHintView.swift index 41fb5861..56402894 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerPairingHintView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerPairingHintView.swift @@ -20,7 +20,7 @@ struct ControllerPairingHintView: View { /// Every concrete family, in picker order, for the chooser grid. private static let selectableLayouts: [ControllerPreviewLayout] = - ControllerPreviewLayout.concreteLayouts + ControllerPreviewLayout.allCases.filter { $0 != .active } var body: some View { Group { @@ -54,7 +54,15 @@ struct ControllerPairingHintView: View { Spacer(minLength: 0) } - ChooserGrid(layouts: Self.selectableLayouts, onSelectLayout: onSelectLayout) + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 150), spacing: 8)], + alignment: .leading, + spacing: 8 + ) { + ForEach(Self.selectableLayouts) { layout in + ChooserChip(layout: layout) { onSelectLayout(layout) } + } + } } } @@ -231,43 +239,6 @@ struct ControllerPairingHintView: View { } } -// MARK: - Chooser grid - -/// Small, static chooser grid. Keep this eager: the card already lives inside -/// the Buttons tab ScrollView, and `LazyVGrid` can stale-clip rows on macOS -/// after the card scrolls partially offscreen and back. -private struct ChooserGrid: View { - let layouts: [ControllerPreviewLayout] - let onSelectLayout: (ControllerPreviewLayout) -> Void - - private let columns = 3 - private let spacing: CGFloat = 8 - - private var rows: [[ControllerPreviewLayout]] { - stride(from: 0, to: layouts.count, by: columns).map { start in - Array(layouts[start.. [ControllerButton] { - if isOuraRing { return [] } - switch side { - case .left: - return hasTriggers ? [.leftTrigger, .leftBumper] : [.leftBumper] - case .right: - return hasTriggers ? [.rightTrigger, .rightBumper] : [.rightBumper] - } - } - - var leftSystemButtons: [ControllerButton] { - if isOuraRing { return [] } - var buttons: [ControllerButton] = [.view] - if eightBitDoModel != .zero2 { - buttons.append(.xbox) - } - return buttons - } - - var rightSystemButtons: [ControllerButton] { - if isOuraRing { return [] } - var buttons: [ControllerButton] = [.menu] - if isDualSense { - buttons.append(.micMute) - } else if !isDualShock && (!isXboxElite || isSteamController) && eightBitDoModel == nil { - buttons.append(.share) - } - return buttons - } -} - -extension ControllerVisualDescriptor { - static func concrete(for layout: ControllerPreviewLayout) -> ControllerVisualDescriptor? { - switch layout { - case .active: - return nil - case .xbox: - return ControllerVisualDescriptor(family: .xbox) - case .xboxElite: - return ControllerVisualDescriptor(family: .xboxElite) - case .dualSense: - return ControllerVisualDescriptor(family: .dualSense) - case .dualSenseEdge: - return ControllerVisualDescriptor(family: .dualSenseEdge) - case .dualShock: - return ControllerVisualDescriptor(family: .dualShock) - case .nintendo: - return ControllerVisualDescriptor(family: .nintendo) - case .steam: - return ControllerVisualDescriptor(family: .steam) - case .eightBitDoZero2: - return ControllerVisualDescriptor(family: .eightBitDo(.zero2)) - case .eightBitDoMicro: - return ControllerVisualDescriptor(family: .eightBitDo(.micro)) - case .eightBitDoLite2: - return ControllerVisualDescriptor(family: .eightBitDo(.lite2)) - case .eightBitDoLiteSE: - return ControllerVisualDescriptor(family: .eightBitDo(.liteSE)) - case .appleTVRemote: - return ControllerVisualDescriptor(family: .appleTVRemote) - case .ouraRing: - return ControllerVisualDescriptor(family: .ouraRing) - } - } - - static func active(from state: ControllerPresentationState) -> ControllerVisualDescriptor { - if state.isAppleTVRemote { - return ControllerVisualDescriptor(family: .appleTVRemote) - } - if let model = state.eightBitDoModel { - return ControllerVisualDescriptor(family: .eightBitDo(model)) - } - switch state.controllerType { - case .xbox: - return ControllerVisualDescriptor(family: .xbox) - case .xboxElite: - return ControllerVisualDescriptor(family: .xboxElite) - case .dualSense: - return ControllerVisualDescriptor(family: .dualSense) - case .dualSenseEdge: - return ControllerVisualDescriptor(family: .dualSenseEdge) - case .dualShock: - return ControllerVisualDescriptor(family: .dualShock) - case .nintendo: - return ControllerVisualDescriptor(family: .nintendo) - case .steam: - return ControllerVisualDescriptor(family: .steam) - case .appleTVRemote: - return ControllerVisualDescriptor(family: .appleTVRemote) - } - } - - static func active(using service: ControllerService) -> ControllerVisualDescriptor { - if service.isOuraRingConnected { - return ControllerVisualDescriptor(family: .ouraRing) - } - return active(from: service.threadSafeControllerPresentationState) - } - - static func resolved( - previewLayout: ControllerPreviewLayout, - presentationState: ControllerPresentationState - ) -> ControllerVisualDescriptor { - concrete(for: previewLayout) ?? active(from: presentationState) - } - - static func resolved(previewLayout: ControllerPreviewLayout, using service: ControllerService) -> ControllerVisualDescriptor { - concrete(for: previewLayout) ?? active(using: service) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView+OuraRing.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView+OuraRing.swift deleted file mode 100644 index 7ac22084..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView+OuraRing.swift +++ /dev/null @@ -1,228 +0,0 @@ -import SwiftUI - -extension ControllerVisualView { - var ouraRingLayout: some View { - HStack(alignment: .center, spacing: 38) { - VStack(alignment: .trailing, spacing: 16) { - referenceGroup(title: "Tap", buttons: ControllerButton.ouraRingTapButtons) - } - .frame(width: 250) - - VStack(spacing: 12) { - OuraRingMinimapView( - isTapPressed: ControllerButton.ouraRingButtons.contains(where: isPressed), - isTapSelected: selectedButton.map { ControllerButton.ouraRingButtons.contains($0) } ?? false, - isSwapSource: swapFirstButton.map { ControllerButton.ouraRingButtons.contains($0) } ?? false, - onButtonTap: onButtonTap, - onButtonHover: handleButtonHover, - onSwapRequest: performSwap - ) - .overlay(alignment: .bottom) { - layerScopeChip(nameMaxWidth: 80) - .frame(maxWidth: OuraRingMinimapView.previewSize.width - 40) - .padding(.horizontal, 20) - .padding(.bottom, 18) - .allowsHitTesting(false) - } - .accessibilityHidden(true) - - if controllerService.isConnected { - BatteryView(level: controllerService.batteryLevel, state: controllerService.batteryState) - } - } - .frame(width: OuraRingMinimapView.previewSize.width) - - VStack(alignment: .leading, spacing: 16) { - ouraMotionOutputSection - referenceGroup(title: "Flick", buttons: ControllerButton.ouraRingFlickButtons) - } - .frame(width: 250) - } - .padding(28) - } - - private var ouraMotionOutputSection: some View { - let output = profileManager.activeProfile?.ouraMotionOutputMode ?? .off - - return VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 8) { - Text("Motion") - .textCase(.uppercase) - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.secondary) - - Spacer(minLength: 4) - ouraMotionOutputMenu(selectedOutput: output) - } - .padding(.horizontal, 4) - - Label(output.detailText, systemImage: output.systemImageName) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 4) - - if output.exposesTiltDirections { - ouraMotionDirectionCluster(for: output) - .padding(.top, 2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private func ouraMotionDirectionCluster(for output: OuraMotionOutputMode) -> some View { - switch output { - case .arrowKeys, .wasdKeys, .custom: - directionCluster( - title: "Tilt Directions", - up: .leftStickUp, - left: .leftStickLeft, - center: .label(""), - right: .leftStickRight, - down: .leftStickDown - ) - case .dpad: - directionCluster( - title: "Tilt Directions", - up: .dpadUp, - left: .dpadLeft, - center: .label(""), - right: .dpadRight, - down: .dpadDown - ) - case .mouse, .scroll, .off: - EmptyView() - } - } - - private func ouraMotionOutputMenu(selectedOutput: OuraMotionOutputMode) -> some View { - Menu { - ForEach(OuraMotionOutputMode.allCases) { output in - Button { - setOuraMotionOutputMode(output) - } label: { - if output == selectedOutput { - Label(output.displayName, systemImage: "checkmark") - } else { - Text(output.displayName) - } - } - } - } label: { - HStack(spacing: 4) { - Image(systemName: selectedOutput.systemImageName) - .font(.system(size: 8, weight: .bold)) - Text(selectedOutput.displayName) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .lineLimit(1) - .minimumScaleFactor(0.7) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 7, weight: .bold)) - .opacity(0.7) - } - .foregroundStyle(.secondary) - .padding(.horizontal, 7) - .frame(height: 20) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.primary.opacity(0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.primary.opacity(0.10), lineWidth: 1) - ) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Set what ring motion does") - } - - private func setOuraMotionOutputMode(_ output: OuraMotionOutputMode) { - profileManager.setOuraMotionOutputMode(output) - } -} - -struct OuraRingMinimapView: View { - static let previewSize = CGSize(width: 300, height: 300) - - let isTapPressed: Bool - var isTapSelected: Bool = false - var isSwapSource: Bool = false - var onButtonTap: (ControllerButton) -> Void = { _ in } - var onButtonHover: (ControllerButton, Bool) -> Void = { _, _ in } - var onSwapRequest: ((ControllerButton, ControllerButton) -> Void)? - - var body: some View { - ZStack { - ringBody - tapTarget - .offset(x: 82, y: 72) - } - .frame(width: Self.previewSize.width, height: Self.previewSize.height) - } - - private var ringBody: some View { - ZStack { - Circle() - .strokeBorder( - AngularGradient( - colors: [ - Color.white.opacity(0.72), - Color(red: 0.58, green: 0.66, blue: 0.72), - Color(red: 0.14, green: 0.16, blue: 0.18), - Color.white.opacity(0.58), - Color(red: 0.58, green: 0.66, blue: 0.72) - ], - center: .center - ), - lineWidth: 34 - ) - .frame(width: 210, height: 210) - .shadow(color: .black.opacity(0.36), radius: 18, x: 0, y: 14) - - Circle() - .strokeBorder(Color.white.opacity(0.32), lineWidth: 1.2) - .frame(width: 190, height: 190) - - Circle() - .strokeBorder(Color.black.opacity(0.42), lineWidth: 1.2) - .frame(width: 230, height: 230) - } - } - - private var tapTarget: some View { - let active = isTapPressed || isTapSelected - - return ZStack { - Circle() - .fill(active ? Color.accentColor : Color(white: 0.16)) - .shadow(color: active ? Color.accentColor.opacity(0.44) : .black.opacity(0.35), radius: active ? 12 : 6, x: 0, y: 4) - - Circle() - .strokeBorder(Color.white.opacity(active ? 0.72 : 0.22), lineWidth: 1.4) - - VStack(spacing: 2) { - Image(systemName: "hand.tap.fill") - .font(.system(size: 17, weight: .bold)) - Text("TAP") - .font(.system(size: 9, weight: .black, design: .rounded)) - } - .foregroundStyle(active ? Color.white : Color.white.opacity(0.78)) - } - .frame(width: 66, height: 66) - .scaleEffect(isTapPressed ? 0.92 : 1.0) - .overlay( - Circle() - .stroke(Color.orange, lineWidth: 3) - .opacity(isSwapSource ? 1 : 0) - ) - .contentShape(Circle()) - .controllerAnchor(.ouraTap, role: .controller) - .onTapGesture { onButtonTap(.ouraTap) } - .onHover { hovering in onButtonHover(.ouraTap, hovering) } - .swappable(.ouraTap, onSwap: onSwapRequest) - .animation(.spring(response: 0.22, dampingFraction: 0.62), value: isTapPressed) - .animation(.easeOut(duration: 0.12), value: isTapSelected) - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView.swift index 5a8d20f7..2d6a57db 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/ControllerVisualView.swift @@ -131,21 +131,27 @@ struct ControllerVisualView: View, ControllerTypeProviding { @State private var hoveredButton: ControllerButton? - var visualDescriptor: ControllerVisualDescriptor { - ControllerVisualDescriptor.resolved(previewLayout: previewLayout, using: controllerService) - } - var isPlayStation: Bool { visualDescriptor.isPlayStation } - var isDualSense: Bool { visualDescriptor.isDualSense } - var isDualSenseEdge: Bool { visualDescriptor.isDualSenseEdge } - var isDualShock: Bool { visualDescriptor.isDualShock } - var isXboxElite: Bool { visualDescriptor.isXboxElite } - var isSteamController: Bool { visualDescriptor.isSteamController } - var isNintendo: Bool { visualDescriptor.isNintendo } - var isAppleTVRemote: Bool { visualDescriptor.isAppleTVRemote } - var isOuraRing: Bool { visualDescriptor.isOuraRing } - var eightBitDoModel: EightBitDoMinimapModel? { visualDescriptor.eightBitDoModel } - var isStickless: Bool { visualDescriptor.isStickless } - var hasSticks: Bool { visualDescriptor.hasSticks } + var isPlayStation: Bool { previewLayout.isPlayStation(using: controllerService) } + var isDualSense: Bool { previewLayout.isDualSense(using: controllerService) } + var isDualSenseEdge: Bool { previewLayout.isDualSenseEdge(using: controllerService) } + var isDualShock: Bool { previewLayout.isDualShock(using: controllerService) } + var isXboxElite: Bool { previewLayout.isXboxElite(using: controllerService) } + var isSteamController: Bool { previewLayout.isSteamController(using: controllerService) } + var isNintendo: Bool { previewLayout.isNintendo(using: controllerService) } + var isAppleTVRemote: Bool { previewLayout.isAppleTVRemote(using: controllerService) } + var eightBitDoModel: EightBitDoMinimapModel? { previewLayout.eightBitDoModel(using: controllerService) } + + /// The small 8BitDo pads (Zero 2, Micro) are stickless: the physical d-pad + /// feeds the left-stick axis, so they expose no analog sticks at all. The + /// Lite 2 / Lite SE are full pads with real sticks. + var isStickless: Bool { eightBitDoModel?.isStickless == true } + + /// Whether the previewed controller exposes analog sticks in the sidebar. + var hasSticks: Bool { !isStickless } + + /// The Zero 2 has no triggers at all (only L/R bumpers). Every other + /// controller — including the Micro (L2/R2) — does. + var hasTriggers: Bool { eightBitDoModel != .zero2 } private var joystickSettings: JoystickSettings { profileManager.activeProfile?.joystickSettings ?? .default @@ -219,9 +225,7 @@ struct ControllerVisualView: View, ControllerTypeProviding { var body: some View { Group { - if isOuraRing { - ouraRingLayout - } else if isAppleTVRemote { + if isAppleTVRemote { appleTVRemoteLayout } else { standardControllerLayout @@ -245,7 +249,7 @@ struct ControllerVisualView: View, ControllerTypeProviding { HStack(alignment: .center, spacing: 0) { // Left Column: Shoulder and Left-side inputs VStack(alignment: .trailing, spacing: 16) { - referenceGroup(title: "Shoulder", buttons: visualDescriptor.shoulderButtons(side: .left)) + referenceGroup(title: "Shoulder", buttons: hasTriggers ? [.leftTrigger, .leftBumper] : [.leftBumper]) if isStickless { // Stickless pads: the d-pad IS the left-stick axis, so a // single section drives both the mode (mouse/scroll/d-pad/…) @@ -266,9 +270,9 @@ struct ControllerVisualView: View, ControllerTypeProviding { // layout and the 8-button quadrant layout. Two-finger buttons // stay visible in both modes since there's no quadrant // analog for two fingers. - if visualDescriptor.showsSteamTouchpads { + if isSteamController { steamTouchpadButtonsSection - } else if visualDescriptor.showsPlayStationTouchpad { + } else if isPlayStation { touchpadButtonsSection } @@ -286,7 +290,13 @@ struct ControllerVisualView: View, ControllerTypeProviding { // updates from the rest of the view hierarchy ControllerAnalogOverlay( controllerService: controllerService, - descriptor: visualDescriptor, + isPlayStation: isPlayStation, + isNintendo: isNintendo, + isXboxElite: isXboxElite, + isSteamController: isSteamController, + isDualShock: isDualShock, + isDualSenseEdge: isDualSenseEdge, + eightBitDoModel: eightBitDoModel, elitePaddleButtons: [ eliteReferenceButton(for: .xboxPaddle1), eliteReferenceButton(for: .xboxPaddle2), @@ -306,21 +316,31 @@ struct ControllerVisualView: View, ControllerTypeProviding { // System Buttons Reference HStack(spacing: 20) { VStack(alignment: .trailing) { - ForEach(visualDescriptor.leftSystemButtons) { button in - referenceRow(for: button) - } + referenceRow(for: .view) + // The Zero 2 has no home/guide button at all. + if eightBitDoModel != .zero2 { + referenceRow(for: .xbox) + } } .frame(width: 220) VStack(alignment: .leading) { - ForEach(visualDescriptor.rightSystemButtons) { button in - referenceRow(for: button) - } + referenceRow(for: .menu) + // Show mic mute for DualSense, share for Xbox (but not Elite 2 where + // the Share button is the hardware profile cycle button, not mappable) + // DualShock 4's physical Share button maps to .view (buttonOptions), not .share + // On 8BitDo pads the star is the firmware profile button (not mappable), + // so it never gets a reference row — it shows on the minimap only. + if isDualSense { + referenceRow(for: .micMute) + } else if !isDualShock && (!isXboxElite || isSteamController) && eightBitDoModel == nil { + referenceRow(for: .share) + } } .frame(width: 220) } // Edge-specific buttons (paddles and function buttons) - if visualDescriptor.showsDualSenseEdgeControls { + if isDualSenseEdge { VStack(alignment: .leading, spacing: 8) { Text("EDGE CONTROLS") .font(.system(size: 10, weight: .bold)) @@ -342,9 +362,9 @@ struct ControllerVisualView: View, ControllerTypeProviding { } // Xbox Elite-specific buttons (back paddles) - if visualDescriptor.showsGripOrPaddleSection { + if isXboxElite || isSteamController { VStack(alignment: .leading, spacing: 12) { - Text(visualDescriptor.gripOrPaddleSectionTitle) + Text(isSteamController ? "STEAM GRIP BUTTONS" : "ELITE PADDLES") .font(.system(size: 10, weight: .bold)) .foregroundColor(.secondary) .padding(.horizontal, 4) @@ -379,7 +399,7 @@ struct ControllerVisualView: View, ControllerTypeProviding { // Right Column: Face buttons and Right-side inputs VStack(alignment: .leading, spacing: 16) { - referenceGroup(title: "Shoulder", buttons: visualDescriptor.shoulderButtons(side: .right)) + referenceGroup(title: "Shoulder", buttons: hasTriggers ? [.rightTrigger, .rightBumper] : [.rightBumper]) referenceGroup(title: "Actions", buttons: [.y, .b, .a, .x]) if hasSticks { stickModeSection(title: "Right Stick", side: .right, center: .rightThumbstick) @@ -618,7 +638,14 @@ struct ControllerVisualView: View, ControllerTypeProviding { /// Resolved minimap style for the previewed controller. var minimapStyle: ControllerMinimapStyle { - visualDescriptor.minimapStyle ?? .xbox + if let eightBitDoModel { return eightBitDoModel.minimapStyle } + if isSteamController { return .steam } + if isDualShock { return .dualShock } + if isDualSenseEdge { return .dualSenseEdge } + if isPlayStation { return .dualSense } + if isNintendo { return .nintendo } + if isXboxElite { return .xboxElite } + return .xbox } private var controllerPreviewWidth: CGFloat { minimapStyle.previewSize.width } @@ -649,7 +676,7 @@ struct ControllerVisualView: View, ControllerTypeProviding { } @ViewBuilder - func stickModeSection(title: String, side: JoystickSide, center: ControllerButton) -> some View { + private func stickModeSection(title: String, side: JoystickSide, center: ControllerButton) -> some View { let mode = stickMode(side: side) let buttons = Set(side == .left ? leftStickDirectionButtons : rightStickDirectionButtons) @@ -853,17 +880,22 @@ struct ControllerVisualView: View, ControllerTypeProviding { } private func profileStickMode(side: JoystickSide) -> StickMode { - joystickSettings.stick(side).mode - } - - /// The active layer's tuning override for this side, if any (nil when editing base). - private func selectedLayerStickOverride(side: JoystickSide) -> StickTuningOverride? { - guard let layer = selectedLayer else { return nil } - return side == .left ? layer.leftStickTuning : layer.rightStickTuning + switch side { + case .left: + return joystickSettings.leftStickMode + case .right: + return joystickSettings.rightStickMode + } } private func layerStickModeOverride(side: JoystickSide) -> StickMode? { - selectedLayerStickOverride(side: side)?.mode + guard let layer = selectedLayer else { return nil } + switch side { + case .left: + return layer.leftStickModeOverride + case .right: + return layer.rightStickModeOverride + } } /// Effective mode resolved at the current editing scope: layer override (if any) → profile default. diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureListViews.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureListViews.swift index ed85a992..a77232ea 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureListViews.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureListViews.swift @@ -78,8 +78,8 @@ struct GestureRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) - .help("Edit \(gestureType.displayName)") - .accessibilityLabel("Edit \(gestureType.displayName)") + .help("Edit") + .accessibilityLabel("Edit") if mapping?.hasAction == true { Button(action: onClear) { @@ -87,8 +87,8 @@ struct GestureRow: View { .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) - .help("Clear \(gestureType.displayName)") - .accessibilityLabel("Clear \(gestureType.displayName)") + .help("Clear") + .accessibilityLabel("Clear") } } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureMappingSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureMappingSheet.swift index 0f875c61..3c5c5376 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureMappingSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/GestureMappingSheet.swift @@ -319,8 +319,6 @@ struct GestureMappingSheet: View { .font(.caption) .foregroundColor(.secondary) } - case .ring: - OuraRingSystemCommandPicker(selection: $editorState.ouraRingCommandOption) case .webhook: VStack(alignment: .leading, spacing: 8) { TextField("URL (e.g. https://api.example.com/webhook)", text: $editorState.webhookURL) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LEDSettingsView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LEDSettingsView.swift index 4b7535d4..a353a176 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LEDSettingsView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LEDSettingsView.swift @@ -11,12 +11,8 @@ struct LEDSettingsView: View { profileManager.activeProfile?.dualSenseLEDSettings ?? .default } - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - private var isDualShock: Bool { - controllerPresentationState.isDualShock + controllerService.threadSafeIsDualShock } var body: some View { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerSheets.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerSheets.swift index bf21bb77..2870a5e0 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerSheets.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerSheets.swift @@ -11,37 +11,30 @@ struct AddLayerSheet: View { @State private var selectedActivator: ControllerButton? = .leftBumper @State private var ledColor: Color = .blue - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - /// Buttons that are already used as layer activators private var usedActivators: Set { Set(profileManager.activeProfile?.layers.compactMap { $0.activatorButton } ?? []) } /// Available activator buttons (exclude already-used ones) - private var availableButtons: [ControllerButton] { - // Good candidates for layer activators: bumpers, triggers, share, view, menu - var candidates: [ControllerButton] = [ - .leftBumper, .rightBumper, .leftTrigger, .rightTrigger, - .share, .view, .menu, .xbox, - .leftThumbstick, .rightThumbstick - ] - let presentationState = controllerPresentationState - if presentationState.isAppleTVRemote { - candidates = [.view, .menu, .xbox, .siri] - } - // Add Edge-specific buttons when Edge controller is connected - if presentationState.isDualSenseEdge { - candidates.append(contentsOf: [.leftFunction, .rightFunction, .leftPaddle, .rightPaddle]) + private var availableButtons: [ControllerButton] { + // Good candidates for layer activators: bumpers, triggers, share, view, menu + var candidates: [ControllerButton] = [ + .leftBumper, .rightBumper, .leftTrigger, .rightTrigger, + .share, .view, .menu, .xbox, + .leftThumbstick, .rightThumbstick + ] + if controllerService.threadSafeIsAppleTVRemote { + candidates = [.view, .menu, .xbox, .siri] + } + // Add Edge-specific buttons when Edge controller is connected + if controllerService.threadSafeIsDualSenseEdge { + candidates.append(contentsOf: [.leftFunction, .rightFunction, .leftPaddle, .rightPaddle]) } return candidates.filter { !usedActivators.contains($0) } } var body: some View { - let presentationState = controllerPresentationState - VStack(spacing: 20) { Text("Add Layer") .font(.headline) @@ -64,21 +57,16 @@ struct AddLayerSheet: View { .foregroundColor(.secondary) Picker("Activator", selection: $selectedActivator) { - Text("None (assign later)").tag(nil as ControllerButton?) - ForEach(availableButtons, id: \.self) { button in - Text(button.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo, - forAppleTVRemote: presentationState.isAppleTVRemote, - forEightBitDo: presentationState.eightBitDoModel != nil - )) - .tag(button as ControllerButton?) - } + Text("None (assign later)").tag(nil as ControllerButton?) + ForEach(availableButtons, id: \.self) { button in + Text(button.displayName(forDualSense: controllerService.threadSafeIsPlayStation, forNintendo: controllerService.threadSafeIsNintendo, forAppleTVRemote: controllerService.threadSafeIsAppleTVRemote)) + .tag(button as ControllerButton?) + } } .pickerStyle(.menu) } - if presentationState.isPlayStation { + if controllerService.threadSafeIsPlayStation { VStack(alignment: .leading, spacing: 4) { Text("Light Bar Color") .font(.subheadline) @@ -147,10 +135,6 @@ struct EditLayerSheet: View { @State private var enableCustomLED: Bool = false @State private var ledColor: Color = .blue - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - /// Buttons that are already used as layer activators (excluding current layer) private var usedActivators: Set { Set(profileManager.activeProfile?.layers @@ -159,26 +143,23 @@ struct EditLayerSheet: View { } /// Available activator buttons (exclude already-used ones, but include current) - private var availableButtons: [ControllerButton] { - var candidates: [ControllerButton] = [ - .leftBumper, .rightBumper, .leftTrigger, .rightTrigger, - .share, .view, .menu, .xbox, - .leftThumbstick, .rightThumbstick - ] - let presentationState = controllerPresentationState - if presentationState.isAppleTVRemote { - candidates = [.view, .menu, .xbox, .siri] - } - // Add Edge-specific buttons when Edge controller is connected - if presentationState.isDualSenseEdge { - candidates.append(contentsOf: [.leftFunction, .rightFunction, .leftPaddle, .rightPaddle]) + private var availableButtons: [ControllerButton] { + var candidates: [ControllerButton] = [ + .leftBumper, .rightBumper, .leftTrigger, .rightTrigger, + .share, .view, .menu, .xbox, + .leftThumbstick, .rightThumbstick + ] + if controllerService.threadSafeIsAppleTVRemote { + candidates = [.view, .menu, .xbox, .siri] + } + // Add Edge-specific buttons when Edge controller is connected + if controllerService.threadSafeIsDualSenseEdge { + candidates.append(contentsOf: [.leftFunction, .rightFunction, .leftPaddle, .rightPaddle]) } return candidates.filter { !usedActivators.contains($0) } } var body: some View { - let presentationState = controllerPresentationState - VStack(spacing: 20) { Text("Edit Layer") .font(.headline) @@ -198,21 +179,16 @@ struct EditLayerSheet: View { .foregroundColor(.secondary) Picker("Activator", selection: $selectedActivator) { - Text("None (assign later)").tag(nil as ControllerButton?) - ForEach(availableButtons, id: \.self) { button in - Text(button.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo, - forAppleTVRemote: presentationState.isAppleTVRemote, - forEightBitDo: presentationState.eightBitDoModel != nil - )) - .tag(button as ControllerButton?) - } + Text("None (assign later)").tag(nil as ControllerButton?) + ForEach(availableButtons, id: \.self) { button in + Text(button.displayName(forDualSense: controllerService.threadSafeIsPlayStation, forNintendo: controllerService.threadSafeIsNintendo, forAppleTVRemote: controllerService.threadSafeIsAppleTVRemote)) + .tag(button as ControllerButton?) + } } .pickerStyle(.menu) } - if presentationState.isPlayStation { + if controllerService.threadSafeIsPlayStation { VStack(alignment: .leading, spacing: 4) { Toggle("Custom Light Bar Color", isOn: $enableCustomLED) .font(.subheadline) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerTabBar.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerTabBar.swift index 29df8c70..53304d4f 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerTabBar.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/LayerTabBar.swift @@ -48,10 +48,6 @@ struct LayerTabBar: View { /// True once measured and the bar is too narrow for full text labels. private var compact: Bool { barWidth > 1 && barWidth < compactThreshold } - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - /// Returns the layer's configured LED color, or a fallback purple if none is set. private func layerBadgeColor(_ layer: Layer) -> Color { if let led = layer.dualSenseLEDSettings, led.lightBarEnabled { @@ -61,8 +57,6 @@ struct LayerTabBar: View { } var body: some View { - let presentationState = controllerPresentationState - HStack(spacing: 8) { // Base Layer tab (always present) Button { @@ -110,11 +104,7 @@ struct LayerTabBar: View { } // Activator button badge (or "No Activator" if unassigned) if let activator = layer.activatorButton { - Text(activator.shortLabel( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo, - forAppleTVRemote: presentationState.isAppleTVRemote - )) + Text(activator.shortLabel(forDualSense: controllerService.threadSafeIsPlayStation, forNintendo: controllerService.threadSafeIsNintendo, forAppleTVRemote: controllerService.threadSafeIsAppleTVRemote)) .font(.system(size: 9, weight: .bold)) .foregroundColor(.white) .padding(.horizontal, 5) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift index f805317f..1016da03 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/MappingEditorState.swift @@ -25,7 +25,6 @@ struct MappingEditorState { var shellCommandText: String = "" var shellRunInTerminal: Bool = true var linkURL: String = "" - var ouraRingCommandOption: OuraRingSystemCommandOption = .centerRing // Webhook (primary-only, but kept here for uniformity) var webhookURL: String = "" @@ -122,8 +121,6 @@ struct MappingEditorState { case .link: guard !linkURL.isEmpty else { return nil } return .openLink(url: linkURL) - case .ring: - return ouraRingCommandOption.systemCommand case .webhook: guard !webhookURL.isEmpty else { return nil } let headers = webhookHeaders.isEmpty ? nil : webhookHeaders @@ -168,8 +165,6 @@ struct MappingEditorState { shellRunInTerminal = terminal case .openLink(let url): linkURL = url - case .centerOuraRing, .toggleOuraMotion: - ouraRingCommandOption = OuraRingSystemCommandOption(command: command) case .httpRequest(let url, let method, let headers, let body, let responseHandling): webhookURL = url webhookMethod = method diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/PairingMinimapView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/PairingMinimapView.swift index f9d8d3eb..881fac6e 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/PairingMinimapView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/PairingMinimapView.swift @@ -33,9 +33,7 @@ struct PairingMinimapView: View { var body: some View { Group { - if visualDescriptor.isOuraRing { - ouraRingMinimap - } else if visualDescriptor.isAppleTVRemote { + if layout.isAppleTVRemote(using: service) { appleTVRemoteMinimap } else { gamepadMinimap @@ -53,12 +51,17 @@ struct PairingMinimapView: View { // MARK: - Gamepad - private var visualDescriptor: ControllerVisualDescriptor { - ControllerVisualDescriptor.resolved(previewLayout: layout, using: service) - } - + /// Mirrors `ControllerAnalogOverlay.minimapStyle` so the body silhouette and + /// the overlay agree on which layout tables to use. private var minimapStyle: ControllerMinimapStyle { - visualDescriptor.minimapStyle ?? .xbox + if let model = layout.eightBitDoModel(using: service) { return model.minimapStyle } + if layout.isSteamController(using: service) { return .steam } + if layout.isDualShock(using: service) { return .dualShock } + if layout.isDualSenseEdge(using: service) { return .dualSenseEdge } + if layout.isPlayStation(using: service) { return .dualSense } + if layout.isNintendo(using: service) { return .nintendo } + if layout.isXboxElite(using: service) { return .xboxElite } + return .xbox } private var gamepadMinimap: some View { @@ -72,7 +75,13 @@ struct PairingMinimapView: View { ControllerAnalogOverlay( controllerService: service, - descriptor: visualDescriptor, + isPlayStation: layout.isPlayStation(using: service), + isNintendo: layout.isNintendo(using: service), + isXboxElite: layout.isXboxElite(using: service), + isSteamController: layout.isSteamController(using: service), + isDualShock: layout.isDualShock(using: service), + isDualSenseEdge: layout.isDualSenseEdge(using: service), + eightBitDoModel: layout.eightBitDoModel(using: service), onButtonTap: { _ in }, overrideColorForButton: { pressedButtons.contains($0) ? Color.accentColor : nil } ) @@ -94,14 +103,4 @@ struct PairingMinimapView: View { .scaleEffect(scale) .frame(width: (size.width * scale).rounded(), height: remoteTargetHeight) } - - private var ouraRingMinimap: some View { - let size = OuraRingMinimapView.previewSize - let scale = targetWidth / size.width - - return OuraRingMinimapView(isTapPressed: pressedButtons.contains { $0.isOuraRingOnly }) - .frame(width: size.width, height: size.height) - .scaleEffect(scale) - .frame(width: targetWidth, height: (size.height * scale).rounded()) - } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RecommendationsSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RecommendationsSheet.swift index 484ca7d8..b2151e3d 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RecommendationsSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RecommendationsSheet.swift @@ -54,10 +54,8 @@ struct RecommendationsSheet: View, ControllerTypeProviding { // MARK: - Content - private var content: some View { - let presentationState = controllerPresentationState - - return Group { + private var content: some View { + Group { if analysisResult.recommendations.isEmpty { VStack(spacing: 12) { Spacer() @@ -74,7 +72,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { ScrollView { LazyVStack(spacing: 8) { ForEach(analysisResult.recommendations) { rec in - recommendationRow(rec, presentationState: presentationState) + recommendationRow(rec) } } .padding(12) @@ -85,10 +83,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { // MARK: - Recommendation Row - private func recommendationRow( - _ rec: BindingRecommendation, - presentationState: ControllerPresentationState - ) -> some View { + private func recommendationRow(_ rec: BindingRecommendation) -> some View { let isSelected = selectedIds.contains(rec.id) return HStack(spacing: 10) { @@ -104,10 +99,10 @@ struct RecommendationsSheet: View, ControllerTypeProviding { // Recommendation content VStack(alignment: .leading, spacing: 6) { // Title line - titleView(for: rec, presentationState: presentationState) + titleView(for: rec) // Before → After with button icons - beforeAfterView(for: rec, presentationState: presentationState) + beforeAfterView(for: rec) } Spacer() @@ -130,44 +125,38 @@ struct RecommendationsSheet: View, ControllerTypeProviding { .onTapGesture { toggleSelection(rec.id) } } - @ViewBuilder - private func titleView( - for rec: BindingRecommendation, - presentationState: ControllerPresentationState - ) -> some View { + @ViewBuilder + private func titleView(for rec: BindingRecommendation) -> some View { switch rec.type { case .swap(let b1, let b2): - Text("Swap \(buttonName(b1, presentationState: presentationState)) and \(buttonName(b2, presentationState: presentationState)) — most-used action is on a harder button") + Text("Swap \(buttonName(b1)) and \(buttonName(b2)) — most-used action is on a harder button") .font(.system(size: 12, weight: .medium)) .foregroundColor(.primary) .lineLimit(2) case .promoteToChord(let fromButton, let fromType, let toChordButtons, _, _): let typeLabel = fromType == .longHold ? "long hold" : "double tap" - let chordLabel = chordButtonNames(toChordButtons, presentationState: presentationState) - Text("Promote \(buttonName(fromButton, presentationState: presentationState)) \(typeLabel) to \(chordLabel) chord — faster to execute") + let chordLabel = chordButtonNames(toChordButtons) + Text("Promote \(buttonName(fromButton)) \(typeLabel) to \(chordLabel) chord — faster to execute") .font(.system(size: 12, weight: .medium)) .foregroundColor(.primary) .lineLimit(2) case .demoteToLongHold(let button, _, _): - Text("Move \(buttonName(button, presentationState: presentationState)) to long hold — rarely used, frees the single-press slot") + Text("Move \(buttonName(button)) to long hold — rarely used, frees the single-press slot") .font(.system(size: 12, weight: .medium)) .foregroundColor(.primary) .lineLimit(2) } } - @ViewBuilder - private func beforeAfterView( - for rec: BindingRecommendation, - presentationState: ControllerPresentationState - ) -> some View { + @ViewBuilder + private func beforeAfterView(for rec: BindingRecommendation) -> some View { switch rec.type { case .swap(let b1, let b2): HStack(spacing: 6) { // Before - buttonIcon(b1, presentationState: presentationState) + buttonIcon(b1) Text(rec.actionDescription1) .font(.system(size: 10)) .foregroundColor(.secondary) @@ -175,7 +164,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { Text("/") .font(.system(size: 10)) .foregroundColor(.secondary.opacity(0.5)) - buttonIcon(b2, presentationState: presentationState) + buttonIcon(b2) Text(rec.actionDescription2) .font(.system(size: 10)) .foregroundColor(.secondary) @@ -186,7 +175,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { .foregroundColor(.secondary) // After (swapped) - buttonIcon(b1, presentationState: presentationState) + buttonIcon(b1) Text(rec.actionDescription2) .font(.system(size: 10)) .foregroundColor(.secondary) @@ -194,7 +183,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { Text("/") .font(.system(size: 10)) .foregroundColor(.secondary.opacity(0.5)) - buttonIcon(b2, presentationState: presentationState) + buttonIcon(b2) Text(rec.actionDescription1) .font(.system(size: 10)) .foregroundColor(.secondary) @@ -203,7 +192,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { case .promoteToChord(let fromButton, let fromType, let toChordButtons, _, _): HStack(spacing: 6) { - buttonIcon(fromButton, presentationState: presentationState) + buttonIcon(fromButton) let typeLabel = fromType == .longHold ? "hold" : "2x" Text("\(typeLabel): \(rec.actionDescription1)") .font(.system(size: 10)) @@ -214,7 +203,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { .font(.system(size: 8)) .foregroundColor(.secondary) - chordButtonIcons(toChordButtons, presentationState: presentationState) + chordButtonIcons(toChordButtons) Text(rec.actionDescription1) .font(.system(size: 10)) .foregroundColor(.secondary) @@ -223,7 +212,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { case .demoteToLongHold(let button, _, _): HStack(spacing: 6) { - buttonIcon(button, presentationState: presentationState) + buttonIcon(button) Text("press: \(rec.actionDescription1)") .font(.system(size: 10)) .foregroundColor(.secondary) @@ -233,7 +222,7 @@ struct RecommendationsSheet: View, ControllerTypeProviding { .font(.system(size: 8)) .foregroundColor(.secondary) - buttonIcon(button, presentationState: presentationState) + buttonIcon(button) Text("long hold: \(rec.actionDescription1)") .font(.system(size: 10)) .foregroundColor(.secondary) @@ -244,53 +233,28 @@ struct RecommendationsSheet: View, ControllerTypeProviding { // MARK: - Button Display Helpers - private func buttonName( - _ button: ControllerButton, - presentationState: ControllerPresentationState - ) -> String { - button.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo - ) - } - - private func chordButtonNames( - _ buttons: Set, - presentationState: ControllerPresentationState - ) -> String { + private func buttonName(_ button: ControllerButton) -> String { + button.displayName(forDualSense: isPlayStation, forNintendo: isNintendo) + } + + private func chordButtonNames(_ buttons: Set) -> String { buttons.sorted { $0.category.chordDisplayOrder < $1.category.chordDisplayOrder } - .map { - $0.shortLabel( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo - ) - } + .map { $0.shortLabel(forDualSense: isPlayStation, forNintendo: isNintendo) } .joined(separator: "+") } - private func buttonIcon( - _ button: ControllerButton, - presentationState: ControllerPresentationState - ) -> some View { - ButtonIconView( - button: button, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController - ) + private func buttonIcon(_ button: ControllerButton) -> some View { + ButtonIconView(button: button, isDualSense: isPlayStation, isNintendo: isNintendo, isSteamController: isSteamController) .scaleEffect(0.7) .frame(width: 22, height: 22) } - @ViewBuilder - private func chordButtonIcons( - _ buttons: Set, - presentationState: ControllerPresentationState - ) -> some View { + @ViewBuilder + private func chordButtonIcons(_ buttons: Set) -> some View { let sorted = buttons.sorted { $0.category.chordDisplayOrder < $1.category.chordDisplayOrder } HStack(spacing: 2) { ForEach(sorted, id: \.self) { button in - buttonIcon(button, presentationState: presentationState) + buttonIcon(button) } } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RingSettingsView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RingSettingsView.swift deleted file mode 100644 index 22fe00db..00000000 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/RingSettingsView.swift +++ /dev/null @@ -1,322 +0,0 @@ -import SwiftUI - -// MARK: - Ring Settings View - -struct RingSettingsView: View { - @EnvironmentObject var profileManager: ProfileManager - @EnvironmentObject var ouraRingInputService: OuraRingInputService - - private var settings: JoystickSettings { - profileManager.activeProfile?.joystickSettings ?? .default - } - - private var ringSettings: OuraMotionSettings { - settings.ouraMotion - } - - private var motionOutputMode: OuraMotionOutputMode { - profileManager.activeProfile?.ouraMotionOutputMode ?? settings.ouraMotionOutputMode - } - - var body: some View { - Form { - connectionSection - - if ringSettings.enabled { - routingSection - calibrationSection - advancedSection - } - } - .formStyle(.grouped) - .padding() - } - - private var connectionSection: some View { - Section("Connection") { - RingStatusHeader( - status: ouraRingInputService.status, - enabled: ringSettings.enabled, - motionOutputEnabled: ringSettings.motionOutputEnabled - ) - - Toggle("Enable Oura Ring", isOn: Binding( - get: { ringSettings.enabled }, - set: { enabled in - updateOuraMotion { $0.enabled = enabled } - } - )) - - RingActionButtons( - enabled: ringSettings.enabled, - motionOutputEnabled: ringSettings.motionOutputEnabled, - onScan: { ouraRingInputService.startIfEnabled() }, - onForgetPairing: { ouraRingInputService.forgetRingKeyAndReconnect() }, - onCenter: { ouraRingInputService.resetMotionCenter() }, - onToggleMotion: { ouraRingInputService.toggleMotionOutputEnabled() } - ) - } - } - - private var routingSection: some View { - Section("Motion Routing") { - Picker("Motion Output", selection: Binding( - get: { motionOutputMode }, - set: { output in - profileManager.setOuraMotionOutputMode(output) - } - )) { - ForEach(OuraMotionOutputMode.allCases) { output in - Label(output.displayName, systemImage: output.systemImageName) - .tag(output) - } - } - .pickerStyle(.menu) - Text(motionOutputMode.detailText) - .font(.caption) - .foregroundStyle(.secondary) - - Picker("Orientation", selection: Binding( - get: { ringSettings.orientation }, - set: { orientation in - updateOuraMotion { $0.orientation = orientation } - } - )) { - ForEach(OuraMotionOrientation.allCases) { orientation in - Text(orientation.displayName).tag(orientation) - } - } - .pickerStyle(.segmented) - - Toggle("Invert X Axis", isOn: Binding( - get: { ringSettings.invertX }, - set: { value in updateOuraMotion { $0.invertX = value } } - )) - - Toggle("Invert Y Axis", isOn: Binding( - get: { ringSettings.invertY }, - set: { value in updateOuraMotion { $0.invertY = value } } - )) - } - } - - private var calibrationSection: some View { - Section("Calibration") { - SliderRow( - label: "Sensitivity", - value: Binding( - get: { ringSettings.sensitivity }, - set: { value in updateOuraMotion { $0.sensitivity = value } } - ), - range: 0...1, - description: "Motion strength before it reaches the selected stick" - ) - - SliderRow( - label: "Horizontal Boost", - value: Binding( - get: { ringSettings.horizontalBoost }, - set: { value in updateOuraMotion { $0.horizontalBoost = value } } - ), - range: 1...4, - description: "Extra ring-twist strength for wide screens" - ) - - SliderRow( - label: "Left Boost", - value: Binding( - get: { ringSettings.leftTiltBoost }, - set: { value in updateOuraMotion { $0.leftTiltBoost = value } } - ), - range: 1...4, - description: "Extra strength for smaller left-hand travel" - ) - - SliderRow( - label: "Deadzone", - value: Binding( - get: { ringSettings.deadzone }, - set: { value in updateOuraMotion { $0.deadzone = value } } - ), - range: 0...0.5, - description: "Ignore small hand drift" - ) - - SliderRow( - label: "Smoothing", - value: Binding( - get: { ringSettings.smoothing }, - set: { value in updateOuraMotion { $0.smoothing = value } } - ), - range: 0...1, - description: "Higher values make motion steadier" - ) - } - } - - private var advancedSection: some View { - Section("Advanced") { - Toggle("Adopt Reset Ring", isOn: Binding( - get: { ringSettings.adoptResetRing }, - set: { value in updateOuraMotion { $0.adoptResetRing = value } } - )) - - Toggle("Diagnostic Logging", isOn: Binding( - get: { ringSettings.diagnosticsEnabled }, - set: { value in updateOuraMotion { $0.diagnosticsEnabled = value } } - )) - - if ringSettings.diagnosticsEnabled, !ouraRingInputService.lastDiagnosticLine.isEmpty { - Text(ouraRingInputService.lastDiagnosticLine) - .font(.caption2.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - .textSelection(.enabled) - } - } - } - - private func updateOuraMotion(_ mutate: (inout OuraMotionSettings) -> Void) { - updateSettings { settings in - mutate(&settings.ouraMotion) - } - } - - private func updateSettings(_ mutate: (inout JoystickSettings) -> Void) { - var newSettings = settings - mutate(&newSettings) - profileManager.updateJoystickSettings(newSettings) - } -} - -private struct RingStatusHeader: View { - let status: OuraRingConnectionStatus - let enabled: Bool - let motionOutputEnabled: Bool - - var body: some View { - HStack(alignment: .center, spacing: 12) { - Image(systemName: status.systemImage) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(status.tint) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 3) { - Text("Oura Ring") - .font(.headline) - - Text(enabled ? status.displayName : "Off for this profile") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - Spacer(minLength: 12) - - if enabled { - RingStatusPill( - text: motionOutputEnabled ? "Motion On" : "Motion Paused", - systemImage: motionOutputEnabled ? "cursorarrow.motionlines" : "pause.circle", - tint: motionOutputEnabled ? .green : .orange - ) - } - } - .padding(.vertical, 4) - } -} - -private struct RingActionButtons: View { - let enabled: Bool - let motionOutputEnabled: Bool - let onScan: () -> Void - let onForgetPairing: () -> Void - let onCenter: () -> Void - let onToggleMotion: () -> Void - - var body: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { - buttons - } - - VStack(alignment: .leading, spacing: 8) { - buttons - } - } - .controlSize(.small) - .disabled(!enabled) - } - - private var buttons: some View { - Group { - Button(action: onScan) { - Label("Scan", systemImage: "dot.radiowaves.left.and.right") - } - - Button(action: onCenter) { - Label("Center", systemImage: "scope") - } - - Button(action: onToggleMotion) { - Label( - motionOutputEnabled ? "Pause Motion" : "Resume Motion", - systemImage: motionOutputEnabled ? "pause.circle" : "play.circle" - ) - } - - Button(role: .destructive, action: onForgetPairing) { - Label("Forget Pairing", systemImage: "key.slash") - } - } - } -} - -private struct RingStatusPill: View { - let text: String - let systemImage: String - let tint: Color - - var body: some View { - Label(text, systemImage: systemImage) - .font(.caption.weight(.semibold)) - .foregroundStyle(tint) - .lineLimit(1) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(tint.opacity(0.12), in: Capsule()) - } -} - -private extension OuraRingConnectionStatus { - var systemImage: String { - switch self { - case .disabled: - return "power.circle" - case .bluetoothUnavailable, .authFailed: - return "exclamationmark.triangle.fill" - case .scanning: - return "dot.radiowaves.left.and.right" - case .connecting, .adopting, .authenticating: - return "arrow.triangle.2.circlepath" - case .connected, .authenticated: - return "checkmark.circle.fill" - case .disconnected: - return "circle" - } - } - - var tint: Color { - switch self { - case .disabled, .disconnected: - return .secondary - case .bluetoothUnavailable, .authFailed: - return .red - case .scanning: - return .blue - case .connecting, .adopting, .authenticating: - return .orange - case .connected, .authenticated: - return .green - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequenceMappingSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequenceMappingSheet.swift index ea10ffbf..08abe39e 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequenceMappingSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequenceMappingSheet.swift @@ -43,7 +43,6 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { @State private var shellCommandText: String = "" @State private var shellRunInTerminal: Bool = true @State private var linkURL: String = "" - @State private var ouraRingCommandOption: OuraRingSystemCommandOption = .centerRing @State private var webhookURL: String = "" @State private var webhookMethod: HTTPMethod = .POST @State private var webhookBody: String = "" @@ -131,10 +130,8 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { dismiss() } - var body: some View { - let presentationState = controllerPresentationState - - ScrollView { + var body: some View { + ScrollView { VStack(spacing: 20) { Text(isEditing ? "Edit Sequence" : "Add Sequence") .font(.headline) @@ -154,27 +151,27 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { .foregroundColor(.white.opacity(0.4)) .accessibilityHidden(true) } - HStack(spacing: 2) { - ButtonIconView( - button: button, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote - ) - .accessibilityLabel("Step \(index + 1): \(button.displayName(forDualSense: presentationState.isPlayStation, forNintendo: presentationState.isNintendo, forAppleTVRemote: presentationState.isAppleTVRemote))") - Button(action: { - steps.remove(at: index) - }) { + HStack(spacing: 2) { + ButtonIconView( + button: button, + isDualSense: isPlayStation, + isNintendo: isNintendo, + isSteamController: isSteamController, + isAppleTVRemote: isAppleTVRemote + ) + .accessibilityLabel("Step \(index + 1): \(button.displayName(forDualSense: isPlayStation, forNintendo: isNintendo, forAppleTVRemote: isAppleTVRemote))") + Button(action: { + steps.remove(at: index) + }) { Image(systemName: "xmark.circle.fill") .font(.system(size: 10)) .foregroundColor(.red.opacity(0.7)) } - .buttonStyle(.borderless) - .help("Remove step") - .accessibilityLabel("Remove \(button.displayName(forDualSense: presentationState.isPlayStation, forNintendo: presentationState.isNintendo, forAppleTVRemote: presentationState.isAppleTVRemote)) from step \(index + 1)") - } - } + .buttonStyle(.borderless) + .help("Remove step") + .accessibilityLabel("Remove \(button.displayName(forDualSense: isPlayStation, forNintendo: isNintendo, forAppleTVRemote: isAppleTVRemote)) from step \(index + 1)") + } + } Spacer() @@ -189,11 +186,11 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { } } .padding(8) - .frame(height: 40) - .background(Color.black.opacity(0.2)) - .cornerRadius(8) - .accessibilityLabel("Current sequence: \(steps.map { $0.displayName(forDualSense: presentationState.isPlayStation, forNintendo: presentationState.isNintendo, forAppleTVRemote: presentationState.isAppleTVRemote) }.joined(separator: ", then "))") - } + .frame(height: 40) + .background(Color.black.opacity(0.2)) + .cornerRadius(8) + .accessibilityLabel("Current sequence: \(steps.map { $0.displayName(forDualSense: isPlayStation, forNintendo: isNintendo, forAppleTVRemote: isAppleTVRemote) }.joined(separator: ", then "))") + } if sequenceAlreadyExists { Text("This sequence already exists") @@ -207,7 +204,7 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { .font(.caption) .foregroundColor(.secondary) - sequenceButtonGrid(presentationState: presentationState) + sequenceButtonGrid } else { Text("Maximum \(Config.maxSequenceSteps) steps reached") .font(.caption) @@ -418,20 +415,20 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { // MARK: - Button Grid for Adding Steps - @ViewBuilder - private func sequenceButtonGrid(presentationState: ControllerPresentationState) -> some View { - VStack(spacing: 10) { + @ViewBuilder + private var sequenceButtonGrid: some View { + VStack(spacing: 10) { // Top Row: Triggers & Bumpers - if !presentationState.isAppleTVRemote { + if !isAppleTVRemote { HStack(spacing: 120) { VStack(spacing: 8) { - addStepButton(.leftTrigger, presentationState: presentationState) - addStepButton(.leftBumper, presentationState: presentationState) + addStepButton(.leftTrigger) + addStepButton(.leftBumper) } VStack(spacing: 8) { - addStepButton(.rightTrigger, presentationState: presentationState) - addStepButton(.rightBumper, presentationState: presentationState) + addStepButton(.rightTrigger) + addStepButton(.rightBumper) } } } @@ -439,154 +436,154 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { // Middle Row: D-Pad, System, Face Buttons, Sticks HStack(alignment: .top, spacing: 40) { // Left Column: D-Pad & L3 - VStack(spacing: 25) { - VStack(spacing: 2) { - addStepButton(.dpadUp, presentationState: presentationState) - HStack(spacing: 25) { - addStepButton(.dpadLeft, presentationState: presentationState) - addStepButton(.dpadRight, presentationState: presentationState) - } - addStepButton(.dpadDown, presentationState: presentationState) + VStack(spacing: 25) { + VStack(spacing: 2) { + addStepButton(.dpadUp) + HStack(spacing: 25) { + addStepButton(.dpadLeft) + addStepButton(.dpadRight) + } + addStepButton(.dpadDown) + } + + if !isAppleTVRemote { + addStepButton(.leftThumbstick) } - if !presentationState.isAppleTVRemote { - addStepButton(.leftThumbstick, presentationState: presentationState) + if !isAppleTVRemote && !leftJoystickDirectionButtons.isEmpty { + JoystickDirectionSelectionGrid(side: .left, mode: joystickSettings.leftStickMode) { button in + addStepButton(button) } + } + } - if !presentationState.isAppleTVRemote && !leftJoystickDirectionButtons.isEmpty { - JoystickDirectionSelectionGrid(side: .left, mode: joystickSettings.leftStick.mode) { button in - addStepButton(button, presentationState: presentationState) - } + // Center Column: System Buttons + VStack(spacing: 15) { + addStepButton(.xbox) + if isAppleTVRemote { + HStack(spacing: 25) { + addStepButton(.view) + addStepButton(.menu) + addStepButton(.siri) + } + } else { + HStack(spacing: 25) { + addStepButton(.view) + addStepButton(.menu) + } + if isDualSense { + addStepButton(.micMute) + } else if !isDualShock { + addStepButton(.share) + } + if isPlayStation { + addStepButton(.touchpadButton) + } } } + .padding(.top, 15) - // Center Column: System Buttons - VStack(spacing: 15) { - addStepButton(.xbox, presentationState: presentationState) - if presentationState.isAppleTVRemote { - HStack(spacing: 25) { - addStepButton(.view, presentationState: presentationState) - addStepButton(.menu, presentationState: presentationState) - addStepButton(.siri, presentationState: presentationState) - } - } else { + // Right Column: Face Buttons & Stick + VStack(spacing: 25) { + if isAppleTVRemote { + VStack(spacing: 12) { + addStepButton(.touchpadButton) + addStepButton(.touchpadTap) + } + } else { + VStack(spacing: 2) { + addStepButton(.y) HStack(spacing: 25) { - addStepButton(.view, presentationState: presentationState) - addStepButton(.menu, presentationState: presentationState) - } - if presentationState.isDualSense { - addStepButton(.micMute, presentationState: presentationState) - } else if !presentationState.isDualShock { - addStepButton(.share, presentationState: presentationState) - } - if presentationState.isPlayStation { - addStepButton(.touchpadButton, presentationState: presentationState) + addStepButton(.x) + addStepButton(.b) } + addStepButton(.a) } } - .padding(.top, 15) - // Right Column: Face Buttons & Stick - VStack(spacing: 25) { - if presentationState.isAppleTVRemote { - VStack(spacing: 12) { - addStepButton(.touchpadButton, presentationState: presentationState) - addStepButton(.touchpadTap, presentationState: presentationState) - } - } else { - VStack(spacing: 2) { - addStepButton(.y, presentationState: presentationState) - HStack(spacing: 25) { - addStepButton(.x, presentationState: presentationState) - addStepButton(.b, presentationState: presentationState) - } - addStepButton(.a, presentationState: presentationState) - } - } + if !isAppleTVRemote { + addStepButton(.rightThumbstick) + } - if !presentationState.isAppleTVRemote { - addStepButton(.rightThumbstick, presentationState: presentationState) + if !isAppleTVRemote && !rightJoystickDirectionButtons.isEmpty { + JoystickDirectionSelectionGrid(side: .right, mode: joystickSettings.rightStickMode) { button in + addStepButton(button) } + } + } + } - if !presentationState.isAppleTVRemote && !rightJoystickDirectionButtons.isEmpty { - JoystickDirectionSelectionGrid(side: .right, mode: joystickSettings.rightStick.mode) { button in - addStepButton(button, presentationState: presentationState) - } - } - } - } - - // Edge Controls - if presentationState.isDualSenseEdge { - VStack(spacing: 8) { + // Edge Controls + if isDualSenseEdge { + VStack(spacing: 8) { Text("EDGE CONTROLS") .font(.system(size: 10, weight: .bold)) .foregroundColor(.secondary) - HStack(spacing: 40) { - addStepButton(.leftFunction, presentationState: presentationState) - Text("Fn") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - addStepButton(.rightFunction, presentationState: presentationState) - } + HStack(spacing: 40) { + addStepButton(.leftFunction) + Text("Fn") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + addStepButton(.rightFunction) + } - HStack(spacing: 40) { - addStepButton(.leftPaddle, presentationState: presentationState) - Text("Paddles") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - addStepButton(.rightPaddle, presentationState: presentationState) - } - } + HStack(spacing: 40) { + addStepButton(.leftPaddle) + Text("Paddles") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + addStepButton(.rightPaddle) + } + } .padding(.top, 20) } - // Xbox Elite Controls - if presentationState.isXboxElite { - VStack(spacing: 12) { - Text(presentationState.isSteamController ? "STEAM GRIP BUTTONS" : "ELITE PADDLES") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.secondary) - - HStack(spacing: 40) { - addStepButton(.xboxPaddle1, presentationState: presentationState) - Text("Upper") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - addStepButton(.xboxPaddle2, presentationState: presentationState) - } + // Xbox Elite Controls + if isXboxElite { + VStack(spacing: 12) { + Text(isSteamController ? "STEAM GRIP BUTTONS" : "ELITE PADDLES") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.secondary) - HStack(spacing: 40) { - addStepButton(.xboxPaddle3, presentationState: presentationState) - Text("Lower") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - addStepButton(.xboxPaddle4, presentationState: presentationState) - } - } + HStack(spacing: 40) { + addStepButton(.xboxPaddle1) + Text("Upper") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + addStepButton(.xboxPaddle2) + } + + HStack(spacing: 40) { + addStepButton(.xboxPaddle3) + Text("Lower") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + addStepButton(.xboxPaddle4) + } + } .padding(.top, 20) } - if presentationState.isSteamController { - VStack(spacing: 12) { + if isSteamController { + VStack(spacing: 12) { Text("STEAM TOUCHPADS") .font(.system(size: 10, weight: .bold)) .foregroundColor(.secondary) - HStack(spacing: 40) { - VStack(spacing: 8) { - addStepButton(.leftTouchpadButton, presentationState: presentationState) - addStepButton(.leftTouchpadTap, presentationState: presentationState) - } - Text("Pads") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - VStack(spacing: 8) { - addStepButton(.rightTouchpadButton, presentationState: presentationState) - addStepButton(.rightTouchpadTap, presentationState: presentationState) - } - } + HStack(spacing: 40) { + VStack(spacing: 8) { + addStepButton(.leftTouchpadButton) + addStepButton(.leftTouchpadTap) + } + Text("Pads") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + VStack(spacing: 8) { + addStepButton(.rightTouchpadButton) + addStepButton(.rightTouchpadTap) + } + } } .padding(.top, 20) } @@ -595,33 +592,26 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { .frame(maxWidth: .infinity) } - @ViewBuilder - private func addStepButton( - _ button: ControllerButton, - presentationState: ControllerPresentationState - ) -> some View { - let scale: CGFloat = 1.3 - let buttonName = button.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo, - forAppleTVRemote: presentationState.isAppleTVRemote - ) - - Button(action: { - if steps.count < Config.maxSequenceSteps { + @ViewBuilder + private func addStepButton(_ button: ControllerButton) -> some View { + let scale: CGFloat = 1.3 + let buttonName = button.displayName(forDualSense: isPlayStation, forNintendo: isNintendo, forAppleTVRemote: isAppleTVRemote) + + Button(action: { + if steps.count < Config.maxSequenceSteps { steps.append(button) } }) { - ButtonIconView( - button: button, - isPressed: false, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote - ) - .scaleEffect(scale) - } + ButtonIconView( + button: button, + isPressed: false, + isDualSense: isPlayStation, + isNintendo: isNintendo, + isSteamController: isSteamController, + isAppleTVRemote: isAppleTVRemote + ) + .scaleEffect(scale) + } .buttonStyle(.borderless) .accessibilityLabel("Add \(buttonName) step") .accessibilityHint("Adds \(buttonName) as the next step in the sequence") @@ -698,8 +688,6 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { .font(.caption) .foregroundColor(.secondary) } - case .ring: - OuraRingSystemCommandPicker(selection: $ouraRingCommandOption) case .webhook: VStack(alignment: .leading, spacing: 8) { TextField("URL (e.g. https://api.example.com/webhook)", text: $webhookURL) @@ -758,8 +746,6 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { case .link: guard !linkURL.isEmpty else { return nil } return .openLink(url: linkURL) - case .ring: - return ouraRingCommandOption.systemCommand case .webhook: guard !webhookURL.isEmpty else { return nil } let headers = webhookHeaders.isEmpty ? nil : webhookHeaders @@ -801,8 +787,6 @@ struct SequenceMappingSheet: View, ControllerTypeProviding { shellRunInTerminal = inTerminal case .openLink(let url): linkURL = url - case .centerOuraRing, .toggleOuraMotion: - ouraRingCommandOption = OuraRingSystemCommandOption(command: command) case .httpRequest(let url, let method, let headers, let body, let responseHandling): webhookURL = url webhookMethod = method diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequencesTab.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequencesTab.swift index 538d99e6..426e9907 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequencesTab.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SequencesTab.swift @@ -7,13 +7,7 @@ struct SequencesTab: View { @Binding var showingSequenceSheet: Bool @Binding var editingSequence: SequenceMapping? - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - Form { Section { Button(action: { showingSequenceSheet = true }) { @@ -25,11 +19,11 @@ struct SequencesTab: View { if let profile = profileManager.activeProfile, !profile.sequenceMappings.isEmpty { SequenceListView( sequences: profile.sequenceMappings, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - onEdit: { sequence in + isDualSense: controllerService.threadSafeIsPlayStation, + isNintendo: controllerService.threadSafeIsNintendo, + isSteamController: controllerService.threadSafeIsSteamController, + isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote, + onEdit: { sequence in editingSequence = sequence }, onDelete: { sequence in diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsSheet.swift index d94bcaf3..7a583033 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsSheet.swift @@ -56,12 +56,8 @@ struct SettingsSheet: View { @AppStorage(WindowBackgroundDefaults.opacityKey) private var windowBackgroundOpacity: Double = WindowBackgroundDefaults.defaultOpacity @AppStorage("telemetryEnabled") private var shareUsageData = true - @ObservedObject private var license = LicenseManager.shared - @ObservedObject private var updater = UpdaterManager.shared - - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } + @ObservedObject private var license = LicenseManager.shared + @ObservedObject private var updater = UpdaterManager.shared @ObservedObject private var permissions = PermissionsManager.shared @State private var selection: SettingsCategory? = .general @@ -469,16 +465,15 @@ struct SettingsSheet: View { } } - @ViewBuilder - private func mainSectionToggle(_ section: MainWindowSection) -> some View { - let presentationState = controllerPresentationState - let available = section.isAvailable( - isPlayStation: presentationState.isPlayStation, - isDualSense: presentationState.isDualSense, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - hasMotion: presentationState.hasMotion - ) + @ViewBuilder + private func mainSectionToggle(_ section: MainWindowSection) -> some View { + let available = section.isAvailable( + isPlayStation: controllerService.threadSafeIsPlayStation, + isDualSense: controllerService.threadSafeIsDualSense, + isSteamController: controllerService.threadSafeIsSteamController, + isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote, + hasMotion: controllerService.threadSafeHasMotion + ) Toggle(isOn: visibleSectionBinding(for: section)) { HStack(spacing: 8) { @@ -763,17 +758,16 @@ struct SettingsSheet: View { ) } - private func isLastVisibleSection(_ section: MainWindowSection) -> Bool { - let hiddenSections = MainWindowSection.hiddenSections(from: hiddenSectionTags) - let presentationState = controllerPresentationState - let visibleSections = MainWindowSection.visibleSections( - hiddenSections: hiddenSections, - isPlayStation: presentationState.isPlayStation, - isDualSense: presentationState.isDualSense, - isSteamController: presentationState.isSteamController, - isAppleTVRemote: presentationState.isAppleTVRemote, - hasMotion: presentationState.hasMotion - ) + private func isLastVisibleSection(_ section: MainWindowSection) -> Bool { + let hiddenSections = MainWindowSection.hiddenSections(from: hiddenSectionTags) + let visibleSections = MainWindowSection.visibleSections( + hiddenSections: hiddenSections, + isPlayStation: controllerService.threadSafeIsPlayStation, + isDualSense: controllerService.threadSafeIsDualSense, + isSteamController: controllerService.threadSafeIsSteamController, + isAppleTVRemote: controllerService.threadSafeIsAppleTVRemote, + hasMotion: controllerService.threadSafeHasMotion + ) return visibleSections.count == 1 && visibleSections.first == section } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsViews.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsViews.swift index b7ea7e8f..dcdf00ac 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsViews.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/SettingsViews.swift @@ -5,7 +5,6 @@ import SwiftUI struct JoystickSettingsView: View { @EnvironmentObject var profileManager: ProfileManager @State private var focusCursorHighlightEnabled: Bool = FocusModeIndicator.isEnabled - @State private var overrideLayerId: UUID? var settings: JoystickSettings { profileManager.activeProfile?.joystickSettings ?? .default @@ -15,8 +14,8 @@ struct JoystickSettingsView: View { Form { Section("Left Joystick") { Picker("Mode", selection: Binding( - get: { settings.leftStick.mode }, - set: { updateSettings(\.leftStick.mode, $0) } + get: { settings.leftStickMode }, + set: { updateSettings(\.leftStickMode, $0) } )) { ForEach(StickMode.visibleModes, id: \.self) { mode in Text(LocalizedStringKey(mode.displayName)).tag(mode) @@ -27,12 +26,12 @@ struct JoystickSettingsView: View { .font(.caption) .foregroundStyle(.secondary) - if settings.leftStick.mode == .mouse { + if settings.leftStickMode == .mouse { SliderRow( label: "Sensitivity", value: Binding( - get: { settings.leftStick.mouseSensitivity }, - set: { updateSettings(\.leftStick.mouseSensitivity, $0) } + get: { settings.mouseSensitivity }, + set: { updateSettings(\.mouseSensitivity, $0) } ), range: 0...1, description: "How fast the cursor moves" @@ -41,20 +40,20 @@ struct JoystickSettingsView: View { SliderRow( label: "Acceleration", value: Binding( - get: { settings.leftStick.mouseAcceleration }, - set: { updateSettings(\.leftStick.mouseAcceleration, $0) } + get: { settings.mouseAcceleration }, + set: { updateSettings(\.mouseAcceleration, $0) } ), range: 0...1, description: "0 = linear, 1 = max curve" ) } - if settings.leftStick.mode == .scroll { + if settings.leftStickMode == .scroll { SliderRow( label: "Sensitivity", value: Binding( - get: { settings.leftStick.scrollSensitivity }, - set: { updateSettings(\.leftStick.scrollSensitivity, $0) } + get: { settings.scrollSensitivity }, + set: { updateSettings(\.scrollSensitivity, $0) } ), range: 0...1, description: "How fast scrolling occurs" @@ -63,63 +62,48 @@ struct JoystickSettingsView: View { SliderRow( label: "Acceleration", value: Binding( - get: { settings.leftStick.scrollAcceleration }, - set: { updateSettings(\.leftStick.scrollAcceleration, $0) } + get: { settings.scrollAcceleration }, + set: { updateSettings(\.scrollAcceleration, $0) } ), range: 0...1, description: "0 = linear, 1 = max curve" ) } - if settings.leftStick.mode == .custom { + if settings.leftStickMode == .custom { JoystickCustomDirectionPanel( side: .left, horizontalSliceSize: Binding( - get: { settings.leftStick.customHorizontalSliceSize }, - set: { updateSettings(\.leftStick.customHorizontalSliceSize, $0) } + get: { settings.leftStickCustomHorizontalSliceSize }, + set: { updateSettings(\.leftStickCustomHorizontalSliceSize, $0) } ), verticalSliceSize: Binding( - get: { settings.leftStick.customVerticalSliceSize }, - set: { updateSettings(\.leftStick.customVerticalSliceSize, $0) } + get: { settings.leftStickCustomVerticalSliceSize }, + set: { updateSettings(\.leftStickCustomVerticalSliceSize, $0) } ), deadzone: Binding( - get: { settings.leftStick.customDeadzone }, - set: { updateSettings(\.leftStick.customDeadzone, $0) } + get: { settings.leftStickCustomDeadzone }, + set: { updateSettings(\.leftStickCustomDeadzone, $0) } ), invertY: Binding( - get: { settings.leftStick.invertMouseY }, - set: { updateSettings(\.leftStick.invertMouseY, $0) } + get: { settings.invertMouseY }, + set: { updateSettings(\.invertMouseY, $0) } ) ) } else { - // Deadzone/Invert follow the active mode so they edit the field the - // runtime actually reads: scroll mode uses the scroll fields, every - // other (movement) mode on the left stick uses the mouse fields. SliderRow( label: "Deadzone", value: Binding( - get: { settings.leftStick.mode == .scroll ? settings.leftStick.scrollDeadzone : settings.leftStick.mouseDeadzone }, - set: { - if settings.leftStick.mode == .scroll { - updateSettings(\.leftStick.scrollDeadzone, $0) - } else { - updateSettings(\.leftStick.mouseDeadzone, $0) - } - } + get: { settings.mouseDeadzone }, + set: { updateSettings(\.mouseDeadzone, $0) } ), range: 0...0.5, description: "Ignore small movements" ) Toggle("Invert Y Axis", isOn: Binding( - get: { settings.leftStick.mode == .scroll ? settings.leftStick.invertScrollY : settings.leftStick.invertMouseY }, - set: { - if settings.leftStick.mode == .scroll { - updateSettings(\.leftStick.invertScrollY, $0) - } else { - updateSettings(\.leftStick.invertMouseY, $0) - } - } + get: { settings.invertMouseY }, + set: { updateSettings(\.invertMouseY, $0) } )) } } @@ -184,82 +168,16 @@ struct JoystickSettingsView: View { } } - Picker("Analog Trigger", selection: Binding( - get: { settings.analogPrecisionTriggerMode }, - set: { updateSettings(\.analogPrecisionTriggerMode, $0) } - )) { - ForEach(AnalogPrecisionTriggerMode.allCases, id: \.self) { mode in - Text(LocalizedStringKey(mode.displayName)).tag(mode) - } - } - .pickerStyle(.segmented) - .help("R2 / RT avoids the left-trigger swipe-typing overlap.") - - if let analogPrecisionWarning { - Label(analogPrecisionWarning, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - .help(analogPrecisionWarning) - } - - if settings.analogPrecisionTriggerMode != .off { - SliderRow( - label: "Minimum Speed", - value: Binding( - get: { settings.analogPrecisionMinimumSpeed }, - set: { updateSettings(\.analogPrecisionMinimumSpeed, $0) } - ), - range: 0.05...1.0, - description: "Cursor speed at full trigger pull" - ) - - SliderRow( - label: "Trigger Deadzone", - value: Binding( - get: { settings.analogPrecisionDeadzone }, - set: { updateSettings(\.analogPrecisionDeadzone, $0) } - ), - range: 0...0.5, - description: "How far the trigger moves before slowing starts" - ) - - SliderRow( - label: "Trigger Curve", - value: Binding( - get: { settings.analogPrecisionCurve }, - set: { updateSettings(\.analogPrecisionCurve, $0) } - ), - range: 0...1, - description: "0 = linear, 1 = more slowdown near full pull" - ) - } - Toggle("Highlight Focused Cursor", isOn: $focusCursorHighlightEnabled) .onChange(of: focusCursorHighlightEnabled) { _, newValue in FocusModeIndicator.isEnabled = newValue } } - Section("FPS / Pointer-Lock Games") { - Picker("Relative Mouse Aiming", selection: Binding( - get: { settings.pointerLockMouseMode }, - set: { updateSettings(\.pointerLockMouseMode, $0) } - )) { - ForEach(PointerLockMouseMode.allCases, id: \.self) { mode in - Text(LocalizedStringKey(mode.displayName)).tag(mode) - } - } - .pickerStyle(.segmented) - - Text("Games that capture the mouse (browser FPS, pointer lock) need relative input to aim 360°. Auto switches while the game hides the cursor, so aiming never stops at screen edges. Always is for per-app game profiles; the cursor won't move outside a game.") - .font(.caption) - .foregroundStyle(.secondary) - } - Section("Right Joystick") { Picker("Mode", selection: Binding( - get: { settings.rightStick.mode }, - set: { updateSettings(\.rightStick.mode, $0) } + get: { settings.rightStickMode }, + set: { updateSettings(\.rightStickMode, $0) } )) { ForEach(StickMode.visibleModes, id: \.self) { mode in Text(LocalizedStringKey(mode.displayName)).tag(mode) @@ -270,12 +188,12 @@ struct JoystickSettingsView: View { .font(.caption) .foregroundStyle(.secondary) - if settings.rightStick.mode == .mouse { + if settings.rightStickMode == .mouse { SliderRow( label: "Sensitivity", value: Binding( - get: { settings.rightStick.mouseSensitivity }, - set: { updateSettings(\.rightStick.mouseSensitivity, $0) } + get: { settings.mouseSensitivity }, + set: { updateSettings(\.mouseSensitivity, $0) } ), range: 0...1, description: "How fast the cursor moves" @@ -284,20 +202,20 @@ struct JoystickSettingsView: View { SliderRow( label: "Acceleration", value: Binding( - get: { settings.rightStick.mouseAcceleration }, - set: { updateSettings(\.rightStick.mouseAcceleration, $0) } + get: { settings.mouseAcceleration }, + set: { updateSettings(\.mouseAcceleration, $0) } ), range: 0...1, description: "0 = linear, 1 = max curve" ) } - if settings.rightStick.mode == .scroll { + if settings.rightStickMode == .scroll { SliderRow( label: "Sensitivity", value: Binding( - get: { settings.rightStick.scrollSensitivity }, - set: { updateSettings(\.rightStick.scrollSensitivity, $0) } + get: { settings.scrollSensitivity }, + set: { updateSettings(\.scrollSensitivity, $0) } ), range: 0...1, description: "How fast scrolling occurs" @@ -306,8 +224,8 @@ struct JoystickSettingsView: View { SliderRow( label: "Acceleration", value: Binding( - get: { settings.rightStick.scrollAcceleration }, - set: { updateSettings(\.rightStick.scrollAcceleration, $0) } + get: { settings.scrollAcceleration }, + set: { updateSettings(\.scrollAcceleration, $0) } ), range: 0...1, description: "0 = linear, 1 = max curve" @@ -324,81 +242,43 @@ struct JoystickSettingsView: View { ) } - if settings.rightStick.mode == .custom { + if settings.rightStickMode == .custom { JoystickCustomDirectionPanel( side: .right, horizontalSliceSize: Binding( - get: { settings.rightStick.customHorizontalSliceSize }, - set: { updateSettings(\.rightStick.customHorizontalSliceSize, $0) } + get: { settings.rightStickCustomHorizontalSliceSize }, + set: { updateSettings(\.rightStickCustomHorizontalSliceSize, $0) } ), verticalSliceSize: Binding( - get: { settings.rightStick.customVerticalSliceSize }, - set: { updateSettings(\.rightStick.customVerticalSliceSize, $0) } + get: { settings.rightStickCustomVerticalSliceSize }, + set: { updateSettings(\.rightStickCustomVerticalSliceSize, $0) } ), deadzone: Binding( - get: { settings.rightStick.customDeadzone }, - set: { updateSettings(\.rightStick.customDeadzone, $0) } + get: { settings.rightStickCustomDeadzone }, + set: { updateSettings(\.rightStickCustomDeadzone, $0) } ), invertY: Binding( - get: { settings.rightStick.invertScrollY }, - set: { updateSettings(\.rightStick.invertScrollY, $0) } + get: { settings.invertScrollY }, + set: { updateSettings(\.invertScrollY, $0) } ) ) } else { - // Deadzone/Invert follow the active mode so they edit the field the - // runtime actually reads: mouse mode uses the mouse fields, every - // other (movement) mode on the right stick uses the scroll fields. SliderRow( label: "Deadzone", value: Binding( - get: { settings.rightStick.mode == .mouse ? settings.rightStick.mouseDeadzone : settings.rightStick.scrollDeadzone }, - set: { - if settings.rightStick.mode == .mouse { - updateSettings(\.rightStick.mouseDeadzone, $0) - } else { - updateSettings(\.rightStick.scrollDeadzone, $0) - } - } + get: { settings.scrollDeadzone }, + set: { updateSettings(\.scrollDeadzone, $0) } ), range: 0...0.5, description: "Ignore small movements" ) Toggle("Invert Y Axis", isOn: Binding( - get: { settings.rightStick.mode == .mouse ? settings.rightStick.invertMouseY : settings.rightStick.invertScrollY }, - set: { - if settings.rightStick.mode == .mouse { - updateSettings(\.rightStick.invertMouseY, $0) - } else { - updateSettings(\.rightStick.invertScrollY, $0) - } - } + get: { settings.invertScrollY }, + set: { updateSettings(\.invertScrollY, $0) } )) } } - - if let layers = profileManager.activeProfile?.layers, !layers.isEmpty { - Section("Per-Layer Overrides") { - Text("Override stick behavior while a layer is held. Any control left on “Inherit” uses the base settings above.") - .font(.caption) - .foregroundStyle(.secondary) - - Picker("Layer", selection: Binding( - get: { resolvedOverrideLayer(layers)?.id }, - set: { overrideLayerId = $0 } - )) { - ForEach(layers) { layer in - Text(layer.name).tag(layer.id as UUID?) - } - } - - if let layer = resolvedOverrideLayer(layers) { - layerStickOverrideGroup(title: "Left Stick", side: .left, layer: layer) - Divider() - layerStickOverrideGroup(title: "Right Stick", side: .right, layer: layer) - } - } - } } .formStyle(.grouped) .padding() @@ -410,154 +290,13 @@ struct JoystickSettingsView: View { profileManager.updateJoystickSettings(newSettings) } - private var analogPrecisionTriggerButtons: [ControllerButton] { - switch settings.analogPrecisionTriggerMode { - case .off: - return [] - case .left: - return [.leftTrigger] - case .right: - return [.rightTrigger] - case .either: - return [.rightTrigger, .leftTrigger] - } - } - - private var analogPrecisionWarning: String? { - guard !analogPrecisionTriggerButtons.isEmpty else { return nil } - var messages: [String] = [] - - if let profile = profileManager.activeProfile { - let mappedTriggers = analogPrecisionTriggerButtons.filter { button in - (profile.buttonMappings[button]?.isEmpty == false) - } - if !mappedTriggers.isEmpty { - let names = mappedTriggers.map(\.displayName).joined(separator: " / ") - messages.append("\(names) also has a button mapping; precision stacks with that action.") - } - } - - if analogPrecisionTriggerButtons.contains(.leftTrigger) { - messages.append("LT also starts swipe typing while the on-screen keyboard is visible.") - } - - return messages.isEmpty ? nil : messages.joined(separator: " ") - } - - /// The layer whose overrides are being edited: the picked one, or the first layer - /// when nothing valid is selected (so the section always shows a layer). - private func resolvedOverrideLayer(_ layers: [Layer]) -> Layer? { - layers.first(where: { $0.id == overrideLayerId }) ?? layers.first - } - - /// Per-stick override controls for the selected layer: a mode override (with an - /// explicit "Inherit" option) plus sensitivity/acceleration/deadzone for the - /// effective mode. Each control inherits the base stick until explicitly set. - @ViewBuilder - private func layerStickOverrideGroup(title: String, side: JoystickSide, layer: Layer) -> some View { - let override = side == .left ? layer.leftStickTuning : layer.rightStickTuning - let base = settings.stick(side) - let effectiveMode = override?.mode ?? base.mode - - VStack(alignment: .leading, spacing: 10) { - HStack { - Text(LocalizedStringKey(title)) - .font(.subheadline.bold()) - Spacer() - if override != nil { - Button("Reset to Base") { - profileManager.clearLayerStickOverride(side: side, layerId: layer.id) - } - .buttonStyle(.borderless) - .font(.caption) - } - } - - Picker("Mode", selection: Binding( - get: { override?.mode }, - set: { profileManager.setLayerStickOverride(\.mode, $0, side: side, layerId: layer.id) } - )) { - Text("Inherit (\(base.mode.displayName))").tag(StickMode?.none) - ForEach(StickMode.visibleModes, id: \.self) { mode in - Text(LocalizedStringKey(mode.displayName)).tag(mode as StickMode?) - } - } - - switch effectiveMode { - case .mouse: - layerOverrideSlider("Sensitivity", side: side, layer: layer, keyPath: \.mouseSensitivity, base: base.mouseSensitivity, range: 0...1) - layerOverrideSlider("Acceleration", side: side, layer: layer, keyPath: \.mouseAcceleration, base: base.mouseAcceleration, range: 0...1) - layerOverrideSlider("Deadzone", side: side, layer: layer, keyPath: \.mouseDeadzone, base: base.mouseDeadzone, range: 0...0.5) - case .scroll: - layerOverrideSlider("Sensitivity", side: side, layer: layer, keyPath: \.scrollSensitivity, base: base.scrollSensitivity, range: 0...1) - layerOverrideSlider("Acceleration", side: side, layer: layer, keyPath: \.scrollAcceleration, base: base.scrollAcceleration, range: 0...1) - layerOverrideSlider("Deadzone", side: side, layer: layer, keyPath: \.scrollDeadzone, base: base.scrollDeadzone, range: 0...0.5) - case .none, .custom, .dpad, .wasdKeys, .arrowKeys: - Text("No tunable sensitivity for \(effectiveMode.displayName) mode.") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 2) - } - - @ViewBuilder - private func layerOverrideSlider( - _ label: String, - side: JoystickSide, - layer: Layer, - keyPath: WritableKeyPath, - base: Double, - range: ClosedRange - ) -> some View { - let override = side == .left ? layer.leftStickTuning : layer.rightStickTuning - let overrideValue = override?[keyPath: keyPath] - let isOverridden = overrideValue != nil - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(LocalizedStringKey(label)) - .italic(!isOverridden) - .foregroundStyle(isOverridden ? Color.primary : Color.secondary) - if !isOverridden { - Text("· inherited") - .font(.caption2) - .foregroundStyle(.tertiary) - } - Spacer() - Text("\(overrideValue ?? base, specifier: "%.2f")") - .font(.caption) - .foregroundColor(.secondary) - .monospacedDigit() - .frame(width: 40) - if isOverridden { - Button { - profileManager.setLayerStickOverride(keyPath, nil, side: side, layerId: layer.id) - } label: { - Image(systemName: "arrow.uturn.backward") - } - .buttonStyle(.borderless) - .help("Reset to base (inherit)") - } - } - - Slider( - value: Binding( - get: { overrideValue ?? base }, - set: { profileManager.setLayerStickOverride(keyPath, $0, side: side, layerId: layer.id) } - ), - in: range - ) - } - } - } // MARK: - Touchpad Settings View struct TouchpadSettingsView: View { @EnvironmentObject var profileManager: ProfileManager - @EnvironmentObject var controllerService: ControllerService + @EnvironmentObject var controllerService: ControllerService @State private var cursorAdvancedExpanded = false @State private var zoomAdvancedExpanded = false @@ -565,13 +304,9 @@ struct TouchpadSettingsView: View { profileManager.activeProfile?.joystickSettings ?? .default } - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - - private var isAppleTVRemote: Bool { - controllerPresentationState.isAppleTVRemote - } + private var isAppleTVRemote: Bool { + controllerService.threadSafeIsAppleTVRemote + } var body: some View { Form { diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/StatsView.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/StatsView.swift index 2f90c310..67ab3180 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/StatsView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/StatsView.swift @@ -335,34 +335,24 @@ struct StatsView: View, ControllerTypeProviding { // MARK: - Top Buttons - private var topButtonsSection: some View { - let presentationState = controllerPresentationState - - return VStack(alignment: .leading, spacing: 12) { - Text("TOP BUTTONS") - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.secondary) + private var topButtonsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("TOP BUTTONS") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.secondary) let top = Array(stats.topButtons.prefix(5)) let maxCount = top.first?.count ?? 1 - ForEach(top, id: \.button) { item in - HStack(spacing: 12) { - ButtonIconView( - button: item.button, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController - ) - .frame(width: ButtonIconView.maxIconWidth) - - Text(item.button.displayName( - forDualSense: presentationState.isPlayStation, - forNintendo: presentationState.isNintendo - )) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white) - .frame(width: 90, alignment: .leading) + ForEach(top, id: \.button) { item in + HStack(spacing: 12) { + ButtonIconView(button: item.button, isDualSense: isPlayStation, isNintendo: isNintendo, isSteamController: isSteamController) + .frame(width: ButtonIconView.maxIconWidth) + + Text(item.button.displayName(forDualSense: isPlayStation, forNintendo: isNintendo)) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + .frame(width: 90, alignment: .leading) GeometryReader { geo in let fraction = CGFloat(item.count) / CGFloat(maxCount) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/TouchpadRegionMappingSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/TouchpadRegionMappingSheet.swift index 5596fc61..34c250d6 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/TouchpadRegionMappingSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/TouchpadRegionMappingSheet.swift @@ -171,7 +171,6 @@ struct TouchpadRegionMappingSheet: View { Text("App").tag(SystemCommandCategory.app) Text("Shell").tag(SystemCommandCategory.shell) Text("Link").tag(SystemCommandCategory.link) - Text("Ring").tag(SystemCommandCategory.ring) } .pickerStyle(.segmented) @@ -204,8 +203,6 @@ struct TouchpadRegionMappingSheet: View { case .link: TextField("URL", text: slot.linkURL) .textFieldStyle(.roundedBorder) - case .ring: - OuraRingSystemCommandPicker(selection: slot.ouraRingCommandOption) default: Text("Unsupported") .font(.caption) @@ -272,7 +269,6 @@ struct ActionSlot { var shellCommand: String = "" var shellRunInTerminal: Bool = false var linkURL: String = "" - var ouraRingCommandOption: OuraRingSystemCommandOption = .centerRing var showingAppPicker: Bool = false var isValid: Bool { @@ -301,8 +297,6 @@ struct ActionSlot { shellRunInTerminal = inTerm case .openLink(let url): linkURL = url - case .centerOuraRing, .toggleOuraMotion: - ouraRingCommandOption = OuraRingSystemCommandOption(command: cmd) default: break } } else if let id = mapping.macroId { @@ -331,8 +325,6 @@ struct ActionSlot { case .link: guard !linkURL.isEmpty else { return nil } return .openLink(url: linkURL) - case .ring: - return ouraRingCommandOption.systemCommand default: return nil } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/WrappedCardSheet.swift b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/WrappedCardSheet.swift index d329e6e0..27eae3de 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/WrappedCardSheet.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/MainWindow/WrappedCardSheet.swift @@ -7,13 +7,7 @@ struct WrappedCardSheet: View { @Environment(\.dismiss) private var dismiss @State private var copied = false - private var controllerPresentationState: ControllerPresentationState { - controllerService.threadSafeControllerPresentationState - } - var body: some View { - let presentationState = controllerPresentationState - VStack(spacing: 24) { Text("Your Controller Wrapped") .font(.title2) @@ -22,9 +16,9 @@ struct WrappedCardSheet: View { WrappedCardView( stats: usageStatsService.stats, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController + isDualSense: controllerService.threadSafeIsPlayStation, + isNintendo: controllerService.threadSafeIsNintendo, + isSteamController: controllerService.threadSafeIsSteamController ) .shadow(color: .black.opacity(0.3), radius: 20, y: 10) @@ -55,12 +49,11 @@ struct WrappedCardSheet: View { @MainActor private func copyCardToClipboard() { - let presentationState = controllerPresentationState let card = WrappedCardView( stats: usageStatsService.stats, - isDualSense: presentationState.isPlayStation, - isNintendo: presentationState.isNintendo, - isSteamController: presentationState.isSteamController + isDualSense: controllerService.threadSafeIsPlayStation, + isNintendo: controllerService.threadSafeIsNintendo, + isSteamController: controllerService.threadSafeIsSteamController ) let renderer = ImageRenderer(content: card) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingModel.swift b/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingModel.swift index 7a424db0..c8585903 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingModel.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingModel.swift @@ -8,18 +8,15 @@ extension Notification.Name { /// The ordered steps of the first-run permissions wizard. /// -/// Input Monitoring and Accessibility are *required* for the app to do anything -/// useful and are requested first. Input Monitoring intentionally comes before -/// Accessibility: once Accessibility is granted, macOS can report listen access -/// as granted without having separately registered the app in the Input -/// Monitoring list. Bluetooth is *optional* (wireless battery %) and is -/// skippable. Local Network is deliberately **absent** — it's requested lazily -/// when the user sets up the cross-Mac relay via the sync button, so a user who -/// never touches that feature never sees the prompt. +/// Accessibility and Input Monitoring are *required* for the app to do anything +/// useful and are requested first. Bluetooth is *optional* (wireless battery %) +/// and is skippable. Local Network is deliberately **absent** — it's requested +/// lazily when the user sets up the cross-Mac relay via the sync button, so a +/// user who never touches that feature never sees the prompt. enum OnboardingStep: Int, CaseIterable, Identifiable { case welcome - case inputMonitoring case accessibility + case inputMonitoring case bluetooth case done @@ -44,7 +41,7 @@ enum OnboardingStep: Int, CaseIterable, Identifiable { /// Index (1-based) and total of the *permission* steps, for the "Step 2 of 3" /// progress label. Welcome and Done aren't counted. - static var permissionSteps: [OnboardingStep] { [.inputMonitoring, .accessibility, .bluetooth] } + static var permissionSteps: [OnboardingStep] { [.accessibility, .inputMonitoring, .bluetooth] } } /// Pure snapshot of the permission states the wizard reacts to. Kept free of any @@ -79,8 +76,8 @@ struct OnboardingStepState: Equatable { /// `.done` when every required permission is granted — used to resume the /// wizard at the right place rather than always starting at `.welcome`. var firstIncompleteStep: OnboardingStep { + if accessibility != .granted { return .accessibility } if inputMonitoring != .granted { return .inputMonitoring } - if accessibility != .granted { return .accessibility } return .done } } diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingView.swift index 5680bf3c..8d97c15e 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Onboarding/OnboardingView.swift @@ -122,8 +122,8 @@ struct OnboardingView: View { .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 10) { + upcomingRow(icon: "accessibility", title: "Accessibility", subtitle: "Required — move the mouse and press keys") upcomingRow(icon: "keyboard", title: "Input Monitoring", subtitle: "Required — read every controller type") - upcomingRow(icon: "accessibility", title: "Accessibility", subtitle: "Required — move the mouse and press keys") upcomingRow(icon: "dot.radiowaves.left.and.right", title: "Bluetooth", subtitle: "Optional — wireless battery level") } .padding(12) @@ -193,9 +193,8 @@ struct OnboardingView: View { instructionList([ "Click **Open System Settings** below.", - "If macOS asks first, choose **Open System Settings** in that prompt too.", - "Add **ControllerKeys** if it is missing: drag the icon below into the list, or click **+** there and pick it.", "Turn on the switch next to **ControllerKeys**.", + "**Don't see it in the list?** Drag the **ControllerKeys** icon below straight into the list (or click **+** there and pick it).", "Return here — the status updates automatically." ]) diff --git a/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift b/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift index 11c8dd28..40c6f5ff 100644 --- a/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift +++ b/XboxControllerMapper/XboxControllerMapper/Views/Scripts/ScriptListView.swift @@ -222,16 +222,16 @@ struct ScriptRow: View { .foregroundColor(.accentColor) } .buttonStyle(.borderless) - .help("Edit \(script.name.isEmpty ? "Untitled Script" : script.name)") - .accessibilityLabel("Edit \(script.name.isEmpty ? "Untitled Script" : script.name)") + .help("Edit") + .accessibilityLabel("Edit") Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.red.opacity(0.8)) } .buttonStyle(.borderless) - .help("Delete \(script.name.isEmpty ? "Untitled Script" : script.name)") - .accessibilityLabel("Delete \(script.name.isEmpty ? "Untitled Script" : script.name)") + .help("Delete") + .accessibilityLabel("Delete") } } .padding(.horizontal, 12) diff --git a/XboxControllerMapper/XboxControllerMapper/XboxControllerMapperApp.swift b/XboxControllerMapper/XboxControllerMapper/XboxControllerMapperApp.swift index 671785d4..a67958a0 100644 --- a/XboxControllerMapper/XboxControllerMapper/XboxControllerMapperApp.swift +++ b/XboxControllerMapper/XboxControllerMapper/XboxControllerMapperApp.swift @@ -94,7 +94,6 @@ final class ServiceContainer { let usageStatsService: UsageStatsService let batteryNotificationManager: BatteryNotificationManager let updateCheckService: UpdateCheckService - let ouraRingInputService: OuraRingInputService private var cancellables = Set() @@ -116,10 +115,6 @@ final class ServiceContainer { self.inputMonitor = inputMonitor self.inputLogService = inputLogService self.usageStatsService = usageStatsService - self.ouraRingInputService = OuraRingInputService( - controllerService: controllerService, - profileManager: profileManager - ) self.mappingEngine = MappingEngine( controllerService: controllerService, profileManager: profileManager, @@ -507,7 +502,6 @@ struct XboxControllerMapperApp: App { .environmentObject(ServiceContainer.shared.inputMonitor) .environmentObject(ServiceContainer.shared.inputLogService) .environmentObject(ServiceContainer.shared.usageStatsService) - .environmentObject(ServiceContainer.shared.ouraRingInputService) } } .windowResizability(.contentSize) @@ -542,7 +536,6 @@ struct XboxControllerMapperApp: App { .environmentObject(ServiceContainer.shared.mappingEngine) .environmentObject(ServiceContainer.shared.inputMonitor) .environmentObject(ServiceContainer.shared.inputLogService) - .environmentObject(ServiceContainer.shared.ouraRingInputService) } } label: { if AppRuntime.isRunningTests { @@ -589,7 +582,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard !AppRuntime.isRunningTests else { return } ServiceContainer.shared.controllerService.cleanup() - ServiceContainer.shared.ouraRingInputService.stop() ServiceContainer.shared.usageStatsService.endSession() ServiceContainer.shared.profileManager.flushPendingSaves() } @@ -635,7 +627,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } permissions.requestBluetoothAction = { ServiceContainer.shared.controllerService.startBluetoothBattery() - ServiceContainer.shared.ouraRingInputService.startIfEnabled() } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/BugFixTests.swift b/XboxControllerMapper/XboxControllerMapperTests/BugFixTests.swift index 5d1f9612..e3243410 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/BugFixTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/BugFixTests.swift @@ -128,17 +128,13 @@ final class ControllerDisconnectStateResetTests: XCTestCase { let pressed = expectation(description: "Lite 2 Home press emits Xbox button") let released = expectation(description: "Lite 2 Home release emits Xbox button") - controllerService.onInputEvent = { event in - switch event { - case .buttonPressed(let button): - XCTAssertEqual(button, .xbox) - pressed.fulfill() - case .buttonReleased(let button, _): - XCTAssertEqual(button, .xbox) - released.fulfill() - default: - break - } + controllerService.onButtonPressed = { button in + XCTAssertEqual(button, .xbox) + pressed.fulfill() + } + controllerService.onButtonReleased = { button, _ in + XCTAssertEqual(button, .xbox) + released.fulfill() } var pressReport = [UInt8](repeating: 0, count: 10) @@ -173,162 +169,6 @@ final class ControllerDisconnectStateResetTests: XCTestCase { XCTAssertEqual(model, .lite2) } - func testEightBitDoMicroPhysicalDPadIsSticklessDPadNotLeftStickFallback() { - XCTAssertTrue(ControllerService.isSticklessEightBitDoModel( - vendorName: "HID", - productCategory: "8BitDo Micro gamepad" - )) - XCTAssertTrue(ControllerService.isSticklessEightBitDoModel( - vendorName: nil, - productCategory: "8BitDo Micro Controller" - )) - XCTAssertFalse(ControllerService.shouldUsePhysicalDirectionPadAsLeftStickFallback( - vendorName: "HID", - productCategory: "8BitDo Micro gamepad" - )) - XCTAssertFalse(ControllerService.isSticklessEightBitDoModel( - vendorName: "HID", - productCategory: "8BitDo Lite 2" - )) - XCTAssertTrue(ControllerService.shouldUsePhysicalDirectionPadAsLeftStickFallback( - vendorName: "HID", - productCategory: "8BitDo Lite 2" - )) - } - - func testInactiveControllerTakeoverWaitsForActiveControllerQuietWindow() { - XCTAssertFalse(ControllerService.shouldActivateInactiveControllerInput( - meaningful: false, - activeControllerHasInput: false, - activeControllerLastInputTime: 10.0, - now: 12.0, - quietInterval: 0.75 - )) - XCTAssertTrue(ControllerService.shouldActivateInactiveControllerInput( - meaningful: true, - activeControllerHasInput: false, - activeControllerLastInputTime: 0, - now: 10.0, - quietInterval: 0.75 - )) - XCTAssertFalse(ControllerService.shouldActivateInactiveControllerInput( - meaningful: true, - activeControllerHasInput: false, - activeControllerLastInputTime: 10.0, - now: 10.4, - quietInterval: 0.75 - )) - XCTAssertTrue(ControllerService.shouldActivateInactiveControllerInput( - meaningful: true, - activeControllerHasInput: false, - activeControllerLastInputTime: 10.0, - now: 10.75, - quietInterval: 0.75 - )) - } - - func testInactiveControllerTakeoverWaitsUntilActiveControllerIsNeutral() { - XCTAssertFalse(ControllerService.shouldActivateInactiveControllerInput( - meaningful: true, - activeControllerHasInput: true, - activeControllerLastInputTime: 10.0, - now: 30.0, - quietInterval: 0.75 - )) - XCTAssertTrue(ControllerService.shouldActivateInactiveControllerInput( - meaningful: true, - activeControllerHasInput: false, - activeControllerLastInputTime: 10.0, - now: 30.0, - quietInterval: 0.75 - )) - } - - func testActiveGameControllerInputDetectsHeldButtonsAnalogAndTouch() { - XCTAssertFalse(ControllerService.hasActiveGameControllerInput( - activeButtons: [], - leftStick: .zero, - rightStick: .zero, - leftTrigger: 0, - rightTrigger: 0, - touchpadIsActive: false, - deadzone: 0.18 - )) - XCTAssertTrue(ControllerService.hasActiveGameControllerInput( - activeButtons: [.a], - leftStick: .zero, - rightStick: .zero, - leftTrigger: 0, - rightTrigger: 0, - touchpadIsActive: false, - deadzone: 0.18 - )) - XCTAssertTrue(ControllerService.hasActiveGameControllerInput( - activeButtons: [], - leftStick: CGPoint(x: 0.2, y: 0), - rightStick: .zero, - leftTrigger: 0, - rightTrigger: 0, - touchpadIsActive: false, - deadzone: 0.18 - )) - XCTAssertTrue(ControllerService.hasActiveGameControllerInput( - activeButtons: [], - leftStick: .zero, - rightStick: .zero, - leftTrigger: 0, - rightTrigger: 0.2, - touchpadIsActive: false, - deadzone: 0.18 - )) - XCTAssertTrue(ControllerService.hasActiveGameControllerInput( - activeButtons: [], - leftStick: .zero, - rightStick: .zero, - leftTrigger: 0, - rightTrigger: 0, - touchpadIsActive: true, - deadzone: 0.18 - )) - } - - func testStopHapticsInvalidatesCurrentHapticSession() { - let initialGeneration = controllerService.currentHapticSessionGeneration() - - controllerService.stopHaptics() - - let stoppedGeneration = controllerService.currentHapticSessionGeneration() - XCTAssertGreaterThan(stoppedGeneration, initialGeneration) - XCTAssertFalse(controllerService.isCurrentHapticSession(initialGeneration)) - XCTAssertTrue(controllerService.isCurrentHapticSession(stoppedGeneration)) - } - - func testQueuedHapticRequestInvalidatedBeforePlaybackDoesNotRun() { - let blocker = DispatchSemaphore(value: 0) - let queueDrained = expectation(description: "haptic queue drained") - let accepted = expectation(description: "stale haptic request accepted") - accepted.isInverted = true - - controllerService.hapticQueue.async { - blocker.wait() - } - controllerService.hapticSessionAcceptedForTesting = { _ in - accepted.fulfill() - } - - controllerService.playHaptic() - controllerService.invalidateHapticSessionForTesting() - - blocker.signal() - controllerService.hapticQueue.async { - queueDrained.fulfill() - } - - wait(for: [queueDrained], timeout: 1.0) - wait(for: [accepted], timeout: 0.1) - controllerService.hapticSessionAcceptedForTesting = nil - } - func testTriggerResetExistsInDisconnectHandler() { // Verify the actual disconnect handler code resets triggers by checking // that the production code includes the trigger reset lines. diff --git a/XboxControllerMapper/XboxControllerMapperTests/ChordMappingEngineTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ChordMappingEngineTests.swift index 618c452b..c5a486d9 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ChordMappingEngineTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ChordMappingEngineTests.swift @@ -25,7 +25,7 @@ final class ChordMappingEngineTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.a, .b, .x])) + controllerService.onChordDetected?([.a, .b, .x]) } await waitForTasks() @@ -51,14 +51,14 @@ final class ChordMappingEngineTests: MappingEngineTestCase { await MainActor.run { // Only 2 buttons - should fallback to individual - controllerService.emitInputEvent(.chordDetected([.a, .b])) + controllerService.onChordDetected?([.a, .b]) } await waitForTasks() // Release both await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) - controllerService.emitInputEvent(.buttonReleased(.b, holdDuration: 0.05)) + controllerService.onButtonReleased?(.a, 0.05) + controllerService.onButtonReleased?(.b, 0.05) } await waitForTasks() @@ -96,7 +96,7 @@ final class ChordMappingEngineTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.x, .y])) + controllerService.onChordDetected?([.x, .y]) } await waitForTasks() @@ -125,7 +125,7 @@ final class ChordMappingEngineTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.a, .b])) + controllerService.onChordDetected?([.a, .b]) } await waitForTasks() @@ -152,7 +152,7 @@ final class ChordMappingEngineTests: MappingEngineTestCase { // Trigger 3-button chord await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.a, .b, .x])) + controllerService.onChordDetected?([.a, .b, .x]) } await waitForTasks() diff --git a/XboxControllerMapper/XboxControllerMapperTests/CommandPaletteFilterTests.swift b/XboxControllerMapper/XboxControllerMapperTests/CommandPaletteFilterTests.swift deleted file mode 100644 index 99e12f9a..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/CommandPaletteFilterTests.swift +++ /dev/null @@ -1,247 +0,0 @@ -import XCTest -@testable import ControllerKeys - -/// Tests for the pure ⌘K command-palette ranking. No UI / app state — exercises -/// `CommandPaletteFilter` directly so the "jump to anything" behavior is locked -/// down without touching SwiftUI. -final class CommandPaletteFilterTests: XCTestCase { - - // MARK: Fixtures - - private func make( - _ title: String, - subtitle: String? = nil, - group: String = "Map", - keywords: [String] = [], - section: Int = 0 - ) -> CommandPaletteDestination { - CommandPaletteDestination( - id: "id-\(title)-\(section)", - title: title, - subtitle: subtitle, - groupLabel: group, - systemImage: "circle", - keywords: keywords, - target: .section(section) - ) - } - - private func titles(_ items: [CommandPaletteDestination]) -> [String] { - items.map(\.title) - } - - private var sampleSections: [CommandPaletteDestination] { - [ - make("Buttons", group: "Map", keywords: ["mappings", "bindings"], section: 0), - make("Chords", group: "Map", keywords: ["combo"], section: 1), - make("Sequences", group: "Map", keywords: ["series"], section: 9), - make("Macros", group: "Automate", keywords: ["automation", "record"], section: 7), - make("Scripts", group: "Automate", keywords: ["javascript", "code"], section: 10), - make("Stats", group: "Activity", keywords: ["usage", "wrapped"], section: 8) - ] - } - - private func providerDestination( - for button: ControllerButton, - descriptor: ControllerVisualDescriptor, - mappings: [ControllerButton: KeyMapping] = [:] - ) -> CommandPaletteDestination? { - CommandPaletteDestinationProvider.destinations( - visibleTabs: [], - mappings: mappings, - descriptor: descriptor - ) - .first { $0.target == .button(button) } - } - - // MARK: Destination provider - - func testDestinationProviderBuildsSectionsButtonsAndSettings() { - let destinations = CommandPaletteDestinationProvider.destinations( - visibleTabs: [ - MainWindowSection.buttons.tabItem, - MainWindowSection.macros.tabItem - ], - mappings: [ - .b: KeyMapping(keyCode: 8, modifiers: ModifierFlags(command: true)), - .share: KeyMapping(keyCode: 9) - ], - descriptor: ControllerVisualDescriptor(family: .xbox) - ) - - XCTAssertEqual(destinations.prefix(2).map(\.target), [ - .section(MainWindowSection.buttons.rawValue), - .section(MainWindowSection.macros.rawValue) - ]) - XCTAssertEqual(destinations.first?.keywords, MainWindowSection.buttons.searchKeywords) - XCTAssertEqual(destinations.last?.target, .settings) - - let buttonTargets = destinations.filter { - if case .button = $0.target { return true } - return false - } - XCTAssertEqual(buttonTargets.filter { $0.target == .button(.b) }.count, 1) - XCTAssertEqual(buttonTargets.filter { $0.target == .button(.share) }.count, 1) - - let mappedBIndex = destinations.firstIndex { $0.target == .button(.b) } - let firstCoreAIndex = destinations.firstIndex { $0.target == .button(.a) } - XCTAssertNotNil(mappedBIndex) - XCTAssertNotNil(firstCoreAIndex) - XCTAssertLessThan(mappedBIndex!, firstCoreAIndex!) - - let b = destinations.first { $0.target == .button(.b) } - XCTAssertEqual(b?.title, "B") - XCTAssertEqual(b?.subtitle, "⌘ + C") - XCTAssertTrue(b?.keywords.contains("b") == true) - XCTAssertTrue(b?.keywords.contains("⌘ + C") == true) - } - - func testDestinationProviderUsesControllerSpecificButtonLabels() { - XCTAssertEqual( - providerDestination( - for: .a, - descriptor: ControllerVisualDescriptor(family: .dualSense) - )?.title, - "Cross" - ) - XCTAssertEqual( - providerDestination( - for: .leftTrigger, - descriptor: ControllerVisualDescriptor(family: .nintendo) - )?.title, - "ZL" - ) - XCTAssertEqual( - providerDestination( - for: .leftTrigger, - descriptor: ControllerVisualDescriptor(family: .eightBitDo(.micro)) - )?.title, - "L2" - ) - XCTAssertEqual( - providerDestination( - for: .view, - descriptor: ControllerVisualDescriptor(family: .appleTVRemote) - )?.title, - "Back" - ) - } - - // MARK: Empty query - - func testEmptyQueryReturnsAllInOriginalOrder() { - let result = CommandPaletteFilter.filter(sampleSections, query: "") - XCTAssertEqual(titles(result), titles(sampleSections)) - } - - func testWhitespaceQueryReturnsAll() { - let result = CommandPaletteFilter.filter(sampleSections, query: " ") - XCTAssertEqual(result.count, sampleSections.count) - } - - // MARK: Matching - - func testExactTitleMatchRanksFirst() { - let result = CommandPaletteFilter.filter(sampleSections, query: "macros") - XCTAssertEqual(result.first?.title, "Macros") - } - - func testCaseInsensitive() { - let result = CommandPaletteFilter.filter(sampleSections, query: "SCRIPTS") - XCTAssertEqual(result.first?.title, "Scripts") - } - - func testPrefixMatchFound() { - let result = CommandPaletteFilter.filter(sampleSections, query: "seq") - XCTAssertEqual(result.first?.title, "Sequences") - } - - func testNoMatchReturnsEmpty() { - let result = CommandPaletteFilter.filter(sampleSections, query: "zzzznope") - XCTAssertTrue(result.isEmpty) - } - - func testSubsequenceFuzzyMatch() { - // "sqnc" is not a substring of "Sequences" but is a subsequence. - let result = CommandPaletteFilter.filter(sampleSections, query: "sqnc") - XCTAssertEqual(result.first?.title, "Sequences") - } - - // MARK: Ranking tiers - - func testTitleMatchOutranksKeywordMatch() { - let items = [ - make("Stats", group: "Activity", keywords: ["usage"], section: 8), - make("Record", group: "Automate", keywords: ["macros"], section: 7) // keyword-only "mac" hit - ] - // "stat" is a title prefix of Stats (score 1) and unrelated to Record. - let result = CommandPaletteFilter.filter(items, query: "stat") - XCTAssertEqual(result.first?.title, "Stats") - } - - func testTitlePrefixBeatsSubstring() { - let items = [ - make("Touch Scripts", section: 1), // "scr" is a substring (score 3) - make("Scripts", section: 10) // "scr" is a prefix (score 1) - ] - let result = CommandPaletteFilter.filter(items, query: "scr") - XCTAssertEqual(result.first?.title, "Scripts") - } - - func testKeywordMatchSurfacesSection() { - // "javascript" only appears in Scripts' keywords. - let result = CommandPaletteFilter.filter(sampleSections, query: "javascript") - XCTAssertEqual(result.first?.title, "Scripts") - } - - // MARK: Shortcut/binding search (the "jump to a shortcut" path) - - func testBindingSubtitleIsSearchable() { - let items = [ - make("A Button", subtitle: "Left Click", group: "Button", keywords: ["a", "Left Click"], section: 0), - make("B Button", subtitle: "⌘ C", group: "Button", keywords: ["b", "⌘ C"], section: 0), - make("X Button", subtitle: "Esc", group: "Button", keywords: ["x", "Esc"], section: 0) - ] - // Typing the bound action finds the button carrying it. - let result = CommandPaletteFilter.filter(items, query: "left click") - XCTAssertEqual(result.first?.title, "A Button") - } - - func testButtonFoundByRawName() { - let items = [ - make("Cross", subtitle: "Jump", group: "Button", keywords: ["a"], section: 0) - ] - // PlayStation "Cross" is internally button `a` — searchable via keyword. - let result = CommandPaletteFilter.filter(items, query: "a") - XCTAssertEqual(result.first?.title, "Cross") - } - - // MARK: Stability - - func testStableTiebreakKeepsOriginalOrder() { - // Two items that match equally well (both exact title prefix of "item"). - let items = [ - make("Item One", section: 1), - make("Item Two", section: 2) - ] - let result = CommandPaletteFilter.filter(items, query: "item") - XCTAssertEqual(titles(result), ["Item One", "Item Two"]) - } - - // MARK: Score primitives - - func testMatchScoreTiers() { - XCTAssertEqual(CommandPaletteFilter.matchScore("macros", "macros"), 0) // exact - XCTAssertEqual(CommandPaletteFilter.matchScore("macros", "mac"), 1) // prefix - XCTAssertEqual(CommandPaletteFilter.matchScore("automacro", "mac"), 3) // substring - XCTAssertEqual(CommandPaletteFilter.matchScore("sequences", "sqnc"), 8) // subsequence - XCTAssertNil(CommandPaletteFilter.matchScore("macros", "xyz")) // no match - } - - func testIsSubsequence() { - XCTAssertTrue(CommandPaletteFilter.isSubsequence("sqnc", of: "sequences")) - XCTAssertTrue(CommandPaletteFilter.isSubsequence("", of: "anything")) - XCTAssertFalse(CommandPaletteFilter.isSubsequence("zzz", of: "sequences")) - XCTAssertFalse(CommandPaletteFilter.isSubsequence("scs", of: "sc")) // needle longer - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerBatteryPolicyTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerBatteryPolicyTests.swift index f669eabd..3399f341 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerBatteryPolicyTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ControllerBatteryPolicyTests.swift @@ -29,18 +29,6 @@ final class ControllerBatteryPolicyTests: XCTestCase { XCTAssertEqual(reading?.state, .discharging) } - func testGameControllerBatteryFivePercentStepsArePreserved() { - let reading = ControllerBatteryReadingResolver.resolve( - prefersBluetoothBattery: false, - bluetoothLevel: nil, - bluetoothIsCharging: false, - controllerBatteryLevel: 0.45, - controllerBatteryState: .discharging - ) - - XCTAssertEqual(reading?.level ?? -1, 0.45, accuracy: 0.001) - } - func testXboxBluetoothBatteryIsClamped() { let reading = ControllerBatteryReadingResolver.resolve( prefersBluetoothBattery: true, diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerButtonCoverageTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerButtonCoverageTests.swift index fb99e882..7d19765c 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerButtonCoverageTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ControllerButtonCoverageTests.swift @@ -99,36 +99,15 @@ final class ControllerButtonCoverageTests: XCTestCase { XCTAssertTrue(ControllerButton.xboxButtons.contains(.a)) XCTAssertFalse(ControllerButton.xboxButtons.contains(.siri)) XCTAssertFalse(ControllerButton.xboxButtons.contains(.appleTVRemotePower)) - XCTAssertFalse(ControllerButton.xboxButtons.contains(.ouraTap)) - XCTAssertFalse(ControllerButton.xboxButtons.contains(.ouraDoubleTap)) XCTAssertFalse(ControllerButton.dualSenseButtons.contains(.share), "Standard DualSense does not expose Share") XCTAssertTrue(ControllerButton.dualSenseButtons.contains(.touchpadButton)) XCTAssertTrue(ControllerButton.dualSenseButtons.contains(.a)) XCTAssertFalse(ControllerButton.dualSenseButtons.contains(.siri)) XCTAssertFalse(ControllerButton.dualSenseButtons.contains(.appleTVRemoteVolumeUp)) - XCTAssertFalse(ControllerButton.dualSenseButtons.contains(.ouraTap)) - XCTAssertFalse(ControllerButton.dualSenseButtons.contains(.ouraTripleTap)) XCTAssertFalse(ControllerButton.dualShockButtons.contains(.siri)) XCTAssertFalse(ControllerButton.nintendoButtons.contains(.siri)) - XCTAssertFalse(ControllerButton.nintendoButtons.contains(.ouraTap)) - XCTAssertFalse(ControllerButton.nintendoButtons.contains(.ouraFiveTap)) - XCTAssertEqual( - ControllerButton.ouraRingTapButtons, - [.ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, .ouraTapHold] - ) - XCTAssertEqual( - ControllerButton.ouraRingFlickButtons, - [.ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight] - ) - XCTAssertEqual( - ControllerButton.ouraRingButtons, - [ - .ouraTap, .ouraDoubleTap, .ouraTripleTap, .ouraFiveTap, .ouraTapHold, - .ouraFlickUp, .ouraFlickDown, .ouraFlickLeft, .ouraFlickRight - ] - ) XCTAssertEqual( ControllerButton.appleTVRemoteButtons, [ @@ -210,18 +189,6 @@ final class ControllerButtonCoverageTests: XCTestCase { XCTAssertEqual(ControllerButton.view.systemImageName(forDualSense: true), "square.and.arrow.up") XCTAssertEqual(ControllerButton.appleTVRemotePower.systemImageName(forDualSense: false), "power") XCTAssertEqual(ControllerButton.appleTVRemoteVolumeUp.systemImageName(forDualSense: false), "speaker.wave.3.fill") - XCTAssertEqual(ControllerButton.ouraTap.systemImageName(forDualSense: false), "hand.tap") - XCTAssertEqual(ControllerButton.ouraDoubleTap.systemImageName(forDualSense: false), "2.circle") - XCTAssertEqual(ControllerButton.ouraTripleTap.systemImageName(forDualSense: false), "3.circle") - XCTAssertEqual(ControllerButton.ouraFiveTap.systemImageName(forDualSense: false), "5.circle") - XCTAssertEqual( - Set([ - ControllerButton.ouraDoubleTap.systemImageName, - ControllerButton.ouraTripleTap.systemImageName, - ControllerButton.ouraFiveTap.systemImageName - ]).count, - 3 - ) XCTAssertNil(ControllerButton.a.systemImageName(forDualSense: true), "DualSense face buttons use text symbols") } diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerInputEventTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerInputEventTests.swift deleted file mode 100644 index 1b7def7c..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerInputEventTests.swift +++ /dev/null @@ -1,43 +0,0 @@ -import CoreGraphics -import XCTest -@testable import ControllerKeys - -final class ControllerInputEventTests: XCTestCase { - func testDiscreteInputEventsUseInputQueue() { - let events: [ControllerInputEvent] = [ - .buttonPressed(.a), - .buttonReleased(.a, holdDuration: 0.12), - .chordDetected([.a, .b]), - .controllerButtonTap(.touchpadButton), - .touchpadRegionTap(.topLeft), - .motionGesture(.tiltBack) - ] - - for event in events { - XCTAssertEqual(ControllerInputEventRouting.queue(for: event), .input, "\(event) should use inputQueue") - } - } - - func testContinuousTouchpadEventsUsePollingQueue() { - let gesture = TouchpadGesture( - centerDelta: CGPoint(x: 1, y: 2), - distanceDelta: 3, - isPrimaryTouching: true, - isSecondaryTouching: true - ) - let events: [ControllerInputEvent] = [ - .touchpadMoved(CGPoint(x: 1, y: 1)), - .steamLeftTouchpadMoved(CGPoint(x: -1, y: 0.5)), - .appleTVRemoteCircularScroll(0.25), - .touchpadGesture(gesture), - .touchpadTap, - .touchpadTwoFingerTap, - .touchpadLongTap, - .touchpadTwoFingerLongTap - ] - - for event in events { - XCTAssertEqual(ControllerInputEventRouting.queue(for: event), .polling, "\(event) should use pollingQueue") - } - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerLockTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerLockTests.swift index 672f0451..b84e2078 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerLockTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ControllerLockTests.swift @@ -42,12 +42,13 @@ final class ControllerLockTests: XCTestCase { override func tearDown() async throws { await MainActor.run { mappingEngine?.disable() - OuraRingCommandCenter.shared.resetHandlersForTesting() } try? await Task.sleep(nanoseconds: 100_000_000) await MainActor.run { mockInputSimulator?.releaseAllModifiers() - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil controllerService?.cleanup() mappingEngine = nil controllerService = nil @@ -64,32 +65,6 @@ final class ControllerLockTests: XCTestCase { await Task.yield() } - func testLockToggleRecentersOuraRingOnLockAndUnlock() async throws { - var centerCount = 0 - await MainActor.run { - OuraRingCommandCenter.shared.install( - center: { centerCount += 1 }, - toggleMotion: {} - ) - } - - await MainActor.run { - XCTAssertTrue(mappingEngine.performLockToggle(), "First toggle should lock") - } - await waitForTasks(0.05) - await MainActor.run { - XCTAssertEqual(centerCount, 1, "Locking should recenter the Oura ring") - } - - await MainActor.run { - XCTAssertFalse(mappingEngine.performLockToggle(), "Second toggle should unlock") - } - await waitForTasks(0.05) - await MainActor.run { - XCTAssertEqual(centerCount, 2, "Unlocking should recenter the Oura ring again") - } - } - // MARK: 1. Lock via single button — blocks subsequent presses func testLockViaSingleButton_BlocksSubsequentPresses() async throws { @@ -105,8 +80,8 @@ final class ControllerLockTests: XCTestCase { // Press X to lock await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.1) @@ -120,8 +95,8 @@ final class ControllerLockTests: XCTestCase { // Press A — should be blocked await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.1) @@ -150,15 +125,15 @@ final class ControllerLockTests: XCTestCase { // Lock await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.1) // Unlock await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.1) @@ -170,8 +145,8 @@ final class ControllerLockTests: XCTestCase { // Press A — should work now await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.2) @@ -205,8 +180,8 @@ final class ControllerLockTests: XCTestCase { // Press L3 three times to trigger sequence lock for _ in 0..<3 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftThumbstick)) - controllerService.emitInputEvent(.buttonReleased(.leftThumbstick, holdDuration: 0.03)) + controllerService.onButtonPressed?(.leftThumbstick) + controllerService.onButtonReleased?(.leftThumbstick, 0.03) } await waitForTasks(0.05) } @@ -220,8 +195,8 @@ final class ControllerLockTests: XCTestCase { // Press A — should be blocked await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.1) @@ -255,8 +230,8 @@ final class ControllerLockTests: XCTestCase { // Lock via sequence for _ in 0..<3 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftThumbstick)) - controllerService.emitInputEvent(.buttonReleased(.leftThumbstick, holdDuration: 0.03)) + controllerService.onButtonPressed?(.leftThumbstick) + controllerService.onButtonReleased?(.leftThumbstick, 0.03) } await waitForTasks(0.05) } @@ -265,8 +240,8 @@ final class ControllerLockTests: XCTestCase { // Unlock via sequence for _ in 0..<3 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftThumbstick)) - controllerService.emitInputEvent(.buttonReleased(.leftThumbstick, holdDuration: 0.03)) + controllerService.onButtonPressed?(.leftThumbstick) + controllerService.onButtonReleased?(.leftThumbstick, 0.03) } await waitForTasks(0.05) } @@ -280,8 +255,8 @@ final class ControllerLockTests: XCTestCase { // Press A — should work await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.2) @@ -314,7 +289,7 @@ final class ControllerLockTests: XCTestCase { // Press chord LB+RB await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.leftBumper, .rightBumper])) + controllerService.onChordDetected?([.leftBumper, .rightBumper]) } await waitForTasks(0.2) @@ -326,8 +301,8 @@ final class ControllerLockTests: XCTestCase { // Press A — should be blocked await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.1) @@ -360,20 +335,20 @@ final class ControllerLockTests: XCTestCase { // Lock await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.leftBumper, .rightBumper])) + controllerService.onChordDetected?([.leftBumper, .rightBumper]) } await waitForTasks(0.2) // Release chord buttons await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.leftBumper, holdDuration: 0.1)) - controllerService.emitInputEvent(.buttonReleased(.rightBumper, holdDuration: 0.1)) + controllerService.onButtonReleased?(.leftBumper, 0.1) + controllerService.onButtonReleased?(.rightBumper, 0.1) } await waitForTasks(0.1) // Unlock await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.leftBumper, .rightBumper])) + controllerService.onChordDetected?([.leftBumper, .rightBumper]) } await waitForTasks(0.2) @@ -385,8 +360,8 @@ final class ControllerLockTests: XCTestCase { // Press A — should work await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.2) @@ -415,7 +390,7 @@ final class ControllerLockTests: XCTestCase { // Hold LB (command modifier) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftBumper)) + controllerService.onButtonPressed?(.leftBumper) } await waitForTasks(0.1) @@ -427,8 +402,8 @@ final class ControllerLockTests: XCTestCase { // Lock — should release modifiers await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.2) @@ -456,8 +431,8 @@ final class ControllerLockTests: XCTestCase { // Lock await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.1) @@ -500,8 +475,8 @@ final class ControllerLockTests: XCTestCase { // Lock await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.1) @@ -510,8 +485,8 @@ final class ControllerLockTests: XCTestCase { // Try sequence A → B while locked for button: ControllerButton in [.a, .b] { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(button)) - controllerService.emitInputEvent(.buttonReleased(button, holdDuration: 0.03)) + controllerService.onButtonPressed?(button) + controllerService.onButtonReleased?(button, 0.03) } await waitForTasks(0.05) } @@ -545,8 +520,8 @@ final class ControllerLockTests: XCTestCase { // Lock await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.x)) - controllerService.emitInputEvent(.buttonReleased(.x, holdDuration: 0.03)) + controllerService.onButtonPressed?(.x) + controllerService.onButtonReleased?(.x, 0.03) } await waitForTasks(0.1) @@ -554,7 +529,7 @@ final class ControllerLockTests: XCTestCase { // Try chord A+B while locked await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.a, .b])) + controllerService.onChordDetected?([.a, .b]) } await waitForTasks(0.2) diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerPairingGuideTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerPairingGuideTests.swift index 9cd4828b..11a4d24d 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerPairingGuideTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ControllerPairingGuideTests.swift @@ -7,6 +7,11 @@ import XCTest /// layout without a guide fails here. final class ControllerPairingGuideTests: XCTestCase { + /// All previewable controllers except `.active` (which has no single device). + private var concreteLayouts: [ControllerPreviewLayout] { + ControllerPreviewLayout.allCases.filter { $0 != .active } + } + func testActiveLayoutHasNoGuide() { // `.active` resolves to a real device at runtime; the empty state shows a // chooser instead of a single guide. @@ -14,7 +19,7 @@ final class ControllerPairingGuideTests: XCTestCase { } func testEveryConcreteLayoutHasAGuide() { - for layout in ControllerPreviewLayout.concreteLayouts { + for layout in concreteLayouts { XCTAssertNotNil( layout.pairingGuide, "\(layout.displayName) is missing a pairing guide" @@ -23,7 +28,7 @@ final class ControllerPairingGuideTests: XCTestCase { } func testGuidesAreWellFormed() { - for layout in ControllerPreviewLayout.concreteLayouts { + for layout in concreteLayouts { guard let guide = layout.pairingGuide else { XCTFail("\(layout.displayName) is missing a pairing guide") continue @@ -55,7 +60,7 @@ final class ControllerPairingGuideTests: XCTestCase { func testIconMatchesPickerIcon() { // The guide header should reuse the picker's icon for the controller so // the empty state and the dropdown stay visually consistent. - for layout in ControllerPreviewLayout.concreteLayouts { + for layout in concreteLayouts { XCTAssertEqual( layout.pairingGuide?.systemImage, layout.systemImage, @@ -64,23 +69,8 @@ final class ControllerPairingGuideTests: XCTestCase { } } - func testChooserPlatformLabelsExplainConsoleFamilies() { - XCTAssertEqual(ControllerPreviewLayout.dualSense.platformLabel, "PS5") - XCTAssertEqual(ControllerPreviewLayout.dualSenseEdge.platformLabel, "PS5 (Edge)") - XCTAssertEqual(ControllerPreviewLayout.dualShock.platformLabel, "PS4") - XCTAssertTrue(ControllerPreviewLayout.nintendo.platformLabel.contains("Switch")) - XCTAssertTrue(ControllerPreviewLayout.xbox.platformLabel.contains("Xbox")) - - for layout in ControllerPreviewLayout.concreteLayouts { - XCTAssertFalse( - layout.platformLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - "\(layout.displayName) needs a platform label for the guide chooser" - ) - } - } - func testGuideURLsAreSecure() { - for layout in ControllerPreviewLayout.concreteLayouts { + for layout in concreteLayouts { guard let url = layout.pairingGuide?.guideURL else { continue } XCTAssertEqual( url.scheme, "https", @@ -97,7 +87,7 @@ final class ControllerPairingGuideTests: XCTestCase { // each string the way the view does and assert the emphasis markers were // actually consumed: a surviving '*' means an unbalanced `**…` span that // would show literal asterisks in the card. - for layout in ControllerPreviewLayout.concreteLayouts { + for layout in concreteLayouts { guard let guide = layout.pairingGuide else { continue } let markdownStrings = guide.bluetoothSteps diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerServiceCallbackProxyTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerServiceCallbackProxyTests.swift index 8d9df5ae..ab405bec 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerServiceCallbackProxyTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ControllerServiceCallbackProxyTests.swift @@ -28,63 +28,43 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } - func testInputEventSinkReceivesControllerEvents() { + func testCallbackProxiesInvokeStoredHandlers() { var events: [String] = [] - controllerService.onInputEvent = { event in - switch event { - case .buttonPressed: - events.append("buttonPressed") - case .buttonReleased: - events.append("buttonReleased") - case .chordDetected: - events.append("chordDetected") - case .touchpadMoved: - events.append("touchpadMoved") - case .steamLeftTouchpadMoved: - events.append("steamLeftTouchpadMoved") - case .appleTVRemoteCircularScroll: - events.append("appleTVRemoteCircularScroll") - case .touchpadGesture: - events.append("touchpadGesture") - case .touchpadTap: - events.append("touchpadTap") - case .controllerButtonTap: - events.append("controllerButtonTap") - case .touchpadTwoFingerTap: - events.append("touchpadTwoFingerTap") - case .touchpadLongTap: - events.append("touchpadLongTap") - case .touchpadTwoFingerLongTap: - events.append("touchpadTwoFingerLongTap") - case .touchpadRegionTap: - events.append("touchpadRegionTap") - case .motionGesture: - events.append("motionGesture") - } - } - - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.08)) - controllerService.emitInputEvent(.chordDetected([.a, .b])) - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.3, y: 0.2))) - controllerService.emitInputEvent(.steamLeftTouchpadMoved(CGPoint(x: -0.3, y: 0.2))) - controllerService.emitInputEvent(.appleTVRemoteCircularScroll(0.3)) - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onButtonPressed = { _ in events.append("buttonPressed") } + controllerService.onButtonReleased = { _, _ in events.append("buttonReleased") } + controllerService.onChordDetected = { _ in events.append("chordDetected") } + controllerService.onLeftStickMoved = { _ in events.append("leftStickMoved") } + controllerService.onRightStickMoved = { _ in events.append("rightStickMoved") } + controllerService.onTouchpadMoved = { _ in events.append("touchpadMoved") } + controllerService.onSteamLeftTouchpadMoved = { _ in events.append("steamLeftTouchpadMoved") } + controllerService.onTouchpadGesture = { _ in events.append("touchpadGesture") } + controllerService.onTouchpadTap = { events.append("touchpadTap") } + controllerService.onControllerButtonTap = { _ in events.append("controllerButtonTap") } + controllerService.onTouchpadTwoFingerTap = { events.append("touchpadTwoFingerTap") } + controllerService.onTouchpadLongTap = { events.append("touchpadLongTap") } + controllerService.onTouchpadTwoFingerLongTap = { events.append("touchpadTwoFingerLongTap") } + + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.08) + controllerService.onChordDetected?([.a, .b]) + controllerService.onLeftStickMoved?(CGPoint(x: 0.2, y: -0.1)) + controllerService.onRightStickMoved?(CGPoint(x: -0.4, y: 0.7)) + controllerService.onTouchpadMoved?(CGPoint(x: 0.3, y: 0.2)) + controllerService.onSteamLeftTouchpadMoved?(CGPoint(x: -0.3, y: 0.2)) + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.05, y: 0.01), distanceDelta: 0.02, isPrimaryTouching: true, isSecondaryTouching: false ) - )) - controllerService.emitInputEvent(.touchpadTap) - controllerService.emitInputEvent(.controllerButtonTap(.rightTouchpadTap)) - controllerService.emitInputEvent(.touchpadTwoFingerTap) - controllerService.emitInputEvent(.touchpadLongTap) - controllerService.emitInputEvent(.touchpadTwoFingerLongTap) - controllerService.emitInputEvent(.touchpadRegionTap(.topLeft)) - controllerService.emitInputEvent(.motionGesture(.tiltBack)) + ) + controllerService.onTouchpadTap?() + controllerService.onControllerButtonTap?(.rightTouchpadTap) + controllerService.onTouchpadTwoFingerTap?() + controllerService.onTouchpadLongTap?() + controllerService.onTouchpadTwoFingerLongTap?() XCTAssertEqual( events, @@ -92,61 +72,44 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { "buttonPressed", "buttonReleased", "chordDetected", + "leftStickMoved", + "rightStickMoved", "touchpadMoved", "steamLeftTouchpadMoved", - "appleTVRemoteCircularScroll", "touchpadGesture", "touchpadTap", "controllerButtonTap", "touchpadTwoFingerTap", "touchpadLongTap", "touchpadTwoFingerLongTap", - "touchpadRegionTap", - "motionGesture", ] ) } - func testInputEventSinkAllowsReplacementAndClearing() { + func testCallbackProxiesAllowReplacementAndClearing() { var firstCount = 0 var secondCount = 0 - controllerService.onInputEvent = { event in - if case .touchpadTap = event { - firstCount += 1 - } - } - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap = { firstCount += 1 } + controllerService.onTouchpadTap?() - controllerService.onInputEvent = { event in - if case .touchpadTap = event { - secondCount += 1 - } - } - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap = { secondCount += 1 } + controllerService.onTouchpadTap?() - controllerService.onInputEvent = nil - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap = nil + controllerService.onTouchpadTap?() XCTAssertEqual(firstCount, 1) XCTAssertEqual(secondCount, 1) - XCTAssertNil(controllerService.onInputEvent) + XCTAssertNil(controllerService.onTouchpadTap) } func testSteamTwoPadGestureLatchSuppressesSinglePadMovementBetweenAlternatingReports() { controllerService.storage.isSteamController = true var movements: [CGPoint] = [] var gestures: [TouchpadGesture] = [] - controllerService.onInputEvent = { event in - switch event { - case .touchpadMoved(let delta): - movements.append(delta) - case .touchpadGesture(let gesture): - gestures.append(gesture) - default: - break - } - } + controllerService.onTouchpadMoved = { movements.append($0) } + controllerService.onTouchpadGesture = { gestures.append($0) } controllerService.updateSteamTouchpad(side: .left, x: 0.0, y: 0.0, isTouching: true) controllerService.updateSteamTouchpad(side: .right, x: 0.0, y: 0.0, isTouching: true) @@ -171,11 +134,7 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { func testSteamLeftTouchpadSinglePadMovementUsesDedicatedCallback() { controllerService.storage.isSteamController = true var leftPadMovements: [CGPoint] = [] - controllerService.onInputEvent = { event in - if case .steamLeftTouchpadMoved(let delta) = event { - leftPadMovements.append(delta) - } - } + controllerService.onSteamLeftTouchpadMoved = { leftPadMovements.append($0) } controllerService.updateSteamTouchpad(side: .left, x: 0.0, y: 0.0, isTouching: true) controllerService.updateSteamTouchpad(side: .left, x: 0.01, y: 0.0, isTouching: true) @@ -191,11 +150,7 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { func testSteamRightTouchpadClickSuppressesClickInducedTouchJitter() { var movements: [CGPoint] = [] controllerService.storage.isSteamController = true - controllerService.onInputEvent = { event in - if case .touchpadMoved(let delta) = event { - movements.append(delta) - } - } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.updateSteamTouchpad(side: .right, x: 0.0, y: 0.0, isTouching: true) controllerService.storage.touchpadFramesSinceTouch = 3 @@ -228,11 +183,7 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { func testSteamLeftTouchpadClickSuppressesClickInducedTouchJitter() { var movements: [CGPoint] = [] controllerService.storage.isSteamController = true - controllerService.onInputEvent = { event in - if case .steamLeftTouchpadMoved(let delta) = event { - movements.append(delta) - } - } + controllerService.onSteamLeftTouchpadMoved = { movements.append($0) } controllerService.updateSteamTouchpad(side: .left, x: 0.0, y: 0.0, isTouching: true) controllerService.storage.touchpadSecondaryFramesSinceTouch = 3 @@ -268,11 +219,7 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { var pressed: [ControllerButton] = [] controllerService.lowLatencyInputEnabled = true controllerService.chordParticipantButtons = [.b] - controllerService.onInputEvent = { event in - if case .buttonPressed(let button) = event { - pressed.append(button) - } - } + controllerService.onButtonPressed = { pressed.append($0) } controllerService.buttonPressed(.a) @@ -285,16 +232,8 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { controllerService.storage.isAppleTVRemote = true controllerService.storage.isSteamController = false controllerService.touchpadInputMode = .quadrants - controllerService.onInputEvent = { event in - switch event { - case .touchpadTap: - tapCount += 1 - case .touchpadRegionTap: - regionTapCount += 1 - default: - break - } - } + controllerService.onTouchpadTap = { tapCount += 1 } + controllerService.onTouchpadRegionTap = { _ in regionTapCount += 1 } controllerService.updateTouchpad(x: 0.5, y: 0.5, isTouching: true) controllerService.updateTouchpad(x: 0.5, y: 0.5, isTouching: false) @@ -306,16 +245,8 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { func testAppleTVRemoteClickpadButtonAggregatesDuplicateHIDSources() { var events: [ControllerButtonEvent] = [] controllerService.lowLatencyInputEnabled = true - controllerService.onInputEvent = { event in - switch event { - case .buttonPressed(let button): - events.append(ControllerButtonEvent(button: button, pressed: true)) - case .buttonReleased(let button, _): - events.append(ControllerButtonEvent(button: button, pressed: false)) - default: - break - } - } + controllerService.onButtonPressed = { events.append(ControllerButtonEvent(button: $0, pressed: true)) } + controllerService.onButtonReleased = { button, _ in events.append(ControllerButtonEvent(button: button, pressed: false)) } controllerService.dispatchAppleTVRemoteButtonState(.touchpadButton, sourceKey: 1, isPressed: true) drainControllerQueue() @@ -339,11 +270,7 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { var movements: [CGPoint] = [] controllerService.storage.isAppleTVRemote = true controllerService.storage.isSteamController = false - controllerService.onInputEvent = { event in - if case .touchpadMoved(let delta) = event { - movements.append(delta) - } - } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.updateTouchpad(x: 0.40, y: 0.40, isTouching: true) controllerService.storage.touchpadFramesSinceTouch = 3 @@ -371,18 +298,9 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { controllerService.lowLatencyInputEnabled = true controllerService.storage.isAppleTVRemote = true controllerService.storage.isSteamController = false - controllerService.onInputEvent = { event in - switch event { - case .buttonPressed(let button): - buttonEvents.append(ControllerButtonEvent(button: button, pressed: true)) - case .buttonReleased(let button, _): - buttonEvents.append(ControllerButtonEvent(button: button, pressed: false)) - case .touchpadTap: - tapCount += 1 - default: - break - } - } + controllerService.onButtonPressed = { buttonEvents.append(ControllerButtonEvent(button: $0, pressed: true)) } + controllerService.onButtonReleased = { button, _ in buttonEvents.append(ControllerButtonEvent(button: button, pressed: false)) } + controllerService.onTouchpadTap = { tapCount += 1 } controllerService.updateTouchpad(x: 0.5, y: 0.5, isTouching: true) controllerService.dispatchAppleTVRemoteButtonState(.touchpadButton, sourceKey: 1, isPressed: true) @@ -648,11 +566,7 @@ final class ControllerServiceCallbackProxyTests: XCTestCase { func testAppleTVRemoteTouchReportsFeedTouchpadMovement() { var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - if case .touchpadMoved(let delta) = event { - movements.append(delta) - } - } + controllerService.onTouchpadMoved = { movements.append($0) } for x in [75, 82, 90, 100, 112] { let report = currentAppleTVRemoteTouchReport(x: x, y: 52, pressure: 17) diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerSnapshotTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerSnapshotTests.swift index 4b96f2d0..125c8e69 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerSnapshotTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ControllerSnapshotTests.swift @@ -52,59 +52,15 @@ final class ControllerSnapshotTests: XCTestCase { func testSnapshotCapturesHasMotionFalseWhenNeitherFlagSet() { controllerService.storage.lock.lock() - controllerService.storage.applyControllerTypeLocked(.xbox) + controllerService.storage.isDualSense = false + controllerService.storage.isDualShock = false + controllerService.storage.isSteamController = false controllerService.storage.lock.unlock() let snap = controllerService.snapshot() XCTAssertFalse(snap.hasMotion) } - func testApplyingControllerTypeClearsCompetingFamilyFlags() { - controllerService.storage.lock.lock() - defer { controllerService.storage.lock.unlock() } - controllerService.storage.isDualSense = true - controllerService.storage.isDualShock = true - controllerService.storage.isNintendo = true - controllerService.storage.isJoyConLeft = true - controllerService.storage.isXboxElite = true - controllerService.storage.isAppleTVRemote = true - - controllerService.storage.applyControllerTypeLocked(.steam) - - XCTAssertEqual(controllerService.storage.controllerTypeStateLocked, .steam) - XCTAssertFalse(controllerService.storage.isDualSense) - XCTAssertFalse(controllerService.storage.isDualShock) - XCTAssertFalse(controllerService.storage.isNintendo) - XCTAssertFalse(controllerService.storage.isJoyConLeft) - XCTAssertFalse(controllerService.storage.isXboxElite) - XCTAssertFalse(controllerService.storage.isAppleTVRemote) - XCTAssertTrue(controllerService.storage.isSteamController) - } - - func testDualSenseEdgeControllerTypeSetsBaseDualSenseCapability() { - controllerService.storage.lock.lock() - defer { controllerService.storage.lock.unlock() } - controllerService.storage.applyControllerTypeLocked(.dualSenseEdge) - - XCTAssertEqual(controllerService.storage.controllerTypeStateLocked, .dualSenseEdge) - XCTAssertTrue(controllerService.storage.isDualSense) - XCTAssertTrue(controllerService.storage.isDualSenseEdge) - XCTAssertFalse(controllerService.storage.isDualShock) - XCTAssertFalse(controllerService.storage.isNintendo) - } - - func testNintendoControllerTypeCarriesJoyConSide() { - controllerService.storage.lock.lock() - defer { controllerService.storage.lock.unlock() } - controllerService.storage.applyControllerTypeLocked(.nintendo(.left)) - - XCTAssertEqual(controllerService.storage.controllerTypeStateLocked, .nintendo(.left)) - XCTAssertTrue(controllerService.storage.isNintendo) - XCTAssertTrue(controllerService.storage.isJoyConLeft) - XCTAssertFalse(controllerService.storage.isJoyConRight) - XCTAssertFalse(controllerService.storage.isDualSense) - } - func testSnapshotReportsSteamControllerHasMotion() { controllerService.storage.lock.lock() controllerService.storage.isSteamController = true @@ -503,11 +459,7 @@ final class ControllerSnapshotTests: XCTestCase { func testSteamTouchpadsUseSharedSurfaceForTwoPadPinch() { var gestures: [TouchpadGesture] = [] - controllerService.onInputEvent = { event in - if case .touchpadGesture(let gesture) = event { - gestures.append(gesture) - } - } + controllerService.onTouchpadGesture = { gestures.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isSteamController = true } @@ -532,16 +484,8 @@ final class ControllerSnapshotTests: XCTestCase { func testSteamRestingLeftTouchpadDoesNotSuppressRightTouchpadMouse() { var gestures: [TouchpadGesture] = [] var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - switch event { - case .touchpadGesture(let gesture): - gestures.append(gesture) - case .touchpadMoved(let delta): - movements.append(delta) - default: - break - } - } + controllerService.onTouchpadGesture = { gestures.append($0) } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isSteamController = true } @@ -571,16 +515,8 @@ final class ControllerSnapshotTests: XCTestCase { func testSteamLeftTouchpadJitterDoesNotSuppressRightTouchpadMouse() { var gestures: [TouchpadGesture] = [] var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - switch event { - case .touchpadGesture(let gesture): - gestures.append(gesture) - case .touchpadMoved(let delta): - movements.append(delta) - default: - break - } - } + controllerService.onTouchpadGesture = { gestures.append($0) } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isSteamController = true } @@ -624,16 +560,8 @@ final class ControllerSnapshotTests: XCTestCase { func testAppleTVRemoteCircularScrollEmitsAngleDeltaAndSuppressesMouseMovement() { var scrollDeltas: [CGFloat] = [] var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - switch event { - case .appleTVRemoteCircularScroll(let angleDelta): - scrollDeltas.append(angleDelta) - case .touchpadMoved(let delta): - movements.append(delta) - default: - break - } - } + controllerService.onAppleTVRemoteCircularScroll = { scrollDeltas.append($0) } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isAppleTVRemote = true } @@ -651,16 +579,8 @@ final class ControllerSnapshotTests: XCTestCase { func testAppleTVRemoteCircularScrollKeepsOwnershipThroughCenterBrushUntilLift() { var scrollDeltas: [CGFloat] = [] var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - switch event { - case .appleTVRemoteCircularScroll(let angleDelta): - scrollDeltas.append(angleDelta) - case .touchpadMoved(let delta): - movements.append(delta) - default: - break - } - } + controllerService.onAppleTVRemoteCircularScroll = { scrollDeltas.append($0) } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isAppleTVRemote = true } @@ -695,16 +615,8 @@ final class ControllerSnapshotTests: XCTestCase { func testAppleTVRemoteTouchStartingInsideDoesNotBecomeCircularScrollAfterMovingOutward() { var scrollDeltas: [CGFloat] = [] var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - switch event { - case .appleTVRemoteCircularScroll(let angleDelta): - scrollDeltas.append(angleDelta) - case .touchpadMoved(let delta): - movements.append(delta) - default: - break - } - } + controllerService.onAppleTVRemoteCircularScroll = { scrollDeltas.append($0) } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isAppleTVRemote = true } @@ -731,16 +643,8 @@ final class ControllerSnapshotTests: XCTestCase { func testAppleTVRemoteCircularScrollDisabledKeepsOuterRingMouseMovement() { var scrollDeltas: [CGFloat] = [] var movements: [CGPoint] = [] - controllerService.onInputEvent = { event in - switch event { - case .appleTVRemoteCircularScroll(let angleDelta): - scrollDeltas.append(angleDelta) - case .touchpadMoved(let delta): - movements.append(delta) - default: - break - } - } + controllerService.onAppleTVRemoteCircularScroll = { scrollDeltas.append($0) } + controllerService.onTouchpadMoved = { movements.append($0) } controllerService.storage.lock.withLock { controllerService.storage.isAppleTVRemote = true controllerService.storage.appleTVRemoteCircularScrollEnabled = false diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerVisualDescriptorTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerVisualDescriptorTests.swift deleted file mode 100644 index 846b64a1..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerVisualDescriptorTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -import XCTest -@testable import ControllerKeys - -final class ControllerVisualDescriptorTests: XCTestCase { - func testConcretePreviewLayoutsResolveExpectedFamilies() { - let expected: [ControllerPreviewLayout: ControllerVisualFamily] = [ - .xbox: .xbox, - .xboxElite: .xboxElite, - .dualSense: .dualSense, - .dualSenseEdge: .dualSenseEdge, - .dualShock: .dualShock, - .nintendo: .nintendo, - .steam: .steam, - .eightBitDoZero2: .eightBitDo(.zero2), - .eightBitDoMicro: .eightBitDo(.micro), - .eightBitDoLite2: .eightBitDo(.lite2), - .eightBitDoLiteSE: .eightBitDo(.liteSE), - .appleTVRemote: .appleTVRemote, - .ouraRing: .ouraRing, - ] - - for layout in ControllerPreviewLayout.concreteLayouts { - XCTAssertEqual( - ControllerVisualDescriptor.concrete(for: layout)?.family, - expected[layout], - "\(layout.rawValue) should resolve to the expected visual family" - ) - } - } - - func testActivePreviewHasNoConcreteDescriptor() { - XCTAssertNil(ControllerVisualDescriptor.concrete(for: .active)) - } - - @MainActor - func testActiveDescriptorUsesCanonicalControllerTypePrecedence() { - let storage = ControllerStorage() - - let presentationState = storage.lock.withLock { - storage.isDualSense = true - storage.isDualSenseEdge = true - storage.isDualShock = true - storage.isNintendo = true - storage.isXboxElite = true - return storage.controllerPresentationStateLocked - } - - XCTAssertEqual(presentationState.controllerType, .dualSenseEdge) - XCTAssertEqual(ControllerVisualDescriptor.active(from: presentationState).family, .dualSenseEdge) - } - - func testConcreteGamepadLayoutsResolveExpectedMinimapStyles() { - let expected: [ControllerPreviewLayout: ControllerMinimapStyle?] = [ - .xbox: .xbox, - .xboxElite: .xboxElite, - .dualSense: .dualSense, - .dualSenseEdge: .dualSenseEdge, - .dualShock: .dualShock, - .nintendo: .nintendo, - .steam: .steam, - .eightBitDoZero2: .eightBitDoZero2, - .eightBitDoMicro: .eightBitDoMicro, - .eightBitDoLite2: .eightBitDoLite2, - .eightBitDoLiteSE: .eightBitDoLiteSE, - .appleTVRemote: nil, - .ouraRing: nil, - ] - - for layout in ControllerPreviewLayout.concreteLayouts { - XCTAssertEqual( - ControllerVisualDescriptor.concrete(for: layout)?.minimapStyle, - expected[layout] ?? nil, - "\(layout.rawValue) should resolve to the expected minimap style" - ) - } - } - - func testEveryMinimapStyleIsReachableFromAPreviewLayout() { - let renderedStyles = Set( - ControllerPreviewLayout.concreteLayouts.compactMap { - ControllerVisualDescriptor.concrete(for: $0)?.minimapStyle - } - ) - - XCTAssertEqual( - renderedStyles, - Set(ControllerMinimapStyle.allCases), - "Every minimap style should be reachable from a concrete preview layout" - ) - } - - func testEveryGamepadMinimapStyleHasValidPreviewSize() { - for style in ControllerMinimapStyle.allCases { - XCTAssertGreaterThan(style.bodyAspectRatio, 0, "\(style) should have a positive aspect ratio") - XCTAssertGreaterThan(style.previewSize.width, 0, "\(style) should have a positive width") - XCTAssertGreaterThan(style.previewSize.height, 0, "\(style) should have a positive height") - XCTAssertTrue(style.previewSize.width.isFinite, "\(style) width should be finite") - XCTAssertTrue(style.previewSize.height.isFinite, "\(style) height should be finite") - } - } - - func testCapabilitiesMatchExistingControllerRules() { - let zero2 = ControllerVisualDescriptor(family: .eightBitDo(.zero2)) - XCTAssertTrue(zero2.isStickless) - XCTAssertFalse(zero2.hasSticks) - XCTAssertFalse(zero2.hasTriggers) - XCTAssertEqual(zero2.leftSystemButtons, [.view]) - XCTAssertEqual(zero2.shoulderButtons(side: .left), [.leftBumper]) - - let micro = ControllerVisualDescriptor(family: .eightBitDo(.micro)) - XCTAssertTrue(micro.isStickless) - XCTAssertTrue(micro.hasTriggers) - XCTAssertEqual(micro.leftSystemButtons, [.view, .xbox]) - - let lite2 = ControllerVisualDescriptor(family: .eightBitDo(.lite2)) - XCTAssertFalse(lite2.isStickless) - XCTAssertTrue(lite2.hasSticks) - XCTAssertTrue(lite2.hasTriggers) - - let oura = ControllerVisualDescriptor(family: .ouraRing) - XCTAssertTrue(oura.isOuraRing) - XCTAssertFalse(oura.hasSticks) - XCTAssertFalse(oura.hasTriggers) - XCTAssertEqual(oura.leftSystemButtons, []) - XCTAssertEqual(oura.rightSystemButtons, []) - } - - func testSystemRowsMatchCurrentPreviewBehavior() { - XCTAssertEqual( - ControllerVisualDescriptor(family: .xbox).rightSystemButtons, - [.menu, .share] - ) - XCTAssertEqual( - ControllerVisualDescriptor(family: .xboxElite).rightSystemButtons, - [.menu], - "Elite profile-cycle hardware button is not a mappable Share row" - ) - XCTAssertEqual( - ControllerVisualDescriptor(family: .steam).rightSystemButtons, - [.menu, .share] - ) - XCTAssertEqual( - ControllerVisualDescriptor(family: .dualSense).rightSystemButtons, - [.menu, .micMute] - ) - XCTAssertEqual( - ControllerVisualDescriptor(family: .dualSenseEdge).rightSystemButtons, - [.menu, .micMute] - ) - XCTAssertEqual( - ControllerVisualDescriptor(family: .dualShock).rightSystemButtons, - [.menu] - ) - } - - func testSpecialSectionsAreDescriptorDriven() { - let edge = ControllerVisualDescriptor(family: .dualSenseEdge) - XCTAssertTrue(edge.showsDualSenseEdgeControls) - XCTAssertFalse(edge.showsGripOrPaddleSection) - - let elite = ControllerVisualDescriptor(family: .xboxElite) - XCTAssertFalse(elite.showsDualSenseEdgeControls) - XCTAssertTrue(elite.showsGripOrPaddleSection) - XCTAssertEqual(elite.gripOrPaddleSectionTitle, "ELITE PADDLES") - - let steam = ControllerVisualDescriptor(family: .steam) - XCTAssertTrue(steam.showsGripOrPaddleSection) - XCTAssertEqual(steam.gripOrPaddleSectionTitle, "STEAM GRIP BUTTONS") - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/ControllerVisualRenderingTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ControllerVisualRenderingTests.swift deleted file mode 100644 index 482e58aa..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/ControllerVisualRenderingTests.swift +++ /dev/null @@ -1,313 +0,0 @@ -import AppKit -import SwiftUI -import XCTest -@testable import ControllerKeys - -@MainActor -final class ControllerVisualRenderingTests: XCTestCase { - private static var retainedControllerServices: [ControllerService] = [] - - func testAllControllerLayoutsRenderVisibleCanvas() throws { - for layout in ControllerPreviewLayout.concreteLayouts { - let image = try renderControllerCanvas(layout) - let stats = try sampledStats(from: image) - - XCTAssertGreaterThanOrEqual(stats.width, 1100, "\(layout.rawValue) rendered too narrow") - XCTAssertGreaterThanOrEqual(stats.height, 700, "\(layout.rawValue) rendered too short") - XCTAssertGreaterThan(stats.distinctColorBuckets, 20, "\(layout.rawValue) rendered mostly blank") - XCTAssertGreaterThan(stats.foregroundSamples, 250, "\(layout.rawValue) has too little foreground content") - XCTAssertGreaterThan(stats.foregroundBounds.width, 300, "\(layout.rawValue) foreground is too narrow") - XCTAssertGreaterThan(stats.foregroundBounds.height, 180, "\(layout.rawValue) foreground is too short") - - try writePNGIfRequested(image, name: "canvas-\(layout.rawValue)") - } - } - - func testAllPairingMinimapsRenderVisibleForeground() throws { - for layout in ControllerPreviewLayout.concreteLayouts { - let image = try renderPairingMinimap(layout) - let stats = try sampledStats(from: image) - let expectations = minimapExpectations(for: layout) - - XCTAssertGreaterThan(stats.distinctColorBuckets, 10, "\(layout.rawValue) minimap rendered mostly blank") - XCTAssertGreaterThan( - stats.foregroundSamples, - expectations.minForegroundSamples, - "\(layout.rawValue) minimap has too little foreground content" - ) - XCTAssertGreaterThan( - stats.foregroundBounds.width, - expectations.minForegroundWidth, - "\(layout.rawValue) minimap foreground is too narrow" - ) - XCTAssertGreaterThan( - stats.foregroundBounds.height, - expectations.minForegroundHeight, - "\(layout.rawValue) minimap foreground is too short" - ) - - try writePNGIfRequested(image, name: "minimap-\(layout.rawValue)") - } - } - - func testNoControllerChooserRendersVisibleCompactGrid() throws { - let image = try renderPairingChooser() - let stats = try sampledStats(from: image) - - XCTAssertGreaterThan(stats.distinctColorBuckets, 12, "Pairing chooser rendered mostly blank") - XCTAssertGreaterThan(stats.foregroundSamples, 500, "Pairing chooser has too little foreground content") - XCTAssertGreaterThan(stats.foregroundBounds.width, 420, "Pairing chooser foreground is too narrow") - XCTAssertGreaterThan(stats.foregroundBounds.height, 240, "Pairing chooser foreground is too short") - - try writePNGIfRequested(image, name: "pairing-chooser-compact") - } - - private func renderControllerCanvas(_ layout: ControllerPreviewLayout) throws -> NSImage { - let controllerService = ControllerService(enableHardwareMonitoring: false) - Self.retainedControllerServices.append(controllerService) - let profileManager = ProfileManager(configDirectoryOverride: temporaryConfigDirectory(for: layout)) - let content = ControllerVisualView( - selectedButton: .constant(nil), - selectedLayerId: nil, - previewLayout: layout, - onButtonTap: { _ in } - ) - .environmentObject(controllerService) - .environmentObject(profileManager) - - return try render( - content - .frame(width: 1120, height: 720) - .padding(24) - .background(Color(NSColor.windowBackgroundColor)), - size: CGSize(width: 1168, height: 768), - label: layout.rawValue - ) - } - - private func renderPairingMinimap(_ layout: ControllerPreviewLayout) throws -> NSImage { - let controllerService = ControllerService(enableHardwareMonitoring: false) - let pressedButtons = layout.pairingGuide?.pairingButtons ?? [] - controllerService.activeButtons = pressedButtons - Self.retainedControllerServices.append(controllerService) - - let content = StaticControllerMinimapPreview( - controllerService: controllerService, - descriptor: ControllerVisualDescriptor.resolved(previewLayout: layout, using: controllerService), - pressedButtons: pressedButtons, - targetWidth: 280, - remoteTargetHeight: 320 - ) - - return try render( - content - .frame(width: 420, height: 420) - .background(Color(NSColor.windowBackgroundColor)), - size: CGSize(width: 420, height: 420), - label: "\(layout.rawValue) minimap" - ) - } - - private func renderPairingChooser() throws -> NSImage { - let content = ControllerPairingHintView(previewLayout: .active, onSelectLayout: { _ in }) - - return try render( - content - .frame(width: 560) - .padding(20) - .background(Color(NSColor.windowBackgroundColor)), - size: CGSize(width: 600, height: 430), - label: "pairing chooser" - ) - } - - private func render(_ content: Content, size: CGSize, label: String) throws -> NSImage { - let renderer = ImageRenderer(content: content) - renderer.proposedSize = ProposedViewSize(width: size.width, height: size.height) - renderer.scale = 1 - - return try XCTUnwrap(renderer.nsImage, "\(label) did not produce an NSImage") - } - - private func temporaryConfigDirectory(for layout: ControllerPreviewLayout) -> URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("ControllerVisualRenderingTests-\(layout.rawValue)-\(UUID().uuidString)", isDirectory: true) - addTeardownBlock { - try? FileManager.default.removeItem(at: directory) - } - return directory - } - - private func sampledStats(from image: NSImage) throws -> RenderStats { - let tiffData = try XCTUnwrap(image.tiffRepresentation, "Rendered image has no TIFF representation") - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData), "Rendered image could not be decoded") - let background = try XCTUnwrap(bitmap.colorAt(x: 0, y: 0), "Rendered image has no background sample") - var foregroundSamples = 0 - var colorBuckets = Set() - var minForegroundX = Int.max - var minForegroundY = Int.max - var maxForegroundX = Int.min - var maxForegroundY = Int.min - let xStride = max(1, bitmap.pixelsWide / 96) - let yStride = max(1, bitmap.pixelsHigh / 72) - - for y in stride(from: 0, to: bitmap.pixelsHigh, by: yStride) { - for x in stride(from: 0, to: bitmap.pixelsWide, by: xStride) { - guard let color = bitmap.colorAt(x: x, y: y), color.alphaComponent > 0.05 else { continue } - colorBuckets.insert(Self.bucket(color)) - guard Self.colorDistance(color, background) > 0.03 else { continue } - - foregroundSamples += 1 - minForegroundX = min(minForegroundX, x) - minForegroundY = min(minForegroundY, y) - maxForegroundX = max(maxForegroundX, x) - maxForegroundY = max(maxForegroundY, y) - } - } - - return RenderStats( - width: bitmap.pixelsWide, - height: bitmap.pixelsHigh, - foregroundSamples: foregroundSamples, - foregroundBounds: foregroundSamples > 0 - ? CGRect( - x: minForegroundX, - y: minForegroundY, - width: maxForegroundX - minForegroundX + 1, - height: maxForegroundY - minForegroundY + 1 - ) - : .zero, - distinctColorBuckets: colorBuckets.count - ) - } - - private static func bucket(_ color: NSColor) -> Int { - let rgb = color.usingColorSpace(.deviceRGB) ?? color - let r = Int((rgb.redComponent * 31).rounded()) - let g = Int((rgb.greenComponent * 31).rounded()) - let b = Int((rgb.blueComponent * 31).rounded()) - let a = Int((rgb.alphaComponent * 31).rounded()) - return (r << 15) | (g << 10) | (b << 5) | a - } - - private static func colorDistance(_ lhs: NSColor, _ rhs: NSColor) -> CGFloat { - let left = lhs.usingColorSpace(.deviceRGB) ?? lhs - let right = rhs.usingColorSpace(.deviceRGB) ?? rhs - return max( - abs(left.redComponent - right.redComponent), - abs(left.greenComponent - right.greenComponent), - abs(left.blueComponent - right.blueComponent), - abs(left.alphaComponent - right.alphaComponent) - ) - } - - private func minimapExpectations(for layout: ControllerPreviewLayout) -> RenderExpectations { - if layout == .appleTVRemote { - return RenderExpectations( - minForegroundSamples: 80, - minForegroundWidth: 45, - minForegroundHeight: 220 - ) - } - - return RenderExpectations( - minForegroundSamples: 120, - minForegroundWidth: 150, - minForegroundHeight: 70 - ) - } - - private func writePNGIfRequested(_ image: NSImage, name: String) throws { - guard let directoryPath = ProcessInfo.processInfo.environment["CONTROLLERKEYS_RENDER_SNAPSHOT_DIR"], - !directoryPath.isEmpty else { return } - - let directory = URL(fileURLWithPath: directoryPath, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let tiffData = try XCTUnwrap(image.tiffRepresentation, "Rendered image has no TIFF representation") - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData), "Rendered image could not be decoded") - let pngData = try XCTUnwrap( - bitmap.representation(using: .png, properties: [:]), - "Rendered image could not be encoded as PNG" - ) - try pngData.write(to: directory.appendingPathComponent("\(name).png")) - } -} - -private struct RenderStats { - let width: Int - let height: Int - let foregroundSamples: Int - let foregroundBounds: CGRect - let distinctColorBuckets: Int -} - -private struct RenderExpectations { - let minForegroundSamples: Int - let minForegroundWidth: CGFloat - let minForegroundHeight: CGFloat -} - -private struct StaticControllerMinimapPreview: View { - let controllerService: ControllerService - let descriptor: ControllerVisualDescriptor - let pressedButtons: Set - let targetWidth: CGFloat - let remoteTargetHeight: CGFloat - - var body: some View { - Group { - if descriptor.isOuraRing { - ouraRingMinimap - } else if descriptor.isAppleTVRemote { - appleTVRemoteMinimap - } else { - gamepadMinimap - } - } - .allowsHitTesting(false) - .accessibilityHidden(true) - } - - private var gamepadMinimap: some View { - let style = descriptor.minimapStyle ?? .xbox - let size = style.previewSize - let scale = targetWidth / size.width - - return ZStack { - ControllerBodyView(style: style) - .frame(width: size.width, height: size.height) - - ControllerAnalogOverlay( - controllerService: controllerService, - descriptor: descriptor, - onButtonTap: { _ in }, - overrideColorForButton: { pressedButtons.contains($0) ? Color.accentColor : nil } - ) - .frame(width: size.width, height: size.height) - } - .frame(width: size.width, height: size.height) - .scaleEffect(scale) - .frame(width: targetWidth, height: (size.height * scale).rounded()) - } - - private var appleTVRemoteMinimap: some View { - let size = AppleTVRemoteMinimapView.previewSize - let scale = remoteTargetHeight / size.height - - return AppleTVRemoteMinimapView(controllerService: controllerService) - .frame(width: size.width, height: size.height) - .scaleEffect(scale) - .frame(width: (size.width * scale).rounded(), height: remoteTargetHeight) - } - - private var ouraRingMinimap: some View { - let size = OuraRingMinimapView.previewSize - let scale = targetWidth / size.width - - return OuraRingMinimapView(isTapPressed: pressedButtons.contains { $0.isOuraRingOnly }) - .frame(width: size.width, height: size.height) - .scaleEffect(scale) - .frame(width: targetWidth, height: (size.height * scale).rounded()) - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/DoubleTapAndLongHoldTests.swift b/XboxControllerMapper/XboxControllerMapperTests/DoubleTapAndLongHoldTests.swift index 7500172d..8114a442 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/DoubleTapAndLongHoldTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/DoubleTapAndLongHoldTests.swift @@ -24,8 +24,8 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // First tap await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.05) } // Wait longer than double-tap threshold @@ -58,22 +58,22 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // First tap await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.05) // Second tap (double-tap) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.05) // Third tap (should start new sequence) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.3) @@ -100,7 +100,7 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } // Wait exactly at threshold @@ -108,7 +108,7 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { await MainActor.run { // Release with hold duration exactly at threshold - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.1)) + controllerService.onButtonReleased?(.a, 0.1) } await waitForTasks() @@ -131,14 +131,14 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } // Wait less than threshold try? await Task.sleep(nanoseconds: 50_000_000) // 0.05s await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonReleased?(.a, 0.05) } await waitForTasks() @@ -167,7 +167,7 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } // Wait for long-hold timer to fire (but don't release) @@ -183,7 +183,7 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // Now release await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.3)) + controllerService.onButtonReleased?(.a, 0.3) } await waitForTasks() @@ -218,14 +218,14 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // Double tap await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.05) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.3) @@ -258,7 +258,7 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } // Wait for long hold to trigger @@ -288,15 +288,15 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // First tap of A await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.05) // Press B during A's double-tap window await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.b)) - controllerService.emitInputEvent(.buttonReleased(.b, holdDuration: 0.03)) + controllerService.onButtonPressed?(.b) + controllerService.onButtonReleased?(.b, 0.03) } await waitForTasks(0.3) @@ -327,14 +327,14 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } // Release quickly (before 0.5s threshold) try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.1)) + controllerService.onButtonReleased?(.a, 0.1) } await waitForTasks() @@ -366,8 +366,8 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // First tap await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } // Wait longer than double-tap threshold @@ -375,8 +375,8 @@ final class DoubleTapAndLongHoldTests: MappingEngineTestCase { // Second tap (too late) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.3) diff --git a/XboxControllerMapper/XboxControllerMapperTests/EngineStateAndEdgeCaseTests.swift b/XboxControllerMapper/XboxControllerMapperTests/EngineStateAndEdgeCaseTests.swift index 388b89de..28d9cf0e 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/EngineStateAndEdgeCaseTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/EngineStateAndEdgeCaseTests.swift @@ -20,7 +20,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftBumper)) + controllerService.onButtonPressed?(.leftBumper) } await waitForTasks() @@ -34,7 +34,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { await MainActor.run { // Release with OLD profile's mapping should still work - controllerService.emitInputEvent(.buttonReleased(.leftBumper, holdDuration: 0.5)) + controllerService.onButtonReleased?(.leftBumper, 0.5) } await waitForTasks() @@ -52,7 +52,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } await waitForTasks(0.1) @@ -71,8 +71,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.b)) - controllerService.emitInputEvent(.buttonReleased(.b, holdDuration: 0.1)) + controllerService.onButtonPressed?(.b) + controllerService.onButtonReleased?(.b, 0.1) } await waitForTasks() @@ -94,14 +94,14 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { await MainActor.run { // Press unmapped button first - controllerService.emitInputEvent(.buttonPressed(.b)) + controllerService.onButtonPressed?(.b) } await waitForTasks(0.1) await MainActor.run { // Then press mapped button - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.05) } await waitForTasks() @@ -126,14 +126,14 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } // Hold for multiple repeat intervals await waitForTasks(0.25) await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.25)) + controllerService.onButtonReleased?(.a, 0.25) } await waitForTasks() @@ -158,7 +158,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } await waitForTasks(0.15) @@ -170,7 +170,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { } await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.15)) + controllerService.onButtonReleased?(.a, 0.15) } // Wait to verify no more repeats @@ -206,8 +206,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.05) } await waitForTasks() @@ -232,8 +232,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftBumper)) - controllerService.emitInputEvent(.buttonPressed(.rightBumper)) + controllerService.onButtonPressed?(.leftBumper) + controllerService.onButtonPressed?(.rightBumper) } await waitForTasks() @@ -263,8 +263,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { // Rapid fire 5 press/release cycles for _ in 0..<5 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.01)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.01) } try? await Task.sleep(nanoseconds: 20_000_000) // 20ms between cycles } @@ -292,8 +292,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { for i in 0..<4 { let button: ControllerButton = i % 2 == 0 ? .a : .b await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(button)) - controllerService.emitInputEvent(.buttonReleased(button, holdDuration: 0.01)) + controllerService.onButtonPressed?(button) + controllerService.onButtonReleased?(button, 0.01) } try? await Task.sleep(nanoseconds: 30_000_000) } @@ -415,8 +415,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { // Rapid press/release cycles for _ in 0..<5 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.01)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.01) } try? await Task.sleep(nanoseconds: 50_000_000) } @@ -449,7 +449,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { await MainActor.run { // Start holding A - controllerService.emitInputEvent(.buttonPressed(.a)) + controllerService.onButtonPressed?(.a) } await waitForTasks(0.1) @@ -457,8 +457,8 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { XCTAssertTrue(mockInputSimulator.heldModifiers.contains(.maskCommand)) // Press and release B while A held - controllerService.emitInputEvent(.buttonPressed(.b)) - controllerService.emitInputEvent(.buttonReleased(.b, holdDuration: 0.05)) + controllerService.onButtonPressed?(.b) + controllerService.onButtonReleased?(.b, 0.05) } await waitForTasks() @@ -474,7 +474,7 @@ final class EngineStateAndEdgeCaseTests: MappingEngineTestCase { } await MainActor.run { - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.5)) + controllerService.onButtonReleased?(.a, 0.5) } await waitForTasks() diff --git a/XboxControllerMapper/XboxControllerMapperTests/HIDControllerDriverDescriptorTests.swift b/XboxControllerMapper/XboxControllerMapperTests/HIDControllerDriverDescriptorTests.swift deleted file mode 100644 index 6d211711..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/HIDControllerDriverDescriptorTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import XCTest -@testable import ControllerKeys - -final class HIDControllerDriverDescriptorTests: XCTestCase { - func testNintendoDescriptorMatchesOnlyNintendoProController() { - let descriptor = NintendoHIDDriverDescriptor() - XCTAssertEqual( - descriptor.matchingCriteria, - [.vendorProduct(vendorID: 0x057E, productID: 0x2009)] - ) - XCTAssertEqual(CFArrayGetCount(descriptor.matchingCFArray), 1) - } - - func testEightBitDoDescriptorCoversDInputPadsWithMissingHomeButton() { - let descriptor = EightBitDoDInputHIDDriverDescriptor() - XCTAssertEqual( - Set(descriptor.matchingCriteria), - Set([ - .vendorProduct(vendorID: 0x2DC8, productID: 0x9020), - .vendorProduct(vendorID: 0x2DC8, productID: 0x3230), - .vendorProduct(vendorID: 0x2DC8, productID: 0x5112), - ]) - ) - XCTAssertEqual(CFArrayGetCount(descriptor.matchingCFArray), 3) - } - - func testGenericDescriptorKeepsKnownPairsBeforeBroadFallbacks() { - let descriptor = GenericHIDDriverDescriptor( - knownVendorProductPairs: [ - (vendorID: 0x1234, productID: 0x0001), - (vendorID: 0x5678, productID: 0x0002), - ] - ) - - XCTAssertEqual( - Array(descriptor.matchingCriteria.prefix(2)), - [ - .vendorProduct(vendorID: 0x1234, productID: 0x0001), - .vendorProduct(vendorID: 0x5678, productID: 0x0002), - ] - ) - XCTAssertTrue(descriptor.matchingCriteria.contains(.transport("BluetoothLowEnergy"))) - XCTAssertTrue(descriptor.matchingCriteria.contains(.transport("Bluetooth Low Energy"))) - XCTAssertEqual(CFArrayGetCount(descriptor.matchingCFArray), descriptor.matchingCriteria.count) - } - - func testGenericDescriptorExcludesSpecializedRawBackendsFromKnownPairs() { - XCTAssertTrue(GenericHIDDriverDescriptor.excludedVendorIDs.contains(0x045E)) - XCTAssertTrue(GenericHIDDriverDescriptor.excludedVendorIDs.contains(0x054C)) - XCTAssertTrue(GenericHIDDriverDescriptor.excludedVendorIDs.contains(0x057E)) - XCTAssertTrue(GenericHIDDriverDescriptor.excludedVendorIDs.contains(SteamControllerHIDParser.valveVendorID)) - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/JoystickAndMouseMappingTests.swift b/XboxControllerMapper/XboxControllerMapperTests/JoystickAndMouseMappingTests.swift index 6866d9fc..51fc1a72 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/JoystickAndMouseMappingTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/JoystickAndMouseMappingTests.swift @@ -27,52 +27,6 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { }, "Mouse movement should be generated from joystick input") } } - - func testAnalogPrecisionTriggerScalesJoystickMouseMovement() async throws { - await MainActor.run { - var settings = JoystickSettings() - settings.leftStick.mode = .mouse - settings.leftStick.mouseDeadzone = 0.05 - settings.leftStick.mouseAcceleration = 0.0 - settings.leftStick.mouseSensitivity = 0.5 - settings.analogPrecisionTriggerMode = .right - settings.analogPrecisionMinimumSpeed = 0.25 - settings.analogPrecisionDeadzone = 0.0 - settings.analogPrecisionCurve = 0.0 - - let profile = Profile(name: "Analog Precision", buttonMappings: [:], joystickSettings: settings) - profileManager.setActiveProfile(profile) - controllerService.isConnected = true - } - await waitForTasks(0.15) - - controllerService.updateRightTrigger(0.0, pressed: false) - mockInputSimulator.clearEvents() - controllerService.setLeftStickForTesting(CGPoint(x: 0.8, y: 0.0)) - await waitForTasks(0.35) - let normal = averageRecentMouseDeltaX() - - controllerService.updateRightTrigger(1.0, pressed: false) - mockInputSimulator.clearEvents() - await waitForTasks(0.35) - let precise = averageRecentMouseDeltaX() - - XCTAssertGreaterThan(normal, 0.1) - XCTAssertGreaterThan(precise, 0.1) - XCTAssertLessThan(precise, normal * 0.6) - } - - private func averageRecentMouseDeltaX(limit: Int = 8) -> CGFloat { - let deltas = mockInputSimulator.events.compactMap { event -> CGFloat? in - if case .moveMouse(let dx, _) = event { - return abs(dx) - } - return nil - } - let recent = Array(deltas.suffix(limit)) - XCTAssertFalse(recent.isEmpty, "Expected mouse movement events") - return recent.reduce(0, +) / CGFloat(max(recent.count, 1)) - } // MARK: - Joystick Processing Tests (High Priority) @@ -120,7 +74,7 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testJoystickInvertedY() async throws { await MainActor.run { var profile = Profile(name: "InvertY", buttonMappings: [:]) - profile.joystickSettings.leftStick.invertMouseY = true + profile.joystickSettings.invertMouseY = true profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -452,13 +406,13 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { profileManager.setActiveProfile(profile) var settings = JoystickSettings.default - settings.leftStick.mouseSensitivity = 0.8 - settings.leftStick.invertMouseY = true + settings.mouseSensitivity = 0.8 + settings.invertMouseY = true profileManager.updateJoystickSettings(settings) - XCTAssertEqual(profileManager.activeProfile?.joystickSettings.leftStick.mouseSensitivity, 0.8) - XCTAssertTrue(profileManager.activeProfile?.joystickSettings.leftStick.invertMouseY ?? false) + XCTAssertEqual(profileManager.activeProfile?.joystickSettings.mouseSensitivity, 0.8) + XCTAssertTrue(profileManager.activeProfile?.joystickSettings.invertMouseY ?? false) } } @@ -468,8 +422,8 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testLeftStickWASDModeUpDirection() async throws { await MainActor.run { var profile = Profile(name: "WASD", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.15 + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.15 profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -493,8 +447,8 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testLeftStickWASDModeDiagonal() async throws { await MainActor.run { var profile = Profile(name: "WASD", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.15 + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.15 profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -515,8 +469,8 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testLeftStickWASDModeReleaseOnCenter() async throws { await MainActor.run { var profile = Profile(name: "WASD", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.15 + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.15 profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -547,8 +501,8 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testLeftStickWASDModeDeadzoneRespected() async throws { await MainActor.run { var profile = Profile(name: "WASD", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.3 // Higher deadzone + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.3 // Higher deadzone profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -571,15 +525,15 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testRightStickArrowKeysMode() async throws { await MainActor.run { var profile = Profile(name: "Arrows", buttonMappings: [:]) - profile.joystickSettings.rightStick.mode = .arrowKeys - profile.joystickSettings.rightStick.scrollDeadzone = 0.15 + profile.joystickSettings.rightStickMode = .arrowKeys + profile.joystickSettings.scrollDeadzone = 0.15 profileManager.setActiveProfile(profile) controllerService.isConnected = true } try? await Task.sleep(nanoseconds: 50_000_000) await MainActor.run { - XCTAssertEqual(profileManager.activeProfile?.joystickSettings.rightStick.mode, .arrowKeys) + XCTAssertEqual(profileManager.activeProfile?.joystickSettings.rightStickMode, .arrowKeys) } } @@ -587,8 +541,8 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testEngineDisableReleasesDirectionKeys() async throws { await MainActor.run { var profile = Profile(name: "WASD", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.15 + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.15 profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -613,15 +567,15 @@ final class JoystickAndMouseMappingTests: MappingEngineTestCase { func testStickModeSettingPersistence() async throws { await MainActor.run { var profile = Profile(name: "ModeTest", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.rightStick.mode = .arrowKeys + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.rightStickMode = .arrowKeys profileManager.setActiveProfile(profile) } try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - XCTAssertEqual(profileManager.activeProfile?.joystickSettings.leftStick.mode, .wasdKeys) - XCTAssertEqual(profileManager.activeProfile?.joystickSettings.rightStick.mode, .arrowKeys) + XCTAssertEqual(profileManager.activeProfile?.joystickSettings.leftStickMode, .wasdKeys) + XCTAssertEqual(profileManager.activeProfile?.joystickSettings.rightStickMode, .arrowKeys) } } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/JoystickCustomDirectionMappingTests.swift b/XboxControllerMapper/XboxControllerMapperTests/JoystickCustomDirectionMappingTests.swift index 9d2852a8..84510a09 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/JoystickCustomDirectionMappingTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/JoystickCustomDirectionMappingTests.swift @@ -38,7 +38,9 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { await waitForTasks(0.1) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil controllerService?.cleanup() mappingEngine = nil @@ -131,8 +133,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { func testChordSequenceDirectionButtonsAreAvailableOnlyForCustomMode() { var customSettings = JoystickSettings.default - customSettings.leftStick.mode = .custom - customSettings.rightStick.mode = .custom + customSettings.leftStickMode = .custom + customSettings.rightStickMode = .custom XCTAssertEqual( customSettings.chordSequenceJoystickDirectionButtons(side: .left), @@ -147,8 +149,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { for mode in [StickMode.none, .mouse, .scroll, .wasdKeys, .arrowKeys] { var settings = JoystickSettings.default - settings.leftStick.mode = mode - settings.rightStick.mode = mode + settings.leftStickMode = mode + settings.rightStickMode = mode XCTAssertTrue(settings.chordSequenceJoystickDirectionButtons(side: .left).isEmpty) XCTAssertTrue(settings.chordSequenceJoystickDirectionButtons(side: .right).isEmpty) @@ -158,37 +160,37 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { func testDefaultCustomTuningLeavesUsableCardinalSlicesAndDiagonalGaps() { let settings = JoystickSettings.default - XCTAssertEqual(settings.leftStick.customHorizontalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.leftStick.customVerticalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.leftStick.customDeadzone, 0.22, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.customHorizontalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.customVerticalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.customDeadzone, 0.22, accuracy: 0.0001) - XCTAssertEqual(settings.leftStick.mouseDeadzone, 0.15, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.scrollDeadzone, 0.15, accuracy: 0.0001) + XCTAssertEqual(settings.leftStickCustomHorizontalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.leftStickCustomVerticalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.leftStickCustomDeadzone, 0.22, accuracy: 0.0001) + XCTAssertEqual(settings.rightStickCustomHorizontalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.rightStickCustomVerticalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.rightStickCustomDeadzone, 0.22, accuracy: 0.0001) + XCTAssertEqual(settings.mouseDeadzone, 0.15, accuracy: 0.0001) + XCTAssertEqual(settings.scrollDeadzone, 0.15, accuracy: 0.0001) XCTAssertEqual( - JoystickDirectionResolver.activeButtons(stick: CGPoint(x: 0, y: 0.9), side: .left, tuning: settings.leftStick), + JoystickDirectionResolver.activeButtons(stick: CGPoint(x: 0, y: 0.9), side: .left, settings: settings), [.leftStickUp], "Default custom tuning should still make clear cardinal input easy" ) XCTAssertEqual( - JoystickDirectionResolver.activeButtons(stick: CGPoint(x: 0.8, y: 0.8), side: .left, tuning: settings.leftStick), + JoystickDirectionResolver.activeButtons(stick: CGPoint(x: 0.8, y: 0.8), side: .left, settings: settings), [], "Default custom tuning should leave a diagonal deadzone between slices" ) XCTAssertEqual( - JoystickDirectionResolver.activeButtons(stick: CGPoint(x: 0.18, y: 0), side: .left, tuning: settings.leftStick), + JoystickDirectionResolver.activeButtons(stick: CGPoint(x: 0.18, y: 0), side: .left, settings: settings), [], "Default custom tuning should ignore small center drift" ) XCTAssertEqual( JoystickDirectionResolver.activeDirections( stick: CGPoint(x: 0, y: 0.9), - deadzone: settings.leftStick.customDeadzone, - horizontalSliceSize: settings.leftStick.customHorizontalSliceSize, - verticalSliceSize: settings.leftStick.customVerticalSliceSize, - invertY: settings.leftStick.invertMouseY + deadzone: settings.leftStickCustomDeadzone, + horizontalSliceSize: settings.leftStickCustomHorizontalSliceSize, + verticalSliceSize: settings.leftStickCustomVerticalSliceSize, + invertY: settings.invertMouseY ), [.up], "Default custom tuning should still make clear cardinal input easy" @@ -198,12 +200,12 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { func testDecodedCustomTuningUsesNewDefaultsWhenFieldsAreMissing() throws { let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data("{}".utf8)) - XCTAssertEqual(settings.leftStick.customHorizontalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.leftStick.customVerticalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.leftStick.customDeadzone, 0.22, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.customHorizontalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.customVerticalSliceSize, 0.75, accuracy: 0.0001) - XCTAssertEqual(settings.rightStick.customDeadzone, 0.22, accuracy: 0.0001) + XCTAssertEqual(settings.leftStickCustomHorizontalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.leftStickCustomVerticalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.leftStickCustomDeadzone, 0.22, accuracy: 0.0001) + XCTAssertEqual(settings.rightStickCustomHorizontalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.rightStickCustomVerticalSliceSize, 0.75, accuracy: 0.0001) + XCTAssertEqual(settings.rightStickCustomDeadzone, 0.22, accuracy: 0.0001) } func testCustomLeftStickDirectionStartsAndStopsHoldMapping() async throws { @@ -211,8 +213,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { var profile = Profile(name: "Custom Left Stick", buttonMappings: [ .leftStickUp: holdMapping(10) ]) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -243,8 +245,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { StickDirectionPreset.wasd.apply(to: &mappings, side: .left) var profile = Profile(name: "Custom WASD Movement", buttonMappings: mappings) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.customDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.leftStickCustomDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -273,8 +275,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { ] var profile = Profile(name: "Custom IJKL Movement", buttonMappings: mappings) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.customDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.leftStickCustomDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -301,9 +303,9 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { .leftStickUp: KeyMapping(keyCode: KeyCodeMapping.scrollUp), .leftStickLeft: KeyMapping(keyCode: KeyCodeMapping.scrollLeft) ]) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.customDeadzone = 0.1 - profile.joystickSettings.leftStick.scrollAcceleration = 0 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.leftStickCustomDeadzone = 0.1 + profile.joystickSettings.scrollAcceleration = 0 installActiveProfile(profile) controllerService.isConnected = true } @@ -343,10 +345,10 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { var lowSensitivityProfile = Profile(name: "Low Scroll Sensitivity", buttonMappings: [ .leftStickUp: KeyMapping(keyCode: KeyCodeMapping.scrollUp) ]) - lowSensitivityProfile.joystickSettings.leftStick.mode = .custom - lowSensitivityProfile.joystickSettings.leftStick.customDeadzone = 0.1 - lowSensitivityProfile.joystickSettings.leftStick.scrollSensitivity = 0 - lowSensitivityProfile.joystickSettings.leftStick.scrollAcceleration = 0 + lowSensitivityProfile.joystickSettings.leftStickMode = .custom + lowSensitivityProfile.joystickSettings.leftStickCustomDeadzone = 0.1 + lowSensitivityProfile.joystickSettings.scrollSensitivity = 0 + lowSensitivityProfile.joystickSettings.scrollAcceleration = 0 installActiveProfile(lowSensitivityProfile) controllerService.isConnected = true } @@ -368,10 +370,10 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { var highSensitivityProfile = Profile(name: "High Scroll Sensitivity", buttonMappings: [ .leftStickUp: KeyMapping(keyCode: KeyCodeMapping.scrollUp) ]) - highSensitivityProfile.joystickSettings.leftStick.mode = .custom - highSensitivityProfile.joystickSettings.leftStick.customDeadzone = 0.1 - highSensitivityProfile.joystickSettings.leftStick.scrollSensitivity = 1 - highSensitivityProfile.joystickSettings.leftStick.scrollAcceleration = 0 + highSensitivityProfile.joystickSettings.leftStickMode = .custom + highSensitivityProfile.joystickSettings.leftStickCustomDeadzone = 0.1 + highSensitivityProfile.joystickSettings.scrollSensitivity = 1 + highSensitivityProfile.joystickSettings.scrollAcceleration = 0 installActiveProfile(highSensitivityProfile) } await waitForTasks(0.12) @@ -395,10 +397,10 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { scrollActionSettings: ScrollActionSettings(speed: 1, acceleration: 0) ) ]) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.customDeadzone = 0.1 - profile.joystickSettings.leftStick.scrollSensitivity = 0 - profile.joystickSettings.leftStick.scrollAcceleration = 0 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.leftStickCustomDeadzone = 0.1 + profile.joystickSettings.scrollSensitivity = 0 + profile.joystickSettings.scrollAcceleration = 0 installActiveProfile(profile) controllerService.isConnected = true } @@ -423,8 +425,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { ChordMapping(buttons: [.leftStickUp, .a], keyCode: 42) ] ) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -452,8 +454,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { ChordMapping(buttons: [.leftStickUp, .a], keyCode: 43) ] ) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -485,8 +487,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { SequenceMapping(steps: [.rightStickLeft, .b], keyCode: 44) ] ) - profile.joystickSettings.rightStick.mode = .arrowKeys - profile.joystickSettings.rightStick.scrollDeadzone = 0.1 + profile.joystickSettings.rightStickMode = .arrowKeys + profile.joystickSettings.scrollDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -522,8 +524,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { ChordMapping(buttons: [.leftStickUp, .a], keyCode: 45) ] ) - profile.joystickSettings.leftStick.mode = .mouse - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .mouse + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -551,10 +553,10 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { .leftStickUp: holdMapping(10), .leftStickRight: holdMapping(11) ]) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.customHorizontalSliceSize = 0.6 - profile.joystickSettings.leftStick.customVerticalSliceSize = 0.6 - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.leftStickCustomHorizontalSliceSize = 0.6 + profile.joystickSettings.leftStickCustomVerticalSliceSize = 0.6 + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -579,10 +581,10 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { .leftStickUp: holdMapping(10), .leftStickRight: holdMapping(11) ]) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.customHorizontalSliceSize = 0.2 - profile.joystickSettings.leftStick.customVerticalSliceSize = 1.0 - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.leftStickCustomHorizontalSliceSize = 0.2 + profile.joystickSettings.leftStickCustomVerticalSliceSize = 1.0 + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -607,8 +609,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { .leftStickUp: holdMapping(10), .leftStickRight: holdMapping(11) ]) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -648,8 +650,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { buttonMappings: [.leftStickUp: holdMapping(20)], layers: [layer] ) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } @@ -680,8 +682,8 @@ final class JoystickCustomDirectionMappingTests: XCTestCase { buttonMappings: [.a: .key(31)], layers: [layer] ) - profile.joystickSettings.leftStick.mode = .custom - profile.joystickSettings.leftStick.mouseDeadzone = 0.1 + profile.joystickSettings.leftStickMode = .custom + profile.joystickSettings.mouseDeadzone = 0.1 installActiveProfile(profile) controllerService.isConnected = true } diff --git a/XboxControllerMapper/XboxControllerMapperTests/JoystickMathTests.swift b/XboxControllerMapper/XboxControllerMapperTests/JoystickMathTests.swift index 87c33b48..ebe75f6a 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/JoystickMathTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/JoystickMathTests.swift @@ -57,14 +57,14 @@ final class JoystickMathTests: XCTestCase { func testMouseAccelerationExponentMapping() { var settings = JoystickSettings() - settings.leftStick.mouseAcceleration = 0.0 - XCTAssertEqual(settings.leftStick.mouseAccelerationExponent, 1.0, "Acceleration 0 -> exponent 1.0 (linear)") + settings.mouseAcceleration = 0.0 + XCTAssertEqual(settings.mouseAccelerationExponent, 1.0, "Acceleration 0 -> exponent 1.0 (linear)") - settings.leftStick.mouseAcceleration = 0.5 - XCTAssertEqual(settings.leftStick.mouseAccelerationExponent, 2.0, "Acceleration 0.5 -> exponent 2.0 (quadratic)") + settings.mouseAcceleration = 0.5 + XCTAssertEqual(settings.mouseAccelerationExponent, 2.0, "Acceleration 0.5 -> exponent 2.0 (quadratic)") - settings.leftStick.mouseAcceleration = 1.0 - XCTAssertEqual(settings.leftStick.mouseAccelerationExponent, 3.0, "Acceleration 1.0 -> exponent 3.0 (cubic)") + settings.mouseAcceleration = 1.0 + XCTAssertEqual(settings.mouseAccelerationExponent, 3.0, "Acceleration 1.0 -> exponent 3.0 (cubic)") } func testMouseAccelerationCurveAtKeyPoints() { @@ -82,37 +82,37 @@ final class JoystickMathTests: XCTestCase { func testScrollAccelerationExponentMapping() { var settings = JoystickSettings() - settings.rightStick.scrollAcceleration = 0.0 - XCTAssertEqual(settings.rightStick.scrollAccelerationExponent, 1.0, "Scroll acceleration 0 -> exponent 1.0 (linear)") + settings.scrollAcceleration = 0.0 + XCTAssertEqual(settings.scrollAccelerationExponent, 1.0, "Scroll acceleration 0 -> exponent 1.0 (linear)") - settings.rightStick.scrollAcceleration = 0.5 - XCTAssertEqual(settings.rightStick.scrollAccelerationExponent, 1.75, "Scroll acceleration 0.5 -> exponent 1.75") + settings.scrollAcceleration = 0.5 + XCTAssertEqual(settings.scrollAccelerationExponent, 1.75, "Scroll acceleration 0.5 -> exponent 1.75") - settings.rightStick.scrollAcceleration = 1.0 - XCTAssertEqual(settings.rightStick.scrollAccelerationExponent, 2.5, "Scroll acceleration 1.0 -> exponent 2.5") + settings.scrollAcceleration = 1.0 + XCTAssertEqual(settings.scrollAccelerationExponent, 2.5, "Scroll acceleration 1.0 -> exponent 2.5") } // MARK: - Mouse Multiplier Tests func testMouseMultiplierAtZero() { var settings = JoystickSettings() - settings.leftStick.mouseSensitivity = 0.0 - XCTAssertEqual(settings.leftStick.mouseMultiplier, 2.0, accuracy: 1e-10, + settings.mouseSensitivity = 0.0 + XCTAssertEqual(settings.mouseMultiplier, 2.0, accuracy: 1e-10, "Minimum sensitivity should give minimum multiplier of 2.0") } func testMouseMultiplierAtHalf() { var settings = JoystickSettings() - settings.leftStick.mouseSensitivity = 0.5 + settings.mouseSensitivity = 0.5 // pow(0.5, 3) = 0.125, 2.0 + 0.125 * 118.0 = 16.75 - XCTAssertEqual(settings.leftStick.mouseMultiplier, 16.75, accuracy: 1e-10, + XCTAssertEqual(settings.mouseMultiplier, 16.75, accuracy: 1e-10, "Mid sensitivity should use cubic mapping: 2 + 0.125 * 118 = 16.75") } func testMouseMultiplierAtFull() { var settings = JoystickSettings() - settings.leftStick.mouseSensitivity = 1.0 - XCTAssertEqual(settings.leftStick.mouseMultiplier, 120.0, accuracy: 1e-10, + settings.mouseSensitivity = 1.0 + XCTAssertEqual(settings.mouseMultiplier, 120.0, accuracy: 1e-10, "Maximum sensitivity should give maximum multiplier of 120.0") } @@ -120,22 +120,22 @@ final class JoystickMathTests: XCTestCase { func testScrollMultiplierAtZero() { var settings = JoystickSettings() - settings.rightStick.scrollSensitivity = 0.0 - XCTAssertEqual(settings.rightStick.scrollMultiplier, 1.0, accuracy: 1e-10, + settings.scrollSensitivity = 0.0 + XCTAssertEqual(settings.scrollMultiplier, 1.0, accuracy: 1e-10, "Minimum scroll sensitivity should give multiplier of 1.0") } func testScrollMultiplierAtHalf() { var settings = JoystickSettings() - settings.rightStick.scrollSensitivity = 0.5 + settings.scrollSensitivity = 0.5 let expected = 1.0 + pow(0.5, 1.5) * 29.0 - XCTAssertEqual(settings.rightStick.scrollMultiplier, expected, accuracy: 1e-10) + XCTAssertEqual(settings.scrollMultiplier, expected, accuracy: 1e-10) } func testScrollMultiplierAtFull() { var settings = JoystickSettings() - settings.rightStick.scrollSensitivity = 1.0 - XCTAssertEqual(settings.rightStick.scrollMultiplier, 30.0, accuracy: 1e-10, + settings.scrollSensitivity = 1.0 + XCTAssertEqual(settings.scrollMultiplier, 30.0, accuracy: 1e-10, "Maximum scroll sensitivity should give multiplier of 30.0") } @@ -148,9 +148,9 @@ final class JoystickMathTests: XCTestCase { let stickY = 0.0 var settings = JoystickSettings() - settings.leftStick.mouseSensitivity = 0.5 - settings.leftStick.mouseAcceleration = 0.5 - settings.leftStick.mouseDeadzone = deadzone + settings.mouseSensitivity = 0.5 + settings.mouseAcceleration = 0.5 + settings.mouseDeadzone = deadzone guard let magnitude = JoystickMath.circularDeadzone(x: stickX, y: stickY, deadzone: deadzone) else { XCTFail("Input should be outside deadzone") @@ -158,8 +158,8 @@ final class JoystickMathTests: XCTestCase { } let normalizedMag = JoystickMath.normalizedMagnitude(magnitude, deadzone: deadzone) - let acceleratedMag = pow(normalizedMag, settings.leftStick.mouseAccelerationExponent) - let scale = acceleratedMag * settings.leftStick.mouseMultiplier / magnitude + let acceleratedMag = pow(normalizedMag, settings.mouseAccelerationExponent) + let scale = acceleratedMag * settings.mouseMultiplier / magnitude let dx = stickX * scale XCTAssertEqual(magnitude, 0.7, accuracy: 1e-10) @@ -529,13 +529,13 @@ final class JoystickMathTests: XCTestCase { {"mouseDeadzone": 1.0, "scrollDeadzone": 1.0} """.data(using: .utf8)! let settings = try! JSONDecoder().decode(JoystickSettings.self, from: json) - XCTAssertLessThanOrEqual(settings.leftStick.mouseDeadzone, 0.99, + XCTAssertLessThanOrEqual(settings.mouseDeadzone, 0.99, "mouseDeadzone must be clamped below 1.0 to prevent division by zero") - XCTAssertLessThanOrEqual(settings.rightStick.scrollDeadzone, 0.99, + XCTAssertLessThanOrEqual(settings.scrollDeadzone, 0.99, "scrollDeadzone must be clamped below 1.0 to prevent division by zero") // Verify normalizedMagnitude produces a finite result at the clamped max - let result = JoystickMath.normalizedMagnitude(1.0, deadzone: settings.leftStick.mouseDeadzone) + let result = JoystickMath.normalizedMagnitude(1.0, deadzone: settings.mouseDeadzone) XCTAssertTrue(result.isFinite, "normalizedMagnitude must be finite at max clamped deadzone") } @@ -546,8 +546,8 @@ final class JoystickMathTests: XCTestCase { // NaN string will fail to decode as Double, falling back to default // Negative value will be clamped to 0.0 if let settings = try? JSONDecoder().decode(JoystickSettings.self, from: json) { - XCTAssertTrue(settings.leftStick.mouseDeadzone.isFinite) - XCTAssertGreaterThanOrEqual(settings.rightStick.scrollDeadzone, 0.0) + XCTAssertTrue(settings.mouseDeadzone.isFinite) + XCTAssertGreaterThanOrEqual(settings.scrollDeadzone, 0.0) } } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/JoystickSettingsPointerLockCodableTests.swift b/XboxControllerMapper/XboxControllerMapperTests/JoystickSettingsPointerLockCodableTests.swift deleted file mode 100644 index 9d383e68..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/JoystickSettingsPointerLockCodableTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -import XCTest -@testable import ControllerKeys - -final class JoystickSettingsPointerLockCodableTests: XCTestCase { - - func testDefaultIsAuto() { - XCTAssertEqual(JoystickSettings().pointerLockMouseMode, .auto) - XCTAssertEqual(JoystickSettings.default.pointerLockMouseMode, .auto) - } - - func testDecodingLegacyJSONWithoutKey_defaultsToAuto() throws { - let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data("{}".utf8)) - XCTAssertEqual(settings.pointerLockMouseMode, .auto, - "Profiles saved by older builds must gain auto mode") - } - - func testEncodeDecodeRoundTrip_allModes() throws { - for mode in PointerLockMouseMode.allCases { - var settings = JoystickSettings() - settings.pointerLockMouseMode = mode - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(JoystickSettings.self, from: data) - XCTAssertEqual(decoded.pointerLockMouseMode, mode) - } - } - - func testUnknownRawValue_degradesToAuto() throws { - let json = #"{"pointerLockMouseMode": "hyperspace"}"# - let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data(json.utf8)) - XCTAssertEqual(settings.pointerLockMouseMode, .auto, - "A mode from a newer build must not throw out the whole profile on downgrade") - } - - func testValidationUnaffectedByMode() { - var settings = JoystickSettings() - settings.pointerLockMouseMode = .always - XCTAssertTrue(settings.isValid()) - } -} - -final class JoystickSettingsAnalogPrecisionCodableTests: XCTestCase { - - func testDefaultIsOffAndLegacyDecodeKeepsNormalSpeed() throws { - let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data("{}".utf8)) - - XCTAssertEqual(settings.analogPrecisionTriggerMode, .off) - XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 1.0, rightTrigger: 1.0), 1.0) - } - - func testEncodeDecodeRoundTripAllTriggerModes() throws { - for mode in AnalogPrecisionTriggerMode.allCases { - var settings = JoystickSettings() - settings.analogPrecisionTriggerMode = mode - settings.analogPrecisionMinimumSpeed = 0.25 - settings.analogPrecisionDeadzone = 0.1 - settings.analogPrecisionCurve = 0.75 - - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(JoystickSettings.self, from: data) - - XCTAssertEqual(decoded.analogPrecisionTriggerMode, mode) - XCTAssertEqual(decoded.analogPrecisionMinimumSpeed, 0.25) - XCTAssertEqual(decoded.analogPrecisionDeadzone, 0.1) - XCTAssertEqual(decoded.analogPrecisionCurve, 0.75) - } - } - - func testUnknownTriggerModeDegradesToOff() throws { - let json = #"{"analogPrecisionTriggerMode": "pressurePlate"}"# - let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data(json.utf8)) - - XCTAssertEqual(settings.analogPrecisionTriggerMode, .off) - } - - func testAnalogPrecisionMultiplierUsesSelectedTrigger() { - var settings = JoystickSettings() - settings.analogPrecisionTriggerMode = .left - settings.analogPrecisionMinimumSpeed = 0.2 - settings.analogPrecisionDeadzone = 0.1 - settings.analogPrecisionCurve = 0.0 - - XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 0.1, rightTrigger: 1.0), 1.0) - XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 1.0, rightTrigger: 0.0), 0.2, accuracy: 1e-10) - - settings.analogPrecisionTriggerMode = .right - XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 1.0, rightTrigger: 0.1), 1.0) - XCTAssertEqual(settings.analogPrecisionMultiplier(leftTrigger: 0.0, rightTrigger: 1.0), 0.2, accuracy: 1e-10) - } - - func testEitherTriggerUsesDeeperPullAndIsMonotonic() { - var settings = JoystickSettings() - settings.analogPrecisionTriggerMode = .either - settings.analogPrecisionMinimumSpeed = 0.15 - settings.analogPrecisionDeadzone = 0.0 - settings.analogPrecisionCurve = 0.35 - - let shallow = settings.analogPrecisionMultiplier(leftTrigger: 0.2, rightTrigger: 0.1) - let medium = settings.analogPrecisionMultiplier(leftTrigger: 0.2, rightTrigger: 0.5) - let full = settings.analogPrecisionMultiplier(leftTrigger: 0.0, rightTrigger: 1.0) - - XCTAssertGreaterThan(shallow, medium) - XCTAssertGreaterThan(medium, full) - XCTAssertEqual(full, 0.15, accuracy: 1e-10) - } - - func testDecodingClampsAnalogPrecisionRanges() throws { - let json = """ - { - "analogPrecisionTriggerMode": "either", - "analogPrecisionMinimumSpeed": 0.01, - "analogPrecisionDeadzone": 2.0, - "analogPrecisionCurve": -1.0 - } - """ - - let settings = try JSONDecoder().decode(JoystickSettings.self, from: Data(json.utf8)) - - XCTAssertEqual(settings.analogPrecisionMinimumSpeed, 0.05) - XCTAssertEqual(settings.analogPrecisionDeadzone, 0.95) - XCTAssertEqual(settings.analogPrecisionCurve, 0.0) - XCTAssertTrue(settings.isValid()) - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/KeychainServiceTests.swift b/XboxControllerMapper/XboxControllerMapperTests/KeychainServiceTests.swift index 2ffe3aeb..2a4e7d50 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/KeychainServiceTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/KeychainServiceTests.swift @@ -20,10 +20,9 @@ final class KeychainServiceTests: XCTestCase { return key } - func testStoreAndRetrieve() throws { - try requireWritableKeychain() - let key = makeTestKey() - let password = "my-secret-password" + func testStoreAndRetrieve() { + let key = makeTestKey() + let password = "my-secret-password" let result = KeychainService.storePassword(password, key: key) XCTAssertNotNil(result, "storePassword should return the key on success") @@ -37,10 +36,9 @@ final class KeychainServiceTests: XCTestCase { XCTAssertNil(retrieved) } - func testDeletePassword() throws { - try requireWritableKeychain() - let key = makeTestKey() - XCTAssertNotNil(KeychainService.storePassword("to-delete", key: key)) + func testDeletePassword() { + let key = makeTestKey() + KeychainService.storePassword("to-delete", key: key) KeychainService.deletePassword(key: key) @@ -48,11 +46,10 @@ final class KeychainServiceTests: XCTestCase { XCTAssertNil(retrieved, "Password should be nil after deletion") } - func testOverwrite() throws { - try requireWritableKeychain() - let key = makeTestKey() - XCTAssertNotNil(KeychainService.storePassword("original", key: key)) - XCTAssertNotNil(KeychainService.storePassword("updated", key: key)) + func testOverwrite() { + let key = makeTestKey() + KeychainService.storePassword("original", key: key) + KeychainService.storePassword("updated", key: key) let retrieved = KeychainService.retrievePassword(key: key) XCTAssertEqual(retrieved, "updated") @@ -142,16 +139,4 @@ final class KeychainServiceTests: XCTestCase { let data = (dictionary as NSDictionary)[kSecValueData as String] as? Data return data.flatMap { String(data: $0, encoding: .utf8) } } - - private func requireWritableKeychain() throws { - let probeKey = makeTestKey() - let probePassword = "probe-\(UUID().uuidString)" - let storedKey = KeychainService.storePassword(probePassword, key: probeKey) - let retrieved = KeychainService.retrievePassword(key: probeKey) - - guard storedKey == probeKey, retrieved == probePassword else { - KeychainService.deletePassword(key: probeKey) - throw XCTSkip("Writable keychain unavailable in this test session") - } - } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/LayerAndConfigCoverageTests.swift b/XboxControllerMapper/XboxControllerMapperTests/LayerAndConfigCoverageTests.swift index 219fd63f..a37b251e 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/LayerAndConfigCoverageTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/LayerAndConfigCoverageTests.swift @@ -109,18 +109,18 @@ final class LayerAndConfigCoverageTests: XCTestCase { func testLegacyStickKeyModesDecodeAsCustomVirtualMappings() throws { var legacy = Profile(name: "Legacy Stick Modes") - legacy.joystickSettings.leftStick.mode = .wasdKeys - legacy.joystickSettings.rightStick.mode = .arrowKeys - legacy.joystickSettings.leftStick.mouseDeadzone = 0.27 - legacy.joystickSettings.rightStick.scrollDeadzone = 0.31 + legacy.joystickSettings.leftStickMode = .wasdKeys + legacy.joystickSettings.rightStickMode = .arrowKeys + legacy.joystickSettings.mouseDeadzone = 0.27 + legacy.joystickSettings.scrollDeadzone = 0.31 let data = try JSONEncoder().encode(legacy) let decoded = try JSONDecoder().decode(Profile.self, from: data) - XCTAssertEqual(decoded.joystickSettings.leftStick.mode, .custom) - XCTAssertEqual(decoded.joystickSettings.rightStick.mode, .custom) - XCTAssertEqual(decoded.joystickSettings.leftStick.customDeadzone, 0.27, accuracy: 0.0001) - XCTAssertEqual(decoded.joystickSettings.rightStick.customDeadzone, 0.31, accuracy: 0.0001) + XCTAssertEqual(decoded.joystickSettings.leftStickMode, .custom) + XCTAssertEqual(decoded.joystickSettings.rightStickMode, .custom) + XCTAssertEqual(decoded.joystickSettings.leftStickCustomDeadzone, 0.27, accuracy: 0.0001) + XCTAssertEqual(decoded.joystickSettings.rightStickCustomDeadzone, 0.31, accuracy: 0.0001) assertStickMapping(decoded.buttonMappings[.leftStickUp], keyCode: KeyCodeMapping.keyW) assertStickMapping(decoded.buttonMappings[.leftStickLeft], keyCode: KeyCodeMapping.keyA) @@ -135,7 +135,7 @@ final class LayerAndConfigCoverageTests: XCTestCase { func testLegacyStickKeyModeMigrationDoesNotOverwriteExistingVirtualMapping() throws { var legacy = Profile(name: "Legacy Stick Custom") - legacy.joystickSettings.leftStick.mode = .wasdKeys + legacy.joystickSettings.leftStickMode = .wasdKeys legacy.buttonMappings[.leftStickUp] = KeyMapping( keyCode: KeyCodeMapping.keyQ, longHoldMapping: LongHoldMapping(keyCode: KeyCodeMapping.space) @@ -144,7 +144,7 @@ final class LayerAndConfigCoverageTests: XCTestCase { let data = try JSONEncoder().encode(legacy) let decoded = try JSONDecoder().decode(Profile.self, from: data) - XCTAssertEqual(decoded.joystickSettings.leftStick.mode, .custom) + XCTAssertEqual(decoded.joystickSettings.leftStickMode, .custom) XCTAssertEqual(decoded.buttonMappings[.leftStickUp]?.keyCode, KeyCodeMapping.keyQ) XCTAssertEqual(decoded.buttonMappings[.leftStickUp]?.longHoldMapping?.keyCode, KeyCodeMapping.space) assertStickMapping(decoded.buttonMappings[.leftStickLeft], keyCode: KeyCodeMapping.keyA) diff --git a/XboxControllerMapper/XboxControllerMapperTests/LayerStickModeOverrideTests.swift b/XboxControllerMapper/XboxControllerMapperTests/LayerStickModeOverrideTests.swift index 9f2b904d..2965645a 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/LayerStickModeOverrideTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/LayerStickModeOverrideTests.swift @@ -26,43 +26,43 @@ final class LayerStickModeOverrideTests: XCTestCase { profileManager.setStickMode(.scroll, side: .left, layerId: nil) let active = try XCTUnwrap(profileManager.activeProfile) - XCTAssertEqual(active.joystickSettings.leftStick.mode, .scroll) + XCTAssertEqual(active.joystickSettings.leftStickMode, .scroll) let stored = try XCTUnwrap(active.layers.first(where: { $0.id == layer.id })) - XCTAssertNil(stored.leftStickTuning, "Profile-level write must not touch layer overrides.") + XCTAssertNil(stored.leftStickModeOverride, "Profile-level write must not touch layer overrides.") } - /// Per-layer write sets the mode override on that layer only. + /// Per-layer write sets the override on that layer only. func testSetStickMode_layerScope_setsOverride() throws { let layer = try XCTUnwrap(profileManager.createLayer(name: "L1", activatorButton: .leftBumper)) - let baseLeftMode = profileManager.activeProfile?.joystickSettings.leftStick.mode + let baseLeftMode = profileManager.activeProfile?.joystickSettings.leftStickMode profileManager.setStickMode(.scroll, side: .left, layerId: layer.id) let active = try XCTUnwrap(profileManager.activeProfile) let stored = try XCTUnwrap(active.layers.first(where: { $0.id == layer.id })) - XCTAssertEqual(stored.leftStickTuning?.mode, .scroll) - XCTAssertEqual(active.joystickSettings.leftStick.mode, baseLeftMode, + XCTAssertEqual(stored.leftStickModeOverride, .scroll) + XCTAssertEqual(active.joystickSettings.leftStickMode, baseLeftMode, "Layer-scoped write must not mutate profile-level mode.") } - /// Clearing a layer mode override (nil mode + non-nil layerId) drops it back to inheriting. + /// Clearing a layer override (nil mode + non-nil layerId) drops it back to inheriting. func testSetStickMode_layerScope_clearOverrideWithNilMode() throws { let layer = try XCTUnwrap(profileManager.createLayer(name: "L1", activatorButton: .leftBumper)) profileManager.setStickMode(.scroll, side: .right, layerId: layer.id) - XCTAssertEqual(profileManager.activeProfile?.layers.first?.rightStickTuning?.mode, .scroll) + XCTAssertEqual(profileManager.activeProfile?.layers.first?.rightStickModeOverride, .scroll) profileManager.setStickMode(nil, side: .right, layerId: layer.id) - XCTAssertNil(profileManager.activeProfile?.layers.first?.rightStickTuning?.mode) + XCTAssertNil(profileManager.activeProfile?.layers.first?.rightStickModeOverride) } /// Profile-level write with nil mode is a no-op (the profile must always have a concrete mode). func testSetStickMode_profileScope_nilModeIsNoop() throws { - let initial = profileManager.activeProfile?.joystickSettings.leftStick.mode + let initial = profileManager.activeProfile?.joystickSettings.leftStickMode profileManager.setStickMode(nil, side: .left, layerId: nil) - XCTAssertEqual(profileManager.activeProfile?.joystickSettings.leftStick.mode, initial) + XCTAssertEqual(profileManager.activeProfile?.joystickSettings.leftStickMode, initial) } /// Two layers can independently override the same side. @@ -76,63 +76,21 @@ final class LayerStickModeOverrideTests: XCTestCase { let stored = try XCTUnwrap(profileManager.activeProfile?.layers) let a = try XCTUnwrap(stored.first(where: { $0.id == layerA.id })) let b = try XCTUnwrap(stored.first(where: { $0.id == layerB.id })) - XCTAssertEqual(a.leftStickTuning?.mode, .scroll) - XCTAssertEqual(b.leftStickTuning?.mode, .mouse) + XCTAssertEqual(a.leftStickModeOverride, .scroll) + XCTAssertEqual(b.leftStickModeOverride, .mouse) } - /// A single tuning field can be overridden without touching the mode, and clearing - /// the only overridden field drops the whole override so the layer fully inherits. - func testSetLayerStickOverride_setsAndClearsSingleField() throws { - let layer = try XCTUnwrap(profileManager.createLayer(name: "L1", activatorButton: .leftBumper)) - - profileManager.setLayerStickOverride(\.mouseSensitivity, 0.9, side: .left, layerId: layer.id) - let stored = try XCTUnwrap(profileManager.activeProfile?.layers.first(where: { $0.id == layer.id })) - XCTAssertEqual(stored.leftStickTuning?.mouseSensitivity, 0.9) - XCTAssertNil(stored.leftStickTuning?.mode, "Only one field is overridden; mode still inherits.") - - profileManager.setLayerStickOverride(\.mouseSensitivity, nil, side: .left, layerId: layer.id) - let cleared = try XCTUnwrap(profileManager.activeProfile?.layers.first(where: { $0.id == layer.id })) - XCTAssertNil(cleared.leftStickTuning, "Clearing the only overridden field drops the whole override.") - } - - /// `clearLayerStickOverride` drops the entire side override regardless of how many fields are set. - func testClearLayerStickOverride_dropsEntireOverride() throws { - let layer = try XCTUnwrap(profileManager.createLayer(name: "L1", activatorButton: .leftBumper)) - profileManager.setStickMode(.scroll, side: .left, layerId: layer.id) - profileManager.setLayerStickOverride(\.mouseSensitivity, 0.9, side: .left, layerId: layer.id) - - profileManager.clearLayerStickOverride(side: .left, layerId: layer.id) - - let stored = try XCTUnwrap(profileManager.activeProfile?.layers.first(where: { $0.id == layer.id })) - XCTAssertNil(stored.leftStickTuning) - } - - /// `applied(to:)` overlays only the fields the override explicitly sets. - func testStickTuningOverride_appliedOverlaysOnlySetFields() { - let base = StickTuning(mode: .mouse, mouseSensitivity: 0.5, mouseDeadzone: 0.15) - var override = StickTuningOverride() - override.mouseSensitivity = 0.9 - - let resolved = override.applied(to: base) - XCTAssertEqual(resolved.mouseSensitivity, 0.9, "Set field is overridden.") - XCTAssertEqual(resolved.mode, .mouse, "Unset mode inherits base.") - XCTAssertEqual(resolved.mouseDeadzone, 0.15, "Unset deadzone inherits base.") - XCTAssertTrue(StickTuningOverride().isEmpty) - XCTAssertFalse(override.isEmpty) - } - - /// A full tuning override survives a Codable round-trip (matters for config persistence). + /// The override field survives a Codable round-trip (matters for config persistence). func testLayer_codableRoundTripPreservesOverrides() throws { var layer = Layer(name: "RT", activatorButton: .leftBumper) - layer.leftStickTuning = StickTuningOverride(mode: .scroll, mouseSensitivity: 0.9) - layer.rightStickTuning = StickTuningOverride(mode: .mouse) + layer.leftStickModeOverride = .scroll + layer.rightStickModeOverride = .mouse let data = try JSONEncoder().encode(layer) let decoded = try JSONDecoder().decode(Layer.self, from: data) - XCTAssertEqual(decoded.leftStickTuning?.mode, .scroll) - XCTAssertEqual(decoded.leftStickTuning?.mouseSensitivity, 0.9) - XCTAssertEqual(decoded.rightStickTuning?.mode, .mouse) + XCTAssertEqual(decoded.leftStickModeOverride, .scroll) + XCTAssertEqual(decoded.rightStickModeOverride, .mouse) } /// Existing configs without the override fields decode with nil overrides (no migration needed). @@ -147,30 +105,13 @@ final class LayerStickModeOverrideTests: XCTestCase { let decoded = try JSONDecoder().decode(Layer.self, from: json) - XCTAssertNil(decoded.leftStickTuning) - XCTAssertNil(decoded.rightStickTuning) - } - - /// A legacy mode-only override (pre per-stick tuning) migrates into the tuning override. - func testLayer_legacyModeOverrideMigratesToTuning() throws { - let json = """ - { - "id": "\(UUID().uuidString)", - "name": "LegacyOverride", - "buttonMappings": {}, - "leftStickModeOverride": "scroll" - } - """.data(using: .utf8)! - - let decoded = try JSONDecoder().decode(Layer.self, from: json) - - XCTAssertEqual(decoded.leftStickTuning?.mode, .scroll, "Legacy mode-only override migrates into the tuning override.") - XCTAssertNil(decoded.rightStickTuning) + XCTAssertNil(decoded.leftStickModeOverride) + XCTAssertNil(decoded.rightStickModeOverride) } - /// An unknown StickMode raw value on a legacy layer override (e.g. a case added by - /// a newer build) must fall back to nil ("inherit"), NOT throw and unwind the whole - /// layer/profile decode on a downgrade. + /// An unknown StickMode raw value on a layer override (e.g. a case added by + /// a newer build) must fall back to nil ("inherit"), NOT throw and unwind + /// the whole layer/profile decode on a downgrade. func testLayer_unknownStickModeOverrideDecodesToNilNotThrow() throws { let json = """ { @@ -184,8 +125,8 @@ final class LayerStickModeOverrideTests: XCTestCase { let decoded = try JSONDecoder().decode(Layer.self, from: json) - XCTAssertNil(decoded.leftStickTuning?.mode, "Unknown raw value should degrade to inherit.") - XCTAssertEqual(decoded.rightStickTuning?.mode, .scroll, "Known sibling value still decodes.") + XCTAssertNil(decoded.leftStickModeOverride, "Unknown raw value should degrade to inherit.") + XCTAssertEqual(decoded.rightStickModeOverride, .scroll, "Known sibling value still decodes.") } /// An unknown StickMode raw value on JoystickSettings must degrade to the @@ -195,7 +136,7 @@ final class LayerStickModeOverrideTests: XCTestCase { let decoded = try JSONDecoder().decode(JoystickSettings.self, from: json) - XCTAssertEqual(decoded.leftStick.mode, .mouse, "Unknown raw value degrades to the default.") - XCTAssertEqual(decoded.rightStick.mode, .dpad, "Known sibling value (incl. newer cases) still decodes.") + XCTAssertEqual(decoded.leftStickMode, .mouse, "Unknown raw value degrades to the default.") + XCTAssertEqual(decoded.rightStickMode, .dpad, "Known sibling value (incl. newer cases) still decodes.") } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/MacroEngineTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MacroEngineTests.swift index 57c9f27c..1201bede 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MacroEngineTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MacroEngineTests.swift @@ -158,7 +158,7 @@ final class MacroEngineTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.a, .b])) + controllerService.onChordDetected?([.a, .b]) } await waitForTasks() diff --git a/XboxControllerMapper/XboxControllerMapperTests/MainWindowSectionVisibilityTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MainWindowSectionVisibilityTests.swift index 9457d628..44452d7d 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MainWindowSectionVisibilityTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MainWindowSectionVisibilityTests.swift @@ -20,14 +20,10 @@ final class MainWindowSectionVisibilityTests: XCTestCase { XCTAssertEqual(groupedSections[.map], [.buttons, .chords, .sequences, .gestures]) XCTAssertEqual(groupedSections[.automate], [.macros, .scripts, .wheel, .keyboard]) - XCTAssertEqual(groupedSections[.hardware], [.input, .ring, .joysticks, .touchpad, .leds, .microphone]) + XCTAssertEqual(groupedSections[.hardware], [.input, .joysticks, .touchpad, .leds, .microphone]) XCTAssertEqual(groupedSections[.activity], [.stats, .history]) } - func testRingSectionUsesStableNewTabId() { - XCTAssertEqual(MainWindowSection.ring.rawValue, 15) - } - func testTabItemsExposeNavigationMetadata() { let buttonTab = MainWindowSection.buttons.tabItem XCTAssertEqual(buttonTab.group, .map) @@ -55,7 +51,6 @@ final class MainWindowSectionVisibilityTests: XCTestCase { XCTAssertFalse(sections.contains(.leds)) XCTAssertFalse(sections.contains(.microphone)) XCTAssertTrue(sections.contains(.buttons)) - XCTAssertTrue(sections.contains(.ring)) XCTAssertTrue(sections.contains(.joysticks)) } diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineBugfixTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineBugfixTests.swift index 73b45ec0..b20ae20a 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineBugfixTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineBugfixTests.swift @@ -42,7 +42,17 @@ final class ProfileChangeDuringActiveInputTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -288,7 +298,17 @@ final class SequenceDetectorProfileChangeIntegrationTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -409,7 +429,17 @@ final class TimerCleanupTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -624,7 +654,17 @@ final class GestureDetectorProfileChangeIntegrationTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -732,7 +772,17 @@ final class NilProfileHandlingTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCharacterizationTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCharacterizationTests.swift index b6fbff96..146e54e9 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCharacterizationTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCharacterizationTests.swift @@ -39,7 +39,18 @@ final class MappingEngineCharacterizationTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onControllerButtonTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCoreTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCoreTests.swift index 9f27bf27..096fbb00 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCoreTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineCoreTests.swift @@ -79,19 +79,19 @@ final class MappingEngineCoreTests: MappingEngineTestCase { // Allow Combine to deliver profile change to MappingEngine try? await Task.sleep(nanoseconds: 10_000_000) // 10ms await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftBumper)) + controllerService.onButtonPressed?(.leftBumper) } await waitForTasks() await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.05) } await waitForTasks(0.1) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.05) } await waitForTasks(0.3) @@ -115,7 +115,7 @@ final class MappingEngineCoreTests: MappingEngineTestCase { // Allow Combine to deliver profile change to MappingEngine try? await Task.sleep(nanoseconds: 10_000_000) // 10ms await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.a, .b])) + controllerService.onChordDetected?([.a, .b]) } await waitForTasks() @@ -161,13 +161,13 @@ final class MappingEngineCoreTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.view, .menu])) + controllerService.onChordDetected?([.view, .menu]) } await waitForTasks() await MainActor.run { XCTAssertEqual(profileManager.activeProfileId, desktopId) - controllerService.emitInputEvent(.chordDetected([.view, .menu])) + controllerService.onChordDetected?([.view, .menu]) } await waitForTasks() @@ -213,7 +213,7 @@ final class MappingEngineCoreTests: MappingEngineTestCase { // Allow Combine to deliver profile change to MappingEngine try? await Task.sleep(nanoseconds: 10_000_000) // 10ms await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftBumper)) + controllerService.onButtonPressed?(.leftBumper) } await waitForTasks() @@ -437,11 +437,11 @@ final class MappingEngineCoreTests: MappingEngineTestCase { // We simulate what MappingEngine sees: // 1. A release (engine doesn't know about chord yet) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.02)) + controllerService.onButtonReleased?(.a, 0.02) // 2. B release - controllerService.emitInputEvent(.buttonReleased(.b, holdDuration: 0.02)) + controllerService.onButtonReleased?(.b, 0.02) // 3. Chord detected (timer finally fired) - controllerService.emitInputEvent(.chordDetected([.a, .b])) + controllerService.onChordDetected?([.a, .b]) } await waitForTasks() diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift index 1792841e..31ab1485 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineLayerAndLifecycleTests.swift @@ -38,7 +38,17 @@ final class MappingEngineLayerAndLifecycleTests: XCTestCase { try? await Task.sleep(nanoseconds: 80_000_000) await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -627,8 +637,8 @@ final class MappingEngineLayerAndLifecycleTests: XCTestCase { func testReset_clearsAllStickHeldKeys() async throws { await MainActor.run { var profile = Profile(name: "WASD", buttonMappings: [:]) - profile.joystickSettings.leftStick.mode = .wasdKeys - profile.joystickSettings.leftStick.mouseDeadzone = 0.05 + profile.joystickSettings.leftStickMode = .wasdKeys + profile.joystickSettings.mouseDeadzone = 0.05 installActiveProfile(profile) controllerService.isConnected = true } diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTestCase.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTestCase.swift index 8a89897e..45e57ef2 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTestCase.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTestCase.swift @@ -51,7 +51,17 @@ class MappingEngineTestCase: XCTestCase { try? await Task.sleep(nanoseconds: 100_000_000) // 100ms await MainActor.run { mockInputSimulator?.releaseAllModifiers() - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() // Clean up HID resources before deallocation // Reset PlayStation controller flags to prevent LED code from running UserDefaults.standard.removeObject(forKey: Config.lastControllerWasDualSenseKey) diff --git a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTouchpadCoverageTests.swift b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTouchpadCoverageTests.swift index f69999e8..6b625c9f 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTouchpadCoverageTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/MappingEngineTouchpadCoverageTests.swift @@ -49,7 +49,19 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { } await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onSteamLeftTouchpadMoved = nil + controllerService?.onAppleTVRemoteCircularScroll = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -69,8 +81,8 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { func testRightStickScrollModeGeneratesScrollEvents() async throws { await MainActor.run { var profile = Profile(name: "RightScroll", buttonMappings: [:]) - profile.joystickSettings.rightStick.mode = .scroll - profile.joystickSettings.rightStick.scrollDeadzone = 0.05 + profile.joystickSettings.rightStickMode = .scroll + profile.joystickSettings.scrollDeadzone = 0.05 profileManager.setActiveProfile(profile) controllerService.isConnected = true } @@ -102,7 +114,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.6, y: 0.4))) + controllerService.onTouchpadMoved?(CGPoint(x: 0.6, y: 0.4)) } await waitForTasks(0.2) @@ -117,52 +129,6 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { } } - func testAnalogPrecisionTriggerScalesTouchpadMouseMovement() async throws { - await MainActor.run { - var profile = Profile(name: "TouchAnalogPrecision", buttonMappings: [:]) - profile.joystickSettings.touchpadDeadzone = 0.00001 - profile.joystickSettings.touchpadSmoothing = 0 - profile.joystickSettings.analogPrecisionTriggerMode = .right - profile.joystickSettings.analogPrecisionMinimumSpeed = 0.25 - profile.joystickSettings.analogPrecisionDeadzone = 0.0 - profile.joystickSettings.analogPrecisionCurve = 0.0 - profileManager.setActiveProfile(profile) - } - try? await Task.sleep(nanoseconds: 10_000_000) - - await MainActor.run { - controllerService.updateRightTrigger(0.0, pressed: false) - mockInputSimulator.clearEvents() - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.6, y: 0.4))) - } - await waitForTasks(0.2) - let normal = averageRecentTouchpadMouseMagnitude() - - await MainActor.run { - controllerService.updateRightTrigger(1.0, pressed: false) - mockInputSimulator.clearEvents() - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.6, y: 0.4))) - } - await waitForTasks(0.2) - let precise = averageRecentTouchpadMouseMagnitude() - - XCTAssertGreaterThan(normal, 0.1) - XCTAssertGreaterThan(precise, 0.1) - XCTAssertLessThan(precise, normal * 0.6) - } - - private func averageRecentTouchpadMouseMagnitude(limit: Int = 4) -> CGFloat { - let magnitudes = mockInputSimulator.events.compactMap { event -> CGFloat? in - if case .moveMouse(let dx, let dy) = event { - return hypot(dx, dy) - } - return nil - } - let recent = Array(magnitudes.suffix(limit)) - XCTAssertFalse(recent.isEmpty, "Expected touchpad mouse movement events") - return recent.reduce(0, +) / CGFloat(max(recent.count, 1)) - } - func testTouchpadMovementSuppressedDuringTwoFingerGesture() async throws { await MainActor.run { var profile = Profile(name: "TouchSuppressed", buttonMappings: [:]) @@ -172,16 +138,16 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) mockInputSimulator.clearEvents() - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.7, y: 0.7))) + controllerService.onTouchpadMoved?(CGPoint(x: 0.7, y: 0.7)) } await waitForTasks(0.2) @@ -192,14 +158,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { } XCTAssertFalse(hasMove, "Touchpad movement should be suppressed while two-finger gesture is active") - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: false ) - )) + ) } } @@ -214,11 +180,11 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap?() } await waitForTasks(0.04) await MainActor.run { - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap?() } await waitForTasks(0.25) @@ -249,11 +215,11 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadTap) // schedules pending single tap + controllerService.onTouchpadTap?() // schedules pending single tap } await waitForTasks(0.05) await MainActor.run { - controllerService.emitInputEvent(.touchpadLongTap) // should cancel pending tap and run long-hold action + controllerService.onTouchpadLongTap?() // should cancel pending tap and run long-hold action } await waitForTasks(0.30) @@ -284,11 +250,11 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadTwoFingerTap) + controllerService.onTouchpadTwoFingerTap?() } await waitForTasks(0.05) await MainActor.run { - controllerService.emitInputEvent(.touchpadTwoFingerLongTap) + controllerService.onTouchpadTwoFingerLongTap?() } await waitForTasks(0.30) @@ -317,14 +283,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0.24, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) } await waitForTasks(0.15) @@ -337,14 +303,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { }.count XCTAssertGreaterThan(plusPresses, 0, "Pinch out should trigger Cmd+Equal zoom-in keypress") - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: false ) - )) + ) } } @@ -358,14 +324,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: -0.24, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) } await waitForTasks(0.15) @@ -378,14 +344,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { }.count XCTAssertGreaterThan(minusPresses, 0, "Pinch in should trigger Cmd+Minus zoom-out keypress") - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: false ) - )) + ) } } @@ -400,7 +366,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: Config.touchpadPinchDeadzone + 0.02, @@ -409,7 +375,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { primaryDelta: CGPoint(x: 0.04, y: 0), secondaryDelta: CGPoint(x: -0.04, y: 0) ) - )) + ) } await waitForTasks(0.15) @@ -423,7 +389,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { XCTAssertEqual(plusPressesBeforeThreshold, 0, "Steam two-pad pinch should ignore small distance changes") mockInputSimulator.clearEvents() - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: Config.steamTouchpadPinchDeadzone + 0.02, @@ -432,7 +398,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { primaryDelta: CGPoint(x: 0.06, y: 0), secondaryDelta: CGPoint(x: -0.06, y: 0) ) - )) + ) } await waitForTasks(0.15) @@ -445,14 +411,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { }.count XCTAssertGreaterThan(plusPressesAfterThreshold, 0, "Steam two-pad pinch should trigger after the larger threshold") - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: false ) - )) + ) } } @@ -469,25 +435,25 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.9, y: 0.7), distanceDelta: 0.0, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) } await waitForTasks(0.05) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.9, y: 0.7), distanceDelta: 0.0, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) } await waitForTasks(0.35) @@ -500,14 +466,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { }.count XCTAssertGreaterThan(nonZeroScrollCount, 0, "Two-finger pan should emit non-zero scroll deltas") - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: false ) - )) + ) } await waitForTasks(0.1) } @@ -527,25 +493,25 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.9, y: 0.7), distanceDelta: 0.0, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) } await waitForTasks(0.05) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.9, y: 0.7), distanceDelta: 0.0, isPrimaryTouching: true, isSecondaryTouching: true ) - )) + ) } await waitForTasks(0.35) @@ -561,14 +527,14 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { "Touchpad pan inversion should reverse both horizontal and vertical scroll" ) - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: .zero, distanceDelta: 0, isPrimaryTouching: false, isSecondaryTouching: false ) - )) + ) } await waitForTasks(0.1) } @@ -587,7 +553,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.9, y: 0.7), distanceDelta: 0, @@ -596,7 +562,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { primaryDelta: CGPoint(x: 0.1, y: 0.1), secondaryDelta: CGPoint(x: 0.1, y: 0.1) ) - )) + ) } await waitForTasks(0.35) @@ -624,7 +590,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.05, y: 0), distanceDelta: 0, @@ -633,9 +599,9 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { primaryDelta: CGPoint(x: 0.1, y: 0), secondaryDelta: .zero ) - )) + ) mockInputSimulator.clearEvents() - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.7, y: 0.7))) + controllerService.onTouchpadMoved?(CGPoint(x: 0.7, y: 0.7)) } await waitForTasks(0.2) @@ -659,7 +625,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.touchpadGesture( + controllerService.onTouchpadGesture?( TouchpadGesture( centerDelta: CGPoint(x: 0.02, y: 0), distanceDelta: Config.steamTouchpadPinchDeadzone + 0.02, @@ -668,9 +634,9 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { primaryDelta: CGPoint(x: 0.1, y: 0), secondaryDelta: CGPoint(x: -0.1, y: 0) ) - )) + ) mockInputSimulator.clearEvents() - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.7, y: 0.7))) + controllerService.onTouchpadMoved?(CGPoint(x: 0.7, y: 0.7)) } await waitForTasks(0.2) @@ -694,7 +660,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.steamLeftTouchpadMoved(CGPoint(x: 0.1, y: -0.1))) + controllerService.onSteamLeftTouchpadMoved?(CGPoint(x: 0.1, y: -0.1)) } await waitForTasks(0.2) @@ -729,7 +695,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.steamLeftTouchpadMoved(CGPoint(x: 0.1, y: -0.1))) + controllerService.onSteamLeftTouchpadMoved?(CGPoint(x: 0.1, y: -0.1)) } await waitForTasks(0.2) @@ -757,7 +723,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.appleTVRemoteCircularScroll(0.12)) + controllerService.onAppleTVRemoteCircularScroll?(0.12) } await waitForTasks(0.2) @@ -786,7 +752,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.appleTVRemoteCircularScroll(0.12)) + controllerService.onAppleTVRemoteCircularScroll?(0.12) } await waitForTasks(0.2) @@ -814,7 +780,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.appleTVRemoteCircularScroll(0.12)) + controllerService.onAppleTVRemoteCircularScroll?(0.12) } await waitForTasks(0.2) @@ -838,7 +804,7 @@ final class MappingEngineTouchpadCoverageTests: XCTestCase { await waitForTasks(0.15) await MainActor.run { - controllerService.emitInputEvent(.appleTVRemoteCircularScroll(0.12)) + controllerService.onAppleTVRemoteCircularScroll?(0.12) } await waitForTasks(0.2) diff --git a/XboxControllerMapper/XboxControllerMapperTests/ModelCodableCoverageTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ModelCodableCoverageTests.swift index 59709b4b..72c3ed1d 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ModelCodableCoverageTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ModelCodableCoverageTests.swift @@ -136,8 +136,6 @@ final class ModelCodableCoverageTests: XCTestCase { let obs = SystemCommand.obsWebSocket(url: "ws://127.0.0.1:4455", password: nil, requestType: "VeryLongRequestTypeNameThatShouldTruncate", requestData: nil) let profileId = UUID() let profile = SystemCommand.switchProfile(profileId: profileId, profileName: "Gaming") - let centerRing = SystemCommand.centerOuraRing - let toggleRing = SystemCommand.toggleOuraMotion XCTAssertEqual(profile.category, .profile) XCTAssertEqual(launch.category, .app) @@ -145,16 +143,12 @@ final class ModelCodableCoverageTests: XCTestCase { XCTAssertEqual(link.category, .link) XCTAssertEqual(webhook.category, .webhook) XCTAssertEqual(obs.category, .obs) - XCTAssertEqual(centerRing.category, .ring) - XCTAssertEqual(toggleRing.category, .ring) XCTAssertEqual(profile.displayName, "Switch to Gaming") XCTAssertEqual(launch.displayName, "com.fake.app (New Window)") XCTAssertTrue(shell.displayName.hasSuffix("...")) XCTAssertTrue(link.displayName.hasSuffix("...")) XCTAssertTrue(obs.displayName.hasSuffix("...")) - XCTAssertEqual(centerRing.displayName, "Center Ring") - XCTAssertEqual(toggleRing.displayName, "Toggle Ring Mouse Control") } func testSystemCommandCodable_DefaultsForMissingFields() throws { @@ -163,8 +157,6 @@ final class ModelCodableCoverageTests: XCTestCase { let link = try JSONDecoder().decode(SystemCommand.self, from: Data(#"{"type":"openLink"}"#.utf8)) let request = try JSONDecoder().decode(SystemCommand.self, from: Data(#"{"type":"httpRequest"}"#.utf8)) let obs = try JSONDecoder().decode(SystemCommand.self, from: Data(#"{"type":"obsWebSocket"}"#.utf8)) - let centerRing = try JSONDecoder().decode(SystemCommand.self, from: Data(#"{"type":"centerOuraRing"}"#.utf8)) - let toggleRing = try JSONDecoder().decode(SystemCommand.self, from: Data(#"{"type":"toggleOuraMotion"}"#.utf8)) let profileId = UUID() let profileData = Data(#"{"type":"switchProfile","profileId":"\#(profileId.uuidString)","profileName":"Gaming"}"#.utf8) let profile = try JSONDecoder().decode(SystemCommand.self, from: profileData) @@ -174,8 +166,6 @@ final class ModelCodableCoverageTests: XCTestCase { XCTAssertEqual(link, .openLink(url: "")) XCTAssertEqual(request, .httpRequest(url: "", method: .POST, headers: nil, body: nil)) XCTAssertEqual(obs, .obsWebSocket(url: "", password: nil, requestType: "", requestData: nil)) - XCTAssertEqual(centerRing, .centerOuraRing) - XCTAssertEqual(toggleRing, .toggleOuraMotion) XCTAssertEqual(profile, .switchProfile(profileId: profileId, profileName: "Gaming")) } @@ -322,11 +312,11 @@ final class ModelCodableCoverageTests: XCTestCase { let decoded = try JSONDecoder().decode(JoystickSettings.self, from: Data(json.utf8)) - XCTAssertEqual(decoded.leftStick.mouseSensitivity, 0.0, accuracy: 0.0001) - XCTAssertEqual(decoded.rightStick.scrollSensitivity, 1.0, accuracy: 0.0001) - XCTAssertEqual(decoded.leftStick.mouseDeadzone, 0.0, accuracy: 0.0001) - XCTAssertEqual(decoded.rightStick.scrollDeadzone, 0.99, accuracy: 0.0001) - XCTAssertEqual(decoded.leftStick.mouseAcceleration, 1.0, accuracy: 0.0001) + XCTAssertEqual(decoded.mouseSensitivity, 0.0, accuracy: 0.0001) + XCTAssertEqual(decoded.scrollSensitivity, 1.0, accuracy: 0.0001) + XCTAssertEqual(decoded.mouseDeadzone, 0.0, accuracy: 0.0001) + XCTAssertEqual(decoded.scrollDeadzone, 0.99, accuracy: 0.0001) + XCTAssertEqual(decoded.mouseAcceleration, 1.0, accuracy: 0.0001) XCTAssertEqual(decoded.touchpadSensitivity, 0.0, accuracy: 0.0001) XCTAssertEqual(decoded.touchpadAcceleration, 1.0, accuracy: 0.0001) XCTAssertEqual(decoded.touchpadDeadzone, 0.03, accuracy: 0.0001) @@ -335,7 +325,7 @@ final class ModelCodableCoverageTests: XCTestCase { XCTAssertTrue(decoded.touchpadInvertScrollX) XCTAssertTrue(decoded.touchpadInvertScrollY) XCTAssertEqual(decoded.touchpadZoomToPanRatio, 0.5, accuracy: 0.0001) - XCTAssertEqual(decoded.rightStick.scrollAcceleration, 1.0, accuracy: 0.0001) + XCTAssertEqual(decoded.scrollAcceleration, 1.0, accuracy: 0.0001) XCTAssertEqual(decoded.scrollBoostMultiplier, 4.0, accuracy: 0.0001) XCTAssertEqual(decoded.focusModeSensitivity, 0.0, accuracy: 0.0001) XCTAssertTrue(decoded.isValid()) diff --git a/XboxControllerMapper/XboxControllerMapperTests/ModifierAndComboTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ModifierAndComboTests.swift index 672406df..63a3ad75 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ModifierAndComboTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ModifierAndComboTests.swift @@ -22,8 +22,8 @@ final class ModifierAndComboTests: MappingEngineTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.05)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.05) } await waitForTasks() diff --git a/XboxControllerMapper/XboxControllerMapperTests/ModifierKeyEmissionPolicyTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ModifierKeyEmissionPolicyTests.swift deleted file mode 100644 index 9c8b9c09..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/ModifierKeyEmissionPolicyTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import XCTest -import CoreGraphics -import Carbon.HIToolbox -@testable import ControllerKeys - -final class ModifierKeyEmissionPolicyTests: XCTestCase { - func testDefaultsUseLeftModifierKeys() { - XCTAssertEqual(ModifierKeyEmissionPolicy.defaultKeyCode(for: .maskCommand), CGKeyCode(kVK_Command)) - XCTAssertEqual(ModifierKeyEmissionPolicy.defaultKeyCode(for: .maskAlternate), CGKeyCode(kVK_Option)) - XCTAssertEqual(ModifierKeyEmissionPolicy.defaultKeyCode(for: .maskShift), CGKeyCode(kVK_Shift)) - XCTAssertEqual(ModifierKeyEmissionPolicy.defaultKeyCode(for: .maskControl), CGKeyCode(kVK_Control)) - } - - func testSideAwarePolicyHonorsRightModifierKeys() { - let sides = ModifierFlags( - command: true, - option: true, - shift: true, - control: true, - commandSide: .right, - optionSide: .right, - shiftSide: .right, - controlSide: .right - ) - - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskCommand, sides: sides), CGKeyCode(kVK_RightCommand)) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskAlternate, sides: sides), CGKeyCode(kVK_RightOption)) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskShift, sides: sides), CGKeyCode(kVK_RightShift)) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskControl, sides: sides), CGKeyCode(kVK_RightControl)) - } - - func testUnsetSidesFallBackToDefaults() { - let sides = ModifierFlags(command: true, option: true, shift: true, control: true) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskCommand, sides: sides), CGKeyCode(kVK_Command)) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskAlternate, sides: sides), CGKeyCode(kVK_Option)) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskShift, sides: sides), CGKeyCode(kVK_Shift)) - XCTAssertEqual(ModifierKeyEmissionPolicy.keyCode(for: .maskControl, sides: sides), CGKeyCode(kVK_Control)) - } - - func testPressAndReleaseOrdersStayStable() { - XCTAssertEqual( - ModifierKeyEmissionPolicy.modifierPressOrder, - [.maskCommand, .maskShift, .maskAlternate, .maskControl] - ) - XCTAssertEqual( - ModifierKeyEmissionPolicy.modifierReleaseOrder, - [.maskControl, .maskAlternate, .maskShift, .maskCommand] - ) - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/OBSWebSocketTests.swift b/XboxControllerMapper/XboxControllerMapperTests/OBSWebSocketTests.swift index 7ddbb926..8d02cf0f 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/OBSWebSocketTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/OBSWebSocketTests.swift @@ -119,15 +119,7 @@ final class OBSWebSocketTests: XCTestCase { XCTAssertEqual(json["type"] as? String, "obsWebSocket") XCTAssertEqual(json["url"] as? String, "ws://127.0.0.1:4455") - // Without a writable keychain the password is either passed through as-is or - // discarded entirely ("Keychain store failed, discarding OBS password") — - // both mean this session can't exercise the keychain-reference path. - guard let passwordReference = json["password"] as? String else { - throw XCTSkip("Writable keychain unavailable in this test session") - } - if passwordReference == "secret" { - throw XCTSkip("Writable keychain unavailable in this test session") - } + let passwordReference = try XCTUnwrap(json["password"] as? String) XCTAssertNotEqual(passwordReference, "secret") XCTAssertNotNil(UUID(uuidString: passwordReference)) XCTAssertEqual(json["requestType"] as? String, "SetCurrentProgramScene") diff --git a/XboxControllerMapper/XboxControllerMapperTests/OnboardingFlowTests.swift b/XboxControllerMapper/XboxControllerMapperTests/OnboardingFlowTests.swift index d78e768a..c75edcb1 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/OnboardingFlowTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/OnboardingFlowTests.swift @@ -17,31 +17,19 @@ final class OnboardingFlowTests: XCTestCase { } func testStepOrdering() { - XCTAssertEqual(OnboardingStep.welcome.next, .inputMonitoring) - XCTAssertEqual(OnboardingStep.inputMonitoring.next, .accessibility) - XCTAssertEqual(OnboardingStep.accessibility.next, .bluetooth) + XCTAssertEqual(OnboardingStep.welcome.next, .accessibility) + XCTAssertEqual(OnboardingStep.accessibility.next, .inputMonitoring) + XCTAssertEqual(OnboardingStep.inputMonitoring.next, .bluetooth) XCTAssertEqual(OnboardingStep.bluetooth.next, .done) XCTAssertNil(OnboardingStep.done.next) XCTAssertNil(OnboardingStep.welcome.previous) - XCTAssertEqual(OnboardingStep.inputMonitoring.previous, .welcome) - XCTAssertEqual(OnboardingStep.accessibility.previous, .inputMonitoring) + XCTAssertEqual(OnboardingStep.accessibility.previous, .welcome) XCTAssertEqual(OnboardingStep.done.previous, .bluetooth) } func testPermissionStepsExcludeWelcomeAndDone() { - XCTAssertEqual(OnboardingStep.permissionSteps, [.inputMonitoring, .accessibility, .bluetooth]) - } - - func testInputMonitoringComesBeforeAccessibility() { - // Accessibility can make macOS report listen access as granted without - // separately registering the app in Input Monitoring. Ask for Input - // Monitoring first so first-run onboarding can add the app to that list. - XCTAssertEqual(OnboardingStep.welcome.next, .inputMonitoring) - XCTAssertLessThan( - OnboardingStep.permissionSteps.firstIndex(of: .inputMonitoring)!, - OnboardingStep.permissionSteps.firstIndex(of: .accessibility)! - ) + XCTAssertEqual(OnboardingStep.permissionSteps, [.accessibility, .inputMonitoring, .bluetooth]) } // MARK: - canAdvance gating @@ -74,16 +62,12 @@ final class OnboardingFlowTests: XCTestCase { func testFirstIncompleteStepWalksRequiredPermissionsInOrder() { XCTAssertEqual( OnboardingStepState(accessibility: .notDetermined, inputMonitoring: .notDetermined, bluetooth: .notDetermined).firstIncompleteStep, - .inputMonitoring + .accessibility ) XCTAssertEqual( OnboardingStepState(accessibility: .granted, inputMonitoring: .notDetermined, bluetooth: .notDetermined).firstIncompleteStep, .inputMonitoring ) - XCTAssertEqual( - OnboardingStepState(accessibility: .notDetermined, inputMonitoring: .granted, bluetooth: .notDetermined).firstIncompleteStep, - .accessibility - ) } func testFirstIncompleteStepIgnoresOptionalBluetooth() { diff --git a/XboxControllerMapper/XboxControllerMapperTests/OuraRingInputTests.swift b/XboxControllerMapper/XboxControllerMapperTests/OuraRingInputTests.swift deleted file mode 100644 index 1aacbb7a..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/OuraRingInputTests.swift +++ /dev/null @@ -1,1055 +0,0 @@ -import CoreBluetooth -import CoreGraphics -import XCTest -@testable import ControllerKeys - -final class OuraRingInputTests: XCTestCase { - func testScreenPlanePitchDirectionConsistentAcrossCenteringPoses() { - // Regression: centering with the finger level-or-raised (neutral y > 0) - // inverted up/down; pitching the finger up must move the projection the - // same direction regardless of the centering pose. - func pitchResponse(neutralY: Double) -> Double { - var settings = OuraMotionSettings.default - settings.enabled = true - settings.orientation = .screenPlane - var mapper = OuraMotionMapper(settings: settings) - let nx = 0.05, nz = -0.95 - _ = mapper.mappingResult(forRawSample: OuraMotionSample(x: nx, y: neutralY, z: nz, timestamp: 1.0)) - let theta = 0.1 - let py = neutralY * cos(theta) - nz * sin(theta) - let pz = neutralY * sin(theta) + nz * cos(theta) - let result = mapper.mappingResult(forRawSample: OuraMotionSample(x: nx, y: py, z: pz, timestamp: 1.05)) - return Double(result.projectedInput.y) - } - - let down = pitchResponse(neutralY: -0.3) - let up = pitchResponse(neutralY: 0.3) - XCTAssertGreaterThan(abs(down), 1e-4) - XCTAssertGreaterThan(abs(up), 1e-4) - XCTAssertEqual((down > 0), (up > 0), "pitch direction must not depend on centering pose") - } - - func testOuraMotionSettingsDecodeClampsAndDefaults() throws { - let json = #""" - { - "enabled": true, - "targetStick": "right", - "sensitivity": 5.0, - "deadzone": -1.0, - "smoothing": 2.0 - } - """#.data(using: .utf8)! - - let decoded = try JSONDecoder().decode(OuraMotionSettings.self, from: json) - - XCTAssertTrue(decoded.enabled) - XCTAssertTrue(decoded.motionOutputEnabled) - XCTAssertNil(decoded.outputMode) - XCTAssertEqual(decoded.targetStick, .right) - XCTAssertEqual(decoded.orientation, .screenPlane) - XCTAssertEqual(decoded.sensitivity, 1.0) - XCTAssertEqual(decoded.horizontalBoost, 1.0) - XCTAssertEqual(decoded.leftTiltBoost, 1.0) - XCTAssertEqual(decoded.deadzone, 0.0) - XCTAssertEqual(decoded.smoothing, 1.0) - XCTAssertTrue(decoded.adoptResetRing) - XCTAssertTrue(decoded.diagnosticsEnabled) - } - - func testOuraMotionSettingsDefaultMatchesTunedRingProfile() { - let settings = OuraMotionSettings.default - - XCTAssertFalse(settings.enabled) - XCTAssertTrue(settings.motionOutputEnabled) - XCTAssertNil(settings.outputMode) - XCTAssertEqual(settings.targetStick, .left) - XCTAssertEqual(settings.orientation, .screenPlane) - XCTAssertEqual(settings.sensitivity, 0.0479656339031339, accuracy: 1e-12) - XCTAssertEqual(settings.horizontalBoost, 1.0, accuracy: 1e-12) - XCTAssertEqual(settings.leftTiltBoost, 1.0, accuracy: 1e-12) - XCTAssertEqual(settings.deadzone, 0.3505420470505618, accuracy: 1e-12) - XCTAssertEqual(settings.smoothing, 0.7516045616005551, accuracy: 1e-12) - XCTAssertFalse(settings.invertX) - XCTAssertFalse(settings.invertY) - XCTAssertTrue(settings.adoptResetRing) - XCTAssertTrue(settings.diagnosticsEnabled) - } - - func testOuraMotionOutputModeMapsToInternalStickRouting() { - var settings = JoystickSettings() - - XCTAssertEqual(settings.ouraMotionOutputMode, .mouse) - - settings.setOuraMotionOutputMode(.scroll) - XCTAssertTrue(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotion.outputMode, .scroll) - XCTAssertEqual(settings.ouraMotion.targetStick, .right) - XCTAssertEqual(settings.rightStick.mode, .scroll) - XCTAssertEqual(settings.ouraMotionOutputMode, .scroll) - - settings.setOuraMotionOutputMode(.off) - XCTAssertFalse(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotionOutputMode, .off) - - settings.setOuraMotionOutputMode(.mouse) - XCTAssertTrue(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotion.outputMode, .mouse) - XCTAssertEqual(settings.ouraMotion.targetStick, .left) - XCTAssertEqual(settings.leftStick.mode, .mouse) - XCTAssertEqual(settings.ouraMotionOutputMode, .mouse) - - settings.setOuraMotionOutputMode(.arrowKeys) - XCTAssertTrue(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotion.outputMode, .arrowKeys) - XCTAssertEqual(settings.ouraMotion.targetStick, .left) - XCTAssertEqual(settings.leftStick.mode, .arrowKeys) - XCTAssertEqual(settings.ouraMotionOutputMode, .arrowKeys) - - settings.setOuraMotionOutputMode(.wasdKeys) - XCTAssertTrue(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotion.outputMode, .wasdKeys) - XCTAssertEqual(settings.ouraMotion.targetStick, .left) - XCTAssertEqual(settings.leftStick.mode, .wasdKeys) - XCTAssertEqual(settings.ouraMotionOutputMode, .wasdKeys) - - settings.leftStick.mode = .mouse - XCTAssertEqual(settings.ouraMotionOutputMode, .mouse) - settings.leftStick.mode = .wasdKeys - - settings.setOuraMotionOutputMode(.custom) - XCTAssertTrue(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotion.outputMode, .custom) - XCTAssertEqual(settings.ouraMotion.targetStick, .left) - XCTAssertEqual(settings.leftStick.mode, .custom) - XCTAssertEqual(settings.ouraMotionOutputMode, .custom) - - settings.setOuraMotionOutputMode(.dpad) - XCTAssertTrue(settings.ouraMotion.motionOutputEnabled) - XCTAssertEqual(settings.ouraMotion.outputMode, .dpad) - XCTAssertEqual(settings.ouraMotion.targetStick, .left) - XCTAssertEqual(settings.leftStick.mode, .dpad) - XCTAssertEqual(settings.ouraMotionOutputMode, .dpad) - } - - func testProfileOuraMotionKeyOutputsUseCustomDirectionMappings() throws { - var profile = Profile(name: "Ring") - - profile.setOuraMotionOutputMode(.wasdKeys) - - XCTAssertEqual(profile.ouraMotionOutputMode, .wasdKeys) - XCTAssertEqual(profile.joystickSettings.ouraMotion.targetStick, .left) - XCTAssertEqual(profile.joystickSettings.leftStick.mode, .custom) - assertStickMapping(profile.buttonMappings[.leftStickUp], keyCode: KeyCodeMapping.keyW) - assertStickMapping(profile.buttonMappings[.leftStickLeft], keyCode: KeyCodeMapping.keyA) - assertStickMapping(profile.buttonMappings[.leftStickRight], keyCode: KeyCodeMapping.keyD) - assertStickMapping(profile.buttonMappings[.leftStickDown], keyCode: KeyCodeMapping.keyS) - - let data = try JSONEncoder().encode(profile) - let decoded = try JSONDecoder().decode(Profile.self, from: data) - - XCTAssertEqual(decoded.ouraMotionOutputMode, .wasdKeys) - XCTAssertEqual(decoded.joystickSettings.leftStick.mode, .custom) - assertStickMapping(decoded.buttonMappings[.leftStickUp], keyCode: KeyCodeMapping.keyW) - - var reconciledProfile = decoded - reconciledProfile.joystickSettings.leftStick.mode = .mouse - reconciledProfile.reconcileOuraMotionOutputModeWithCurrentRouting() - XCTAssertEqual(reconciledProfile.ouraMotionOutputMode, .mouse) - XCTAssertEqual(reconciledProfile.joystickSettings.ouraMotion.outputMode, .mouse) - - var customProfile = decoded - customProfile.setOuraMotionOutputMode(.custom) - XCTAssertEqual(customProfile.ouraMotionOutputMode, .custom) - XCTAssertEqual(customProfile.joystickSettings.leftStick.mode, .custom) - - var arrowsProfile = decoded - arrowsProfile.setOuraMotionOutputMode(.arrowKeys) - - XCTAssertEqual(arrowsProfile.ouraMotionOutputMode, .arrowKeys) - XCTAssertEqual(arrowsProfile.joystickSettings.leftStick.mode, .custom) - assertStickMapping(arrowsProfile.buttonMappings[.leftStickUp], keyCode: KeyCodeMapping.upArrow) - assertStickMapping(arrowsProfile.buttonMappings[.leftStickLeft], keyCode: KeyCodeMapping.leftArrow) - assertStickMapping(arrowsProfile.buttonMappings[.leftStickRight], keyCode: KeyCodeMapping.rightArrow) - assertStickMapping(arrowsProfile.buttonMappings[.leftStickDown], keyCode: KeyCodeMapping.downArrow) - - arrowsProfile.buttonMappings[.leftStickUp]?.keyCode = KeyCodeMapping.tab - arrowsProfile.updateOuraMotionOutputModeIfNeeded(afterChanging: .leftStickUp) - XCTAssertEqual(arrowsProfile.ouraMotionOutputMode, .custom) - - var presetProfile = decoded - StickDirectionPreset.arrows.apply(to: &presetProfile.buttonMappings, side: .left) - presetProfile.updateOuraMotionOutputModeIfNeeded(afterChanging: .leftStickUp) - XCTAssertEqual(presetProfile.ouraMotionOutputMode, .arrowKeys) - } - - func testJoystickSettingsRoundTripPreservesOuraMotion() throws { - var settings = JoystickSettings() - settings.ouraMotion.enabled = true - settings.ouraMotion.motionOutputEnabled = false - settings.ouraMotion.targetStick = .right - settings.ouraMotion.outputMode = .scroll - settings.ouraMotion.orientation = .legacyXY - settings.ouraMotion.sensitivity = 0.72 - settings.ouraMotion.horizontalBoost = 2.4 - settings.ouraMotion.leftTiltBoost = 1.6 - settings.ouraMotion.invertY = true - - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(JoystickSettings.self, from: data) - - XCTAssertEqual(decoded.ouraMotion, settings.ouraMotion) - XCTAssertTrue(decoded.isValid()) - } - - private func assertStickMapping( - _ mapping: KeyMapping?, - keyCode: CGKeyCode, - file: StaticString = #filePath, - line: UInt = #line - ) { - XCTAssertEqual(mapping?.keyCode, keyCode, file: file, line: line) - XCTAssertTrue(mapping?.isHoldModifier ?? false, file: file, line: line) - XCTAssertTrue(mapping?.holdRepeatEnabled ?? false, file: file, line: line) - } - - func testOuraPacketDecoderRecognizesAuthFrames() { - let nonceFrame = Data([0x2f, 0x10, 0x2c] + Array(repeating: 0x11, count: 15)) - let authFrame = Data([0x2f, 0x02, 0x2e, 0x00]) - let keyFrame = Data([0x25, 0x01, 0x00]) - - XCTAssertEqual(OuraRingPacketDecoder.decode(nonceFrame), [.nonce(Data(repeating: 0x11, count: 15))]) - XCTAssertEqual(OuraRingPacketDecoder.decode(authFrame), [.authStatus(.success)]) - XCTAssertEqual(OuraRingPacketDecoder.decode(keyFrame), [.keyInstallStatus(success: true)]) - } - - func testOuraProtocolBuildsRealtimeAccelerometerCommands() { - XCTAssertEqual( - OuraRingProtocol.startAccelerometerCommand(durationMinutes: 10), - Data([0x06, 0x07, 0x20, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00]) - ) - XCTAssertEqual( - OuraRingProtocol.stopRealtimeCommand(), - Data([0x06, 0x04, 0x00, 0x00, 0x00, 0x00]) - ) - } - - func testOuraScanMatcherRecognizesServiceAndNames() { - XCTAssertEqual( - OuraRingScanMatcher.match( - peripheralName: nil, - advertisementData: [CBAdvertisementDataServiceUUIDsKey: [OuraRingProtocol.serviceUUID]] - ), - "service" - ) - XCTAssertEqual( - OuraRingScanMatcher.match( - peripheralName: nil, - advertisementData: [CBAdvertisementDataLocalNameKey: "Oura Ring 4"] - ), - "local name" - ) - XCTAssertEqual( - OuraRingScanMatcher.match(peripheralName: "Ōura Ring", advertisementData: [:]), - "peripheral name" - ) - XCTAssertNil( - OuraRingScanMatcher.match( - peripheralName: "Keyboard", - advertisementData: [CBAdvertisementDataLocalNameKey: "Keyboard"] - ) - ) - } - - func testOuraPacketDecoderRecognizesTapFeaturePush() { - let tapFrame = Data([0x2f, 0x02, 0x28, OuraRingProtocol.tapToTagFeature]) - - XCTAssertEqual(OuraRingPacketDecoder.decode(tapFrame), [.tap]) - } - - func testOuraPacketDecoderRecognizesRealtimeAccelerometerFrame() throws { - let frame = Data([ - 0x33, 0x0c, 0x32, 0x01, - 0xe8, 0x03, 0x18, 0xfc, 0xf4, 0x01, - 0x00, 0x00, 0x00, 0x00, 0xe8, 0x03 - ]) - - let events = OuraRingPacketDecoder.decode(frame) - guard events.count == 2 else { - return XCTFail("Expected two accelerometer samples") - } - guard case .motion(let first) = events[0], - case .motion(let second) = events[1] else { - return XCTFail("Expected motion samples") - } - - XCTAssertEqual(first.x, 1.0, accuracy: 1e-9) - XCTAssertEqual(first.y, -1.0, accuracy: 1e-9) - XCTAssertEqual(first.z, 0.5, accuracy: 1e-9) - XCTAssertEqual(second.x, 0.0, accuracy: 1e-9) - XCTAssertEqual(second.y, 0.0, accuracy: 1e-9) - XCTAssertEqual(second.z, 1.0, accuracy: 1e-9) - } - - func testOuraPacketDecoderRecognizesMotionRecord() throws { - let motionFrame = Data([0x47, 0x08, 0, 0, 0, 0, 0, 64, 0xc0, 0]) - - guard case .motion(let sample) = OuraRingPacketDecoder.decode(motionFrame).first else { - return XCTFail("Expected motion sample") - } - - XCTAssertEqual(sample.x, 1.0, accuracy: 1e-9) - XCTAssertEqual(sample.y, -1.0, accuracy: 1e-9) - XCTAssertEqual(sample.z, 0.0, accuracy: 1e-9) - } - - func testOuraTapDetectorDetectsSharpImpulse() { - var detector = OuraTapDetector() - - XCTAssertFalse(detector.register(OuraMotionSample(x: 0, y: 0, z: 1.0, timestamp: 10.00))) - XCTAssertFalse(detector.register(OuraMotionSample(x: 0.01, y: 0, z: 1.0, timestamp: 10.02))) - XCTAssertFalse(detector.register(OuraMotionSample(x: 0.72, y: 0.08, z: 1.42, timestamp: 10.04))) - XCTAssertTrue(detector.register(OuraMotionSample(x: 0, y: 0, z: 1.0, timestamp: 10.06))) - XCTAssertFalse(detector.register(OuraMotionSample(x: 0.05, y: 0.05, z: 1.02, timestamp: 10.35))) - } - - func testOuraTapSequenceRecognizerResolvesSingleDoubleAndTripleTap() { - var recognizer = OuraTapSequenceRecognizer() - - XCTAssertEqual(recognizer.registerTap(at: 1.00), .pending(1)) - XCTAssertNil(recognizer.resolvePending(at: 1.70)) - XCTAssertEqual(recognizer.resolvePending(at: 1.76), .tapCount(1)) - - XCTAssertEqual(recognizer.registerTap(at: 2.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 2.48), .pending(2)) - XCTAssertNil(recognizer.resolvePending(at: 3.10)) - XCTAssertEqual(recognizer.resolvePending(at: 3.24), .tapCount(2)) - - XCTAssertEqual(recognizer.registerTap(at: 4.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 4.42), .pending(2)) - XCTAssertEqual(recognizer.registerTap(at: 4.88), .pending(3)) - XCTAssertEqual(recognizer.resolvePending(at: 5.64), .tapCount(3)) - } - - func testOuraTapSequenceRecognizerCompletesFiveTapImmediately() { - var recognizer = OuraTapSequenceRecognizer() - - XCTAssertEqual(recognizer.registerTap(at: 5.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 5.32), .pending(2)) - XCTAssertEqual(recognizer.registerTap(at: 5.75), .pending(3)) - XCTAssertEqual(recognizer.registerTap(at: 6.19), .pending(4)) - XCTAssertEqual(recognizer.registerTap(at: 6.62), .completed(5)) - XCTAssertNil(recognizer.resolvePending(at: 7.30)) - } - - func testOuraTapSequenceRecognizerIgnoresDuplicateReports() { - var recognizer = OuraTapSequenceRecognizer() - - XCTAssertEqual(recognizer.registerTap(at: 4.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 4.04), .duplicate) - XCTAssertEqual(recognizer.registerTap(at: 4.18), .pending(2)) - XCTAssertEqual(recognizer.resolvePending(at: 4.94), .tapCount(2)) - } - - func testOuraTapSequenceRecognizerEchoGuardDowngradesFastPairToSingle() { - var recognizer = OuraTapSequenceRecognizer() - recognizer.echoGuardGap = 0.35 - - // Settle echo 0.26s after a single tap — the live misfire signature. - XCTAssertEqual(recognizer.registerTap(at: 1.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 1.26), .pending(2)) - XCTAssertEqual(recognizer.resolvePending(at: 2.02), .tapCount(1)) - XCTAssertEqual(recognizer.lastResolutionEchoGap ?? -1, 0.26, accuracy: 0.001) - - // A deliberate two-beat double is untouched. - XCTAssertEqual(recognizer.registerTap(at: 3.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 3.45), .pending(2)) - XCTAssertEqual(recognizer.resolvePending(at: 4.21), .tapCount(2)) - XCTAssertNil(recognizer.lastResolutionEchoGap) - } - - func testOuraTapSequenceRecognizerEchoGuardDropsRhythmBreakingTrailingTap() { - var recognizer = OuraTapSequenceRecognizer() - recognizer.echoGuardGap = 0.35 - - // Slow double followed by a fast settle echo → still a double. - XCTAssertEqual(recognizer.registerTap(at: 1.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 1.45), .pending(2)) - XCTAssertEqual(recognizer.registerTap(at: 1.70), .pending(3)) - XCTAssertEqual(recognizer.resolvePending(at: 2.46), .tapCount(2)) - XCTAssertEqual(recognizer.lastResolutionEchoGap ?? -1, 0.25, accuracy: 0.001) - - // A machine-gun triple establishes its rhythm — trailing tap kept. - XCTAssertEqual(recognizer.registerTap(at: 5.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 5.24), .pending(2)) - XCTAssertEqual(recognizer.registerTap(at: 5.50), .pending(3)) - XCTAssertEqual(recognizer.resolvePending(at: 6.26), .tapCount(3)) - XCTAssertNil(recognizer.lastResolutionEchoGap) - } - - func testOuraTapSequenceRecognizerEchoGuardDisabledByZeroGap() { - var recognizer = OuraTapSequenceRecognizer() - recognizer.echoGuardGap = 0 - - XCTAssertEqual(recognizer.registerTap(at: 1.00), .pending(1)) - XCTAssertEqual(recognizer.registerTap(at: 1.26), .pending(2)) - XCTAssertEqual(recognizer.resolvePending(at: 2.02), .tapCount(2)) - } - - func testOuraTapMotionSuppressorExtendsAndExpires() { - var suppressor = OuraTapMotionSuppressor() - - XCTAssertFalse(suppressor.isSuppressed(at: 10.00)) - - suppressor.suppress(at: 10.00, duration: 0.50) - XCTAssertTrue(suppressor.isSuppressed(at: 10.49)) - XCTAssertFalse(suppressor.isSuppressed(at: 10.50)) - - suppressor.suppress(at: 11.00, duration: 0.20) - suppressor.suppress(at: 11.10, duration: 0.50) - XCTAssertTrue(suppressor.isSuppressed(at: 11.59)) - XCTAssertFalse(suppressor.isSuppressed(at: 11.60)) - - suppressor.reset() - XCTAssertFalse(suppressor.isSuppressed(at: 11.20)) - } - - func testOuraTapHoldRecognizerFiresAfterStillHold() { - var recognizer = OuraTapHoldRecognizer() - let anchor = OuraMotionSample(x: 0.10, y: 0.95, z: -0.12, timestamp: 40.00) - - recognizer.registerTap(at: anchor.timestamp, sample: anchor) - - XCTAssertFalse(recognizer.registerMotion(OuraMotionSample(x: 0.11, y: 0.95, z: -0.11, timestamp: 40.20))) - XCTAssertFalse(recognizer.registerMotion(OuraMotionSample(x: 0.10, y: 0.96, z: -0.12, timestamp: 40.50))) - XCTAssertTrue(recognizer.registerMotion(OuraMotionSample(x: 0.10, y: 0.95, z: -0.12, timestamp: 40.70))) - XCTAssertFalse(recognizer.registerMotion(OuraMotionSample(x: 0.11, y: 0.95, z: -0.12, timestamp: 40.95))) - } - - func testOuraTapHoldRecognizerCancelsWhenHandMoves() { - var recognizer = OuraTapHoldRecognizer() - let anchor = OuraMotionSample(x: 0.10, y: 0.95, z: -0.12, timestamp: 41.00) - - recognizer.registerTap(at: anchor.timestamp, sample: anchor) - - XCTAssertFalse(recognizer.registerMotion(OuraMotionSample(x: 0.58, y: 0.72, z: -0.32, timestamp: 41.20))) - XCTAssertFalse(recognizer.registerMotion(OuraMotionSample(x: 0.10, y: 0.95, z: -0.12, timestamp: 41.70))) - } - - func testOuraDirectionalFlickRecognizerDetectsFastReturnToStart() { - var recognizer = OuraDirectionalFlickRecognizer() - - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: -0.22, y: 0.38), timestamp: 42.00)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: 0.42, y: 0.43), timestamp: 42.25)) - XCTAssertEqual( - recognizer.register(projectedInput: CGPoint(x: -0.15, y: 0.36), timestamp: 42.50), - .right - ) - } - - func testOuraDirectionalFlickRecognizerRequiresReturnToStart() { - var recognizer = OuraDirectionalFlickRecognizer() - - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: 0.20, y: -0.24), timestamp: 43.00)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: -0.44, y: -0.18), timestamp: 43.25)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: -0.36, y: -0.17), timestamp: 43.45)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: -0.30, y: -0.18), timestamp: 43.70)) - } - - func testOuraDirectionalFlickRecognizerIgnoresSlowSteeringMotion() { - var recognizer = OuraDirectionalFlickRecognizer() - - XCTAssertNil(recognizer.register(projectedInput: .zero, timestamp: 44.00)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: 0.14, y: 0.02), timestamp: 44.08)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: 0.29, y: 0.02), timestamp: 44.20)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: 0.47, y: 0.03), timestamp: 44.36)) - XCTAssertNil(recognizer.register(projectedInput: CGPoint(x: 0.09, y: 0.02), timestamp: 44.64)) - } - - func testOuraMotionMapperAppliesDeadzoneSensitivityAndSmoothing() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.orientation = .legacyXY - settings.deadzone = 0.1 - settings.sensitivity = 1.0 - settings.smoothing = 0.0 - - var mapper = OuraMotionMapper(settings: settings) - let sample = OuraMotionSample(x: 0.5, y: 0.0, z: 0.0, timestamp: 0) - let stick = mapper.stickPosition(for: sample) - - XCTAssertGreaterThan(stick.x, 0.0) - XCTAssertEqual(stick.y, 0.0, accuracy: 1e-9) - XCTAssertLessThanOrEqual(stick.x, 1.0) - - let drift = OuraMotionSample(x: 0.01, y: 0.01, z: 0.0, timestamp: 0) - let neutral = mapper.stickPosition(for: drift) - XCTAssertEqual(neutral, .zero) - } - - func testOuraMotionMapperAppliesHorizontalBoost() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.orientation = .legacyXY - settings.deadzone = 0.0 - settings.sensitivity = 0.0 - settings.smoothing = 0.0 - settings.horizontalBoost = 1.0 - - var normalMapper = OuraMotionMapper(settings: settings) - let normalStick = normalMapper.stickPosition(for: OuraMotionSample(x: 0.2, y: 0.0, z: 0.0, timestamp: 0)) - - settings.horizontalBoost = 2.2 - var boostedMapper = OuraMotionMapper(settings: settings) - let boostedStick = boostedMapper.stickPosition(for: OuraMotionSample(x: 0.2, y: 0.0, z: 0.0, timestamp: 0)) - - XCTAssertGreaterThan(abs(boostedStick.x), abs(normalStick.x)) - XCTAssertEqual(boostedStick.y, 0.0, accuracy: 1e-9) - } - - func testOuraMotionMapperBoostsNegativeHorizontalSideForLeftTilt() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.orientation = .legacyXY - settings.deadzone = 0.0 - settings.sensitivity = 0.0 - settings.smoothing = 0.0 - settings.horizontalBoost = 1.0 - settings.leftTiltBoost = 1.8 - - var positiveMapper = OuraMotionMapper(settings: settings) - let positiveStick = positiveMapper.stickPosition(for: OuraMotionSample(x: 0.2, y: 0.0, z: 0.0, timestamp: 0)) - - var negativeMapper = OuraMotionMapper(settings: settings) - let negativeStick = negativeMapper.stickPosition(for: OuraMotionSample(x: -0.2, y: 0.0, z: 0.0, timestamp: 0)) - - XCTAssertGreaterThan(abs(negativeStick.x), abs(positiveStick.x)) - XCTAssertEqual(positiveStick.y, 0.0, accuracy: 1e-9) - XCTAssertEqual(negativeStick.y, 0.0, accuracy: 1e-9) - } - - func testOuraMotionMapperSuppressesStickWhenMotionOutputPaused() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.motionOutputEnabled = false - settings.orientation = .legacyXY - settings.deadzone = 0.0 - settings.sensitivity = 1.0 - settings.smoothing = 0.0 - - var mapper = OuraMotionMapper(settings: settings) - _ = mapper.mappingResult(forRawSample: OuraMotionSample(x: 0.0, y: 0.0, z: 0.0, timestamp: 0)) - let result = mapper.mappingResult(forRawSample: OuraMotionSample(x: 1.0, y: 1.0, z: 0.0, timestamp: 0.02)) - - XCTAssertGreaterThan(result.projectedInput.x, 0.0) - XCTAssertGreaterThan(result.projectedInput.y, 0.0) - XCTAssertEqual(result.stick, .zero) - } - - func testOuraMotionMapperFingerToScreenUsesZForVerticalTilt() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.orientation = .fingerToScreen - settings.deadzone = 0.0 - settings.sensitivity = 0.0 - settings.smoothing = 0.0 - - var mapper = OuraMotionMapper(settings: settings) - let stick = mapper.stickPosition(for: OuraMotionSample(x: 0.0, y: 0.9, z: 0.4, timestamp: 0)) - - XCTAssertEqual(stick.x, 0.0, accuracy: 1e-9) - XCTAssertEqual(stick.y, 0.6392, accuracy: 1e-9) - } - - func testOuraMotionMapperLegacyXYUsesYForVerticalTilt() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.orientation = .legacyXY - settings.deadzone = 0.0 - settings.sensitivity = 0.0 - settings.smoothing = 0.0 - - var mapper = OuraMotionMapper(settings: settings) - let stick = mapper.stickPosition(for: OuraMotionSample(x: 0.0, y: 0.4, z: 0.9, timestamp: 0)) - - XCTAssertEqual(stick.x, 0.0, accuracy: 1e-9) - XCTAssertEqual(stick.y, 0.6392, accuracy: 1e-9) - } - - func testOuraMotionMapperScreenPlaneUsesNeutralRelativeTilt() { - var settings = OuraMotionSettings() - settings.enabled = true - settings.orientation = .screenPlane - settings.deadzone = 0.0 - settings.sensitivity = 0.0 - settings.smoothing = 0.0 - - var mapper = OuraMotionMapper(settings: settings) - let neutral = mapper.mappingResult(forRawSample: OuraMotionSample(x: 0.0, y: 1.0, z: 0.0, timestamp: 0)) - let twistedRight = mapper.mappingResult(forRawSample: OuraMotionSample(x: -0.35, y: 0.94, z: 0.0, timestamp: 0.02)) - let tiltedUp = mapper.mappingResult(forRawSample: OuraMotionSample(x: 0.0, y: 0.94, z: -0.35, timestamp: 0.04)) - - XCTAssertTrue(neutral.didEstablishCenter) - XCTAssertEqual(neutral.stick, .zero) - XCTAssertGreaterThan(twistedRight.projectedInput.x, 0.30) - XCTAssertEqual(twistedRight.projectedInput.y, 0.0, accuracy: 1e-9) - XCTAssertEqual(tiltedUp.projectedInput.x, 0.0, accuracy: 1e-9) - // Sign flipped 2026-07-06 with the pose-independent up axis: this - // synthetic neutral has y > 0, the pose family whose vertical - // response was inverted in real use before the basis sign anchor. - XCTAssertLessThan(tiltedUp.projectedInput.y, -0.30) - } - - func testOuraTapDetectorIgnoresFastDirectionalSwipe() { - var detector = OuraTapDetector() - - XCTAssertFalse(detector.register(OuraMotionSample(x: 0.0, y: 0.0, z: 1.0, timestamp: 20.00))) - XCTAssertFalse(detector.register(OuraMotionSample(x: 0.9, y: 0.0, z: 1.1, timestamp: 20.02))) - XCTAssertFalse(detector.register(OuraMotionSample(x: 1.3, y: 0.0, z: 1.1, timestamp: 20.04))) - XCTAssertFalse(detector.register(OuraMotionSample(x: 1.7, y: 0.0, z: 1.1, timestamp: 20.06))) - } - - func testOuraTapDetectorIgnoresBroadHandMotionBurst() { - var detector = OuraTapDetector() - // Real ring data: the most violent stretch of a prompted "move the - // cursor, no gestures" trial from the 2026-07-05 labeled session - // (Tools/oura-calibration), including the paired-timestamp BLE frame - // structure. Broad hand motion must never register as a tap. - let samples = [ - OuraMotionSample(x: 0.08, y: 0.46, z: -0.95, timestamp: 30.000), - OuraMotionSample(x: 0.08, y: 0.45, z: -0.99, timestamp: 30.045), - OuraMotionSample(x: 0.18, y: 0.46, z: -0.99, timestamp: 30.045), - OuraMotionSample(x: 0.22, y: 0.48, z: -1.05, timestamp: 30.075), - OuraMotionSample(x: 0.08, y: 0.52, z: -0.81, timestamp: 30.075), - OuraMotionSample(x: 0.04, y: 0.50, z: -0.71, timestamp: 30.135), - OuraMotionSample(x: 0.20, y: 0.43, z: -0.64, timestamp: 30.135), - OuraMotionSample(x: 0.47, y: 0.21, z: -0.67, timestamp: 30.165), - OuraMotionSample(x: 0.70, y: 0.18, z: -0.41, timestamp: 30.165), - OuraMotionSample(x: 0.90, y: 0.27, z: -0.28, timestamp: 30.210), - OuraMotionSample(x: 0.92, y: 0.27, z: -0.63, timestamp: 30.210), - OuraMotionSample(x: 0.92, y: 0.29, z: -0.95, timestamp: 30.240), - OuraMotionSample(x: 0.90, y: 0.34, z: -0.71, timestamp: 30.240), - OuraMotionSample(x: 0.90, y: 0.35, z: -0.49, timestamp: 30.285), - OuraMotionSample(x: 0.99, y: 0.32, z: -0.56, timestamp: 30.285), - OuraMotionSample(x: 0.99, y: 0.23, z: -0.31, timestamp: 30.331), - OuraMotionSample(x: 0.89, y: 0.17, z: -0.22, timestamp: 30.331), - OuraMotionSample(x: 0.89, y: 0.09, z: 0.00, timestamp: 30.375) - ] - - for sample in samples { - XCTAssertFalse(detector.register(sample)) - } - } - - @MainActor - func testConnectionLossResetReleasesVirtualOuraInputs() { - let configDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("controllerkeys-oura-tests-\(UUID().uuidString)", isDirectory: true) - let controllerService = ControllerService(enableHardwareMonitoring: false) - controllerService.lowLatencyInputEnabled = true - let profileManager = ProfileManager(configDirectoryOverride: configDirectory) - let service = OuraRingInputService(controllerService: controllerService, profileManager: profileManager) - - controllerService.updateOuraRingStick(CGPoint(x: 0.42, y: -0.31), side: .left) - controllerService.updateOuraRingStick(CGPoint(x: -0.22, y: 0.67), side: .right) - controllerService.handleButton(.ouraTap, pressed: true) - - let leftStick = controllerService.readStorage(\.leftStick) - let rightStick = controllerService.readStorage(\.rightStick) - XCTAssertEqual(Double(leftStick.x), 0.42, accuracy: 1e-6) - XCTAssertEqual(Double(leftStick.y), -0.31, accuracy: 1e-6) - XCTAssertEqual(Double(rightStick.x), -0.22, accuracy: 1e-6) - XCTAssertEqual(Double(rightStick.y), 0.67, accuracy: 1e-6) - XCTAssertTrue(controllerService.readStorage(\.activeButtons).contains(.ouraTap)) - - service.resetInputSessionForConnectionLoss() - - XCTAssertEqual(controllerService.readStorage(\.leftStick), .zero) - XCTAssertEqual(controllerService.readStorage(\.rightStick), .zero) - XCTAssertFalse(controllerService.readStorage(\.activeButtons).contains(.ouraTap)) - controllerService.cleanup() - try? FileManager.default.removeItem(at: configDirectory) - } - - @MainActor - func testConnectionFailureClearsConnectedStateAndVirtualInputs() { - let configDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("controllerkeys-oura-tests-\(UUID().uuidString)", isDirectory: true) - let controllerService = ControllerService(enableHardwareMonitoring: false) - controllerService.lowLatencyInputEnabled = true - let profileManager = ProfileManager(configDirectoryOverride: configDirectory) - let service = OuraRingInputService(controllerService: controllerService, profileManager: profileManager) - - controllerService.setOuraRingConnected(true) - controllerService.updateOuraRingStick(CGPoint(x: 0.35, y: 0.46), side: .left) - controllerService.handleButton(.ouraDoubleTap, pressed: true) - - service.failCurrentConnection("wrong auth key") - - XCTAssertEqual(service.status, .authFailed("wrong auth key")) - XCTAssertFalse(controllerService.isOuraRingConnected) - XCTAssertEqual(controllerService.readStorage(\.leftStick), .zero) - XCTAssertFalse(controllerService.readStorage(\.activeButtons).contains(.ouraDoubleTap)) - controllerService.cleanup() - try? FileManager.default.removeItem(at: configDirectory) - } - - @MainActor - func testBluetoothUnavailableClearsConnectedStateAndVirtualInputs() { - let configDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("controllerkeys-oura-tests-\(UUID().uuidString)", isDirectory: true) - let controllerService = ControllerService(enableHardwareMonitoring: false) - controllerService.lowLatencyInputEnabled = true - let profileManager = ProfileManager(configDirectoryOverride: configDirectory) - let service = OuraRingInputService(controllerService: controllerService, profileManager: profileManager) - - controllerService.setOuraRingConnected(true) - controllerService.updateOuraRingStick(CGPoint(x: -0.4, y: 0.25), side: .right) - controllerService.handleButton(.ouraTripleTap, pressed: true) - - service.handleBluetoothUnavailable("powered off") - - XCTAssertEqual(service.status, .bluetoothUnavailable("powered off")) - XCTAssertFalse(controllerService.isOuraRingConnected) - XCTAssertEqual(controllerService.readStorage(\.rightStick), .zero) - XCTAssertFalse(controllerService.readStorage(\.activeButtons).contains(.ouraTripleTap)) - controllerService.cleanup() - try? FileManager.default.removeItem(at: configDirectory) - } -} - -final class OuraMotionTraceFormatTests: XCTestCase { - func testSampleLineIsValidJSONWithExpectedFields() throws { - let line = OuraMotionTraceFormat.sampleLine( - wallTime: 1783272960.123456, - sample: OuraMotionSample(x: 0.0125, y: -0.98, z: 1.4049, timestamp: 773430000.25), - projected: CGPoint(x: 0.05, y: -0.0213) - ) - - let object = try XCTUnwrap( - JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] - ) - XCTAssertEqual(object["type"] as? String, "sample") - XCTAssertEqual(try XCTUnwrap(object["t"] as? Double), 1783272960.123456, accuracy: 1e-5) - XCTAssertEqual(try XCTUnwrap(object["ct"] as? Double), 773430000.25, accuracy: 1e-5) - XCTAssertEqual(try XCTUnwrap(object["x"] as? Double), 0.0125, accuracy: 1e-4) - XCTAssertEqual(try XCTUnwrap(object["y"] as? Double), -0.98, accuracy: 1e-4) - XCTAssertEqual(try XCTUnwrap(object["z"] as? Double), 1.4049, accuracy: 1e-4) - XCTAssertEqual(try XCTUnwrap(object["px"] as? Double), 0.05, accuracy: 1e-4) - XCTAssertEqual(try XCTUnwrap(object["py"] as? Double), -0.0213, accuracy: 1e-4) - } - - func testEventLineWithDetailIsValidJSON() throws { - let line = OuraMotionTraceFormat.eventLine( - wallTime: 1783272961.5, - timestamp: 773430001.5, - name: "tap-candidate", - detail: "accelerometer spike" - ) - - let object = try XCTUnwrap( - JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] - ) - XCTAssertEqual(object["type"] as? String, "event") - XCTAssertEqual(object["name"] as? String, "tap-candidate") - XCTAssertEqual(object["detail"] as? String, "accelerometer spike") - XCTAssertEqual(try XCTUnwrap(object["t"] as? Double), 1783272961.5, accuracy: 1e-5) - } - - func testEventLineEscapesStringsAsJSON() throws { - let detail = #"ml "tap" \ backslash"# - let line = OuraMotionTraceFormat.eventLine( - wallTime: 1783272961.5, - timestamp: 773430001.5, - name: #"tap "candidate""#, - detail: detail - ) - - let object = try XCTUnwrap( - JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] - ) - XCTAssertEqual(object["name"] as? String, #"tap "candidate""#) - XCTAssertEqual(object["detail"] as? String, detail) - } - - func testEventLineWithoutDetailOmitsField() throws { - let line = OuraMotionTraceFormat.eventLine( - wallTime: 1783272962.0, - timestamp: 773430002.0, - name: "center" - ) - - let object = try XCTUnwrap( - JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] - ) - XCTAssertEqual(object["name"] as? String, "center") - XCTAssertNil(object["detail"]) - } -} - -final class OuraGestureEventClassifierTests: XCTestCase { - func testImpulseDetectorConfirmsSpikeAndHonorsSeparation() { - var detector = OuraImpulseDetector() - - XCTAssertNil(detector.register(OuraMotionSample(x: 0, y: 0, z: 1.0, timestamp: 10.00))) - XCTAssertNil(detector.register(OuraMotionSample(x: 0.02, y: 0, z: 1.0, timestamp: 10.04))) - XCTAssertNil(detector.register(OuraMotionSample(x: 0.9, y: 0.1, z: 1.4, timestamp: 10.08))) - // spike confirmed one jerk-series entry later - XCTAssertEqual(detector.register(OuraMotionSample(x: 0.05, y: 0, z: 1.0, timestamp: 10.12)), 10.08) - // a second peak inside the 0.18s separation must not confirm - XCTAssertNil(detector.register(OuraMotionSample(x: 0.9, y: 0.1, z: 1.4, timestamp: 10.16))) - XCTAssertNil(detector.register(OuraMotionSample(x: 0.05, y: 0, z: 1.0, timestamp: 10.20))) - } - - func testImpulseDetectorSkipsPairedFrameSamples() { - var detector = OuraImpulseDetector() - - XCTAssertNil(detector.register(OuraMotionSample(x: 0, y: 0, z: 1.0, timestamp: 20.00))) - // second sample of the same BLE frame (<1ms apart) adds no jerk entry - XCTAssertNil(detector.register(OuraMotionSample(x: 5.0, y: 5.0, z: 5.0, timestamp: 20.0004))) - XCTAssertNil(detector.register(OuraMotionSample(x: 0.01, y: 0, z: 1.0, timestamp: 20.04))) - XCTAssertNil(detector.register(OuraMotionSample(x: 0.02, y: 0, z: 1.0, timestamp: 20.08))) - XCTAssertNil(detector.register(OuraMotionSample(x: 0.03, y: 0, z: 1.0, timestamp: 20.12))) - } - - func testMotionWindowBufferResamplesLinearRamp() throws { - var buffer = OuraMotionWindowBuffer() - // linear ramp: x goes 0→1 over exactly the window span around center 10.0 - let lo = 10.0 - OuraMotionWindowBuffer.preSpan - let hi = 10.0 + OuraMotionWindowBuffer.postSpan - for i in 0...64 { - let t = lo + (hi - lo) * Double(i) / 64.0 - let v = Double(i) / 64.0 - buffer.append(OuraMotionSample(x: v, y: -v, z: 1.0, timestamp: t), - projected: CGPoint(x: v * 2, y: 0)) - } - - let window = try XCTUnwrap(buffer.window(around: 10.0)) - XCTAssertEqual(window.count, OuraMotionWindowBuffer.steps) - XCTAssertEqual(window[0][0], 0.0, accuracy: 0.02) - XCTAssertEqual(window[31][0], 1.0, accuracy: 0.02) - XCTAssertEqual(window[16][0], Double(16) / 31.0, accuracy: 0.04) - XCTAssertEqual(window[16][1], -window[16][0], accuracy: 1e-9) - XCTAssertEqual(window[16][3], window[16][0] * 2, accuracy: 1e-9) - } - - func testMotionWindowBufferRejectsSparseCoverage() { - var buffer = OuraMotionWindowBuffer() - buffer.append(OuraMotionSample(x: 0, y: 0, z: 1, timestamp: 9.99), projected: .zero) - buffer.append(OuraMotionSample(x: 0, y: 0, z: 1, timestamp: 10.01), projected: .zero) - XCTAssertNil(buffer.window(around: 10.0)) - } - - // Process-lifetime instance: deallocating a classifier inside XCTest's - // memory-check scope exercised an isolated-deinit runtime bug (see the - // nonisolated note on the class); a static also mirrors production, where - // the service holds one classifier for the app's lifetime. - private static let sharedClassifier = OuraGestureEventClassifier() - - func testClassifierPredictsRealFlickAndTapWindows() { - let classifier = Self.sharedClassifier - classifier.loadIfNeeded() - let deadline = Date().addingTimeInterval(10) - while !classifier.isAvailable && Date() < deadline { - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - XCTAssertTrue(classifier.isAvailable, "OuraGestureClassifier.mlmodelc missing or failed to load from bundle") - - // Real windows from the 2026-07-05 labeled session (events.ndjson). - let flickResult = classifier.classify(window: Self.flickLeftWindow) - XCTAssertEqual(flickResult?.event, .flickLeft) - XCTAssertGreaterThan(flickResult?.confidence ?? 0, 0.97, "real flicks measured ≥0.98 confidence") - XCTAssertEqual(classifier.classify(window: Self.tapWindow)?.event, .tap) - XCTAssertNil(classifier.classify(window: Array(repeating: [0.0, 0.0, 0.0, 0.0], count: OuraMotionWindowBuffer.steps))) - } - - private static let flickLeftWindow: [[Double]] = [ - [0.2383, -0.2868, -0.9815, -0.139, 0.4406], - [0.2455, -0.2836, -0.9862, -0.1461, 0.4461], - [0.2287, -0.2772, -0.9762, -0.1306, 0.4442], - [0.2119, -0.2707, -0.9661, -0.1152, 0.4423], - [0.2046, -0.2611, -0.9455, -0.1101, 0.4358], - [0.1905, -0.2589, -0.9405, -0.0968, 0.4341], - [0.1346, -0.2594, -0.9581, -0.0397, 0.4454], - [0.0935, -0.273, -0.9781, 0.0037, 0.4487], - [0.0525, -0.2866, -0.9981, 0.0471, 0.4519], - [-0.0714, -0.2693, -1.0847, 0.1759, 0.5225], - [-0.1802, -0.2766, -1.1164, 0.287, 0.5381], - [-0.2889, -0.284, -1.1481, 0.3982, 0.5538], - [-1.0676, -0.2884, -1.2029, 1.1772, 0.587], - [-1.1045, -0.4403, -1.0634, 1.2135, 0.3809], - [-0.2479, -0.8955, -0.7008, 0.3653, -0.2002], - [0.0813, -0.8462, -0.649, 0.0304, -0.198], - [0.4105, -0.7969, -0.5972, -0.3045, -0.1958], - [0.9689, -0.6454, -0.9311, -0.8441, 0.1397], - [0.9961, -0.6277, -1.0119, -0.866, 0.2068], - [1.0233, -0.6101, -1.0927, -0.8879, 0.2738], - [0.7829, -0.435, -1.2329, -0.6502, 0.4978], - [0.3613, -0.4554, -0.798, -0.2639, 0.1927], - [0.3627, -0.4288, -0.8027, -0.2669, 0.2156], - [0.3642, -0.4022, -0.8073, -0.2698, 0.2385], - [0.4255, -0.359, -0.8462, -0.3307, 0.2966], - [0.422, -0.3475, -0.8445, -0.3282, 0.3041], - [0.4185, -0.3359, -0.8427, -0.3257, 0.3115], - [0.2463, -0.3199, -0.8288, -0.1566, 0.3141], - [0.2034, -0.2973, -0.8197, -0.1164, 0.325], - [0.0602, -0.2369, -0.831, 0.0227, 0.3774], - [0.0481, -0.2365, -0.838, 0.0352, 0.3825], - [0.036, -0.236, -0.845, 0.0478, 0.3875] - ] - - private static let tapWindow: [[Double]] = [ - [0.2947, -0.3983, -1.1207, 0.0819, -0.0381], - [0.2937, -0.2675, -1.1154, 0.0692, 0.0849], - [0.3475, -0.2397, -1.1878, 0.0365, 0.1334], - [0.4012, -0.212, -1.2602, 0.0037, 0.1819], - [0.2246, -0.0494, -1.4993, 0.2263, 0.4096], - [0.3262, -0.2055, -1.1042, 0.0293, 0.1405], - [0.2355, -0.1592, -0.5084, -0.0618, 0.0032], - [0.2327, -0.0507, -0.6809, -0.019, 0.1591], - [0.2298, 0.0578, -0.8534, 0.0237, 0.3149], - [0.2958, 0.1884, -0.9443, -0.0248, 0.4671], - [0.2745, 0.1989, -0.8844, -0.0229, 0.4588], - [0.2774, 0.1031, -0.7038, -0.0693, 0.3126], - [0.288, 0.0209, -0.4989, -0.1313, 0.1719], - [0.2987, -0.0614, -0.2941, -0.1933, 0.0311], - [-0.0968, -0.3795, -0.6882, 0.3274, -0.1518], - [-0.2021, 0.5632, -2.9792, 1.0057, 1.4437], - [-0.3074, 1.5059, -5.2701, 1.684, 3.0393], - [-0.0471, -0.649, -1.0909, 0.4222, -0.2859], - [0.2931, -0.245, -1.1167, 0.0681, 0.1068], - [0.3067, -0.2571, -1.1249, 0.0587, 0.0977], - [0.3202, -0.2693, -1.1331, 0.0493, 0.0886], - [0.3203, 0.0835, -1.1701, 0.0272, 0.4358], - [0.307, -0.0, -1.0317, 0.0074, 0.3142], - [0.2937, -0.0835, -0.8934, -0.0124, 0.1925], - [0.2242, -0.1538, -0.7263, 0.0119, 0.0747], - [0.2327, -0.135, -0.7696, 0.0146, 0.1058], - [0.2411, -0.1162, -0.813, 0.0174, 0.137], - [0.2636, -0.0599, -0.8783, 0.0098, 0.2105], - [0.2613, -0.0436, -0.9004, 0.0168, 0.2327], - [0.2591, -0.0274, -0.9225, 0.0238, 0.2549], - [0.2917, 0.0042, -1.016, 0.017, 0.3134], - [0.2862, -0.0358, -0.9589, 0.0094, 0.2579] - ] -} - -final class OuraAutoRecenterMonitorTests: XCTestCase { - private func makeMonitor() -> OuraAutoRecenterMonitor { - var monitor = OuraAutoRecenterMonitor() - monitor.enabled = true - return monitor - } - - private let neutral = OuraMotionSample(x: 0, y: 0.7, z: 0.7, timestamp: 0) - - /// Feeds still samples at `pose` from `start` at ~48Hz for `duration`; - /// returns the first snap, if any. - private func feedStill( - _ monitor: inout OuraAutoRecenterMonitor, - pose: (Double, Double, Double), - from start: CFAbsoluteTime, - duration: CFTimeInterval - ) -> (neutral: OuraMotionSample, driftDegrees: Double)? { - var t = start - while t < start + duration { - let sample = OuraMotionSample(x: pose.0, y: pose.1, z: pose.2, timestamp: t) - if let snap = monitor.register(sample, neutral: neutral) { - return snap - } - t += 0.021 - } - return nil - } - - func testStaleCenterAtRestSnapsAfterConfirmWindow() { - var monitor = makeMonitor() - // 60° away from neutral, |a| ≈ 0.99 — provably stale at rest. - let snap = feedStill(&monitor, pose: (0.7, 0.7, 0), from: 10.0, duration: 2.5) - XCTAssertNotNil(snap) - XCTAssertEqual(snap?.driftDegrees ?? 0, 60, accuracy: 3) - XCTAssertEqual(snap?.neutral.x ?? 0, 0.7, accuracy: 0.01) - // The snap must not fire before the confirm window has elapsed. - XCTAssertGreaterThanOrEqual(snap?.neutral.timestamp ?? 0, 10.0 + monitor.confirmDuration) - } - - func testActiveMotionNeverSnaps() { - var monitor = makeMonitor() - var t: CFAbsoluteTime = 10.0 - var flip = false - while t < 14.0 { - // Inter-sample delta ~0.06 — above the stillness bar. - let x = 0.7 + (flip ? 0.035 : -0.035) - XCTAssertNil(monitor.register( - OuraMotionSample(x: x, y: 0.7, z: 0, timestamp: t), neutral: neutral)) - flip.toggle() - t += 0.021 - } - } - - func testDriftBelowThresholdDoesNotSnap() { - var monitor = makeMonitor() - // ~8° from neutral — inside the healthy band. - let pose = (0.0, 0.7 * cos(8 * Double.pi / 180) - 0.7 * sin(8 * Double.pi / 180), - 0.7 * sin(8 * Double.pi / 180) + 0.7 * cos(8 * Double.pi / 180)) - XCTAssertNil(feedStill(&monitor, pose: pose, from: 10.0, duration: 3.0)) - } - - func testNonGravityMagnitudeDoesNotSnap() { - var monitor = makeMonitor() - // Large angle but |a| = 0.42 — not a pure-gravity rest window. - XCTAssertNil(feedStill(&monitor, pose: (0.3, 0.3, 0), from: 10.0, duration: 3.0)) - } - - func testHoldOffDefersSnapUntilExpiry() { - var monitor = makeMonitor() - monitor.holdOff(until: 13.0) - XCTAssertNil(feedStill(&monitor, pose: (0.7, 0.7, 0), from: 10.0, duration: 2.9)) - let snap = feedStill(&monitor, pose: (0.7, 0.7, 0), from: 12.9, duration: 0.5) - XCTAssertNotNil(snap) - XCTAssertGreaterThanOrEqual(snap?.neutral.timestamp ?? 0, 13.0) - } - - func testCooldownBlocksBackToBackSnaps() { - var monitor = makeMonitor() - XCTAssertNotNil(feedStill(&monitor, pose: (0.7, 0.7, 0), from: 10.0, duration: 2.0)) - // New still pose right after — still stale vs neutral, but inside cooldown. - XCTAssertNil(feedStill(&monitor, pose: (0, 0, 1), from: 12.1, duration: 2.5)) - // After the cooldown expires the ongoing still run may snap again. - XCTAssertNotNil(feedStill(&monitor, pose: (0, 0, 1), from: 14.7, duration: 2.5)) - } - - func testDisabledMonitorNeverSnaps() { - var monitor = makeMonitor() - monitor.enabled = false - XCTAssertNil(feedStill(&monitor, pose: (0.7, 0.7, 0), from: 10.0, duration: 4.0)) - } - - func testMapperSnapsStaleCenterAndZeroesProjection() { - var settings = OuraMotionSettings.default - settings.enabled = true - settings.orientation = .screenPlane - var mapper = OuraMotionMapper(settings: settings) - mapper.autoRecenterMonitor.enabled = true - - // First sample establishes the center at (0, 0.7, 0.7). - let first = mapper.mappingResult(forRawSample: OuraMotionSample(x: 0, y: 0.7, z: 0.7, timestamp: 0)) - XCTAssertTrue(first.didEstablishCenter) - - // Hand comes to rest 60° away — projection reads a large phantom - // deflection until the monitor snaps. - var t: CFAbsoluteTime = 0.021 - var snapDrift: Double? - var lastProjection = CGPoint.zero - while t < 3.0 { - let result = mapper.mappingResult(forRawSample: OuraMotionSample(x: 0.7, y: 0.7, z: 0, timestamp: t)) - if let drift = result.autoRecenterDriftDegrees { - snapDrift = drift - } - lastProjection = result.projectedInput - t += 0.021 - } - XCTAssertEqual(snapDrift ?? 0, 60, accuracy: 3) - XCTAssertLessThan(hypot(lastProjection.x, lastProjection.y), 0.05, - "projection should read neutral after the snap") - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/PerStickTuningTests.swift b/XboxControllerMapper/XboxControllerMapperTests/PerStickTuningTests.swift deleted file mode 100644 index 9d30fc99..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/PerStickTuningTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import XCTest -@testable import ControllerKeys - -/// Coverage for the per-stick tuning model: independent left/right response, -/// legacy-config migration, new-format round-trips, downgrade dual-write, and -/// the response curves that moved onto `StickTuning`. -final class PerStickTuningTests: XCTestCase { - - /// The headline fix (Discord feedback): two sticks both in mouse mode can have - /// independent sensitivity. Previously both read one shared `mouseSensitivity`. - func testLeftAndRightMouseSensitivityAreIndependent() { - var settings = JoystickSettings() - settings.leftStick.mode = .mouse - settings.rightStick.mode = .mouse - settings.leftStick.mouseSensitivity = 0.1 - settings.rightStick.mouseSensitivity = 0.9 - - XCTAssertNotEqual(settings.leftStick.mouseSensitivity, settings.rightStick.mouseSensitivity) - XCTAssertLessThan( - settings.leftStick.mouseMultiplier, - settings.rightStick.mouseMultiplier, - "A lower-sensitivity left stick must yield a smaller mouse multiplier than the right." - ) - } - - /// A legacy config (shared function-keyed fields) fans the same mouse/scroll values - /// into BOTH sticks, preserving the old behavior while becoming independently editable. - func testLegacyConfigMigratesIntoBothSticks() throws { - let json = #""" - { - "mouseSensitivity": 0.3, - "scrollSensitivity": 0.7, - "mouseDeadzone": 0.2, - "scrollDeadzone": 0.25, - "leftStickMode": "mouse", - "rightStickMode": "scroll" - } - """#.data(using: .utf8)! - - let decoded = try JSONDecoder().decode(JoystickSettings.self, from: json) - - // Both sticks inherit the shared legacy values... - XCTAssertEqual(decoded.leftStick.mouseSensitivity, 0.3, accuracy: 1e-9) - XCTAssertEqual(decoded.rightStick.mouseSensitivity, 0.3, accuracy: 1e-9) - XCTAssertEqual(decoded.leftStick.scrollSensitivity, 0.7, accuracy: 1e-9) - XCTAssertEqual(decoded.rightStick.scrollSensitivity, 0.7, accuracy: 1e-9) - XCTAssertEqual(decoded.leftStick.mouseDeadzone, 0.2, accuracy: 1e-9) - XCTAssertEqual(decoded.rightStick.scrollDeadzone, 0.25, accuracy: 1e-9) - // ...but keep their own modes. - XCTAssertEqual(decoded.leftStick.mode, .mouse) - XCTAssertEqual(decoded.rightStick.mode, .scroll) - } - - /// New-format encode/decode preserves independent per-stick tuning. - func testNewFormatRoundTripPreservesIndependentSticks() throws { - var settings = JoystickSettings() - settings.leftStick.mouseSensitivity = 0.15 - settings.rightStick.mouseSensitivity = 0.85 - settings.rightStick.mode = .mouse - - let data = try JSONEncoder().encode(settings) - let decoded = try JSONDecoder().decode(JoystickSettings.self, from: data) - - XCTAssertEqual(decoded.leftStick.mouseSensitivity, 0.15, accuracy: 1e-9) - XCTAssertEqual(decoded.rightStick.mouseSensitivity, 0.85, accuracy: 1e-9) - XCTAssertEqual(decoded.rightStick.mode, .mouse) - } - - /// Encoding writes the legacy flat keys too (downgrade safety): an older build reads - /// the left stick's mouse values and the right stick's scroll values. - func testEncodeWritesLegacyCompatibilityKeys() throws { - var settings = JoystickSettings() - settings.leftStick.mouseSensitivity = 0.42 - settings.rightStick.scrollSensitivity = 0.66 - - let data = try JSONEncoder().encode(settings) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - XCTAssertEqual(object["mouseSensitivity"] as? Double, 0.42, "Legacy key mirrors left stick's mouse sensitivity.") - XCTAssertEqual(object["scrollSensitivity"] as? Double, 0.66, "Legacy key mirrors right stick's scroll sensitivity.") - XCTAssertNotNil(object["leftStick"], "New per-stick representation is also written.") - XCTAssertNotNil(object["rightStick"], "New per-stick representation is also written.") - } - - /// Response curves moved onto StickTuning keep the documented mapping. - func testStickTuningResponseCurves() { - var tuning = StickTuning(mode: .mouse) - tuning.mouseSensitivity = 0.0 - XCTAssertEqual(tuning.mouseMultiplier, 2.0, accuracy: 1e-9) - tuning.mouseSensitivity = 1.0 - XCTAssertEqual(tuning.mouseMultiplier, 120.0, accuracy: 1e-9) - tuning.scrollSensitivity = 1.0 - XCTAssertEqual(tuning.scrollMultiplier, 30.0, accuracy: 1e-9) - tuning.mouseAcceleration = 0.5 - XCTAssertEqual(tuning.mouseAccelerationExponent, 2.0, accuracy: 1e-9) - tuning.scrollAcceleration = 1.0 - XCTAssertEqual(tuning.scrollAccelerationExponent, 2.5, accuracy: 1e-9) - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/PipelineCharacterizationTests.swift b/XboxControllerMapper/XboxControllerMapperTests/PipelineCharacterizationTests.swift index 51d20162..efe22f4d 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/PipelineCharacterizationTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/PipelineCharacterizationTests.swift @@ -41,7 +41,17 @@ final class PipelineCharacterizationTests: XCTestCase { await waitForInputQueue() await MainActor.run { - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil + controllerService?.onLeftStickMoved = nil + controllerService?.onRightStickMoved = nil + controllerService?.onTouchpadMoved = nil + controllerService?.onTouchpadGesture = nil + controllerService?.onTouchpadTap = nil + controllerService?.onTouchpadTwoFingerTap = nil + controllerService?.onTouchpadLongTap = nil + controllerService?.onTouchpadTwoFingerLongTap = nil controllerService?.cleanup() mappingEngine = nil @@ -80,7 +90,7 @@ final class PipelineCharacterizationTests: XCTestCase { // MARK: - Chord Detection Characterization - /// A single button press (no chord partner) should emit one input event. + /// A single button press (no chord partner) should fire onButtonPressed exactly once. func testSingleButtonFiresOnButtonPressed() async throws { await MainActor.run { let profile = Profile( @@ -108,7 +118,7 @@ final class PipelineCharacterizationTests: XCTestCase { XCTAssertEqual(pressCount, 1, "Single button should fire mapped key exactly once") } - /// Two buttons pressed within chord window should emit one chord event, not individual presses. + /// Two buttons pressed within chord window should fire onChordDetected (not individual presses). func testTwoButtonsWithinWindowFiresChord() async throws { await MainActor.run { let profile = Profile( @@ -218,7 +228,7 @@ final class PipelineCharacterizationTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.motionGesture(.tiltBack)) + controllerService.onMotionGesture?(.tiltBack) } await waitForInputQueue() @@ -244,7 +254,7 @@ final class PipelineCharacterizationTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap?() } await waitForPollingQueue() @@ -268,7 +278,7 @@ final class PipelineCharacterizationTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadTwoFingerTap) + controllerService.onTouchpadTwoFingerTap?() } await waitForPollingQueue() @@ -325,7 +335,7 @@ final class PipelineCharacterizationTests: XCTestCase { try? await Task.sleep(nanoseconds: 10_000_000) await MainActor.run { - controllerService.emitInputEvent(.touchpadMoved(CGPoint(x: 0.5, y: 0.3))) + controllerService.onTouchpadMoved?(CGPoint(x: 0.5, y: 0.3)) } await waitForPollingQueue() diff --git a/XboxControllerMapper/XboxControllerMapperTests/PointerLockMousePolicyTests.swift b/XboxControllerMapper/XboxControllerMapperTests/PointerLockMousePolicyTests.swift deleted file mode 100644 index 09212c78..00000000 --- a/XboxControllerMapper/XboxControllerMapperTests/PointerLockMousePolicyTests.swift +++ /dev/null @@ -1,107 +0,0 @@ -import XCTest -@testable import ControllerKeys - -final class PointerLockMousePolicyTests: XCTestCase { - - private func decide( - mode: PointerLockMouseMode, - cursorVisible: Bool?, - zoomActive: Bool = false, - relayActive: Bool = false, - appHide: Bool = false - ) -> Bool { - PointerLockMousePolicy.shouldUseRelativeMovement( - mode: mode, - cursorVisible: cursorVisible, - zoomActive: zoomActive, - universalControlRelayActive: relayActive, - appInitiatedCursorHide: appHide - ) - } - - // MARK: - Off mode - - func testOff_neverRelative_evenWhenCursorHidden() { - XCTAssertFalse(decide(mode: .off, cursorVisible: false), - "Off mode must preserve legacy absolute behavior") - } - - // MARK: - Always mode - - func testAlways_relative_regardlessOfCursorVisibility() { - XCTAssertTrue(decide(mode: .always, cursorVisible: true)) - XCTAssertTrue(decide(mode: .always, cursorVisible: false)) - XCTAssertTrue(decide(mode: .always, cursorVisible: nil), - "Always must work even when visibility detection is unavailable") - } - - func testAlways_notSuppressedByAppCursorHide() { - XCTAssertTrue(decide(mode: .always, cursorVisible: false, appHide: true), - "The OSK guard is a heuristic guard for auto, not a semantic override for always") - } - - // MARK: - Auto mode - - func testAuto_relative_whenCursorHidden() { - XCTAssertTrue(decide(mode: .auto, cursorVisible: false), - "Pointer lock hides the cursor; auto must switch to relative") - } - - func testAuto_absolute_whenCursorVisible() { - XCTAssertFalse(decide(mode: .auto, cursorVisible: true)) - } - - func testAuto_absolute_whenDetectionUnavailable() { - XCTAssertFalse(decide(mode: .auto, cursorVisible: nil), - "If CGCursorIsVisible stops resolving, auto degrades to absolute") - } - - func testAuto_absolute_whenAppInitiatedCursorHide() { - XCTAssertFalse(decide(mode: .auto, cursorVisible: false, appHide: true), - "OSK navigation mode hides the cursor; that must not read as pointer lock") - } - - // MARK: - Zoom and Universal Control relay always win - - func testZoomActive_suppressesRelative_inAllModes() { - for mode in PointerLockMouseMode.allCases { - XCTAssertFalse(decide(mode: mode, cursorVisible: false, zoomActive: true), - "Accessibility Zoom needs the absolute path (mode: \(mode))") - } - } - - func testRelayActive_suppressesRelative_inAllModes() { - for mode in PointerLockMouseMode.allCases { - XCTAssertFalse(decide(mode: mode, cursorVisible: false, relayActive: true), - "Universal Control handoff needs the absolute path (mode: \(mode))") - } - } - - // MARK: - Visibility poll throttle - - func testVisibilityPoll_firstPollAlwaysRuns() { - XCTAssertTrue(PointerLockMousePolicy.shouldRefreshCursorVisibility(now: 100, lastPoll: nil)) - } - - func testVisibilityPoll_throttledWithinInterval() { - let interval = PointerLockMousePolicy.cursorVisibilityPollInterval - XCTAssertFalse(PointerLockMousePolicy.shouldRefreshCursorVisibility( - now: 100 + interval * 0.5, lastPoll: 100)) - } - - func testVisibilityPoll_runsAfterInterval() { - let interval = PointerLockMousePolicy.cursorVisibilityPollInterval - XCTAssertTrue(PointerLockMousePolicy.shouldRefreshCursorVisibility( - now: 100 + interval, lastPoll: 100)) - } - - // MARK: - Cursor visibility detection (runtime dlsym contract) - - func testCursorVisibilityDetection_resolvesOnThisOS() { - // CGCursorIsVisible is SDK-unavailable but runtime-exported (SkyLight - // SLCursorIsVisible re-export). If this fails on a new macOS, auto-detection - // is silently off and the release notes / UI copy need updating. - XCTAssertTrue(CursorVisibility.isDetectionSupported) - XCTAssertNotNil(CursorVisibility.isCursorVisible()) - } -} diff --git a/XboxControllerMapper/XboxControllerMapperTests/PrecomputedLookupCacheTests.swift b/XboxControllerMapper/XboxControllerMapperTests/PrecomputedLookupCacheTests.swift index e9059223..08bb0c6c 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/PrecomputedLookupCacheTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/PrecomputedLookupCacheTests.swift @@ -1,134 +1,304 @@ import XCTest +import CoreGraphics @testable import ControllerKeys -/// Tests for the profile-derived lookup tables used by MappingEngine to avoid +/// Tests for the precomputed lookup caches used by MappingEngine to avoid /// linear scans on every button press. +/// +/// These tests verify the cache-building logic in isolation (using the same +/// expressions that MappingEngine.setupBindings uses) rather than instantiating +/// EngineState directly, which requires the full app module to be loaded. final class PrecomputedLookupCacheTests: XCTestCase { - func testMappingProfileIndexBuildsLookupTablesFromProfile() { - let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 10, hint: "first") - let chord2 = ChordMapping( - buttons: [.leftBumper, .rightBumper], - keyCode: 20, - modifiers: ModifierFlags(command: true), - hint: "second" - ) - let sequence = SequenceMapping(steps: [.dpadDown, .dpadDown, .a], keyCode: 30) - let layerID = UUID() - let layer = Layer( - id: layerID, - name: "Navigation", - activatorButton: .leftThumbstick, - buttonMappings: [.a: KeyMapping(keyCode: 8)] - ) - let profile = Profile( - name: "Indexed", - chordMappings: [chord1, chord2], - sequenceMappings: [sequence], - layers: [layer] - ) - let index = MappingProfileIndex(profile: profile) + // MARK: - Chord Participant Cache - XCTAssertEqual(index.chordParticipantButtons, Set([.a, .b, .leftBumper, .rightBumper] as [ControllerButton])) - XCTAssertEqual(index.sequenceParticipantButtons, Set([.dpadDown, .a] as [ControllerButton])) + func testChordParticipantButtons_emptyChords() { + let chords: [ChordMapping] = [] + let result: Set = Set(chords.flatMap { $0.buttons }) + XCTAssertTrue(result.isEmpty) + } + + func testChordParticipantButtons_containsAllButtons() { + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 0) + let chord2 = ChordMapping(buttons: [.x, .y], keyCode: 1) + let result = Set([chord1, chord2].flatMap { $0.buttons }) + + XCTAssertTrue(result.contains(.a)) + XCTAssertTrue(result.contains(.b)) + XCTAssertTrue(result.contains(.x)) + XCTAssertTrue(result.contains(.y)) + XCTAssertEqual(result.count, 4) + } + + func testChordParticipantButtons_buttonInMultipleChords() { + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 0) + let chord2 = ChordMapping(buttons: [.a, .x], keyCode: 1) + let result = Set([chord1, chord2].flatMap { $0.buttons }) + + XCTAssertTrue(result.contains(.a)) + XCTAssertTrue(result.contains(.b)) + XCTAssertTrue(result.contains(.x)) + // .a appears in both chords but Set deduplicates + XCTAssertEqual(result.count, 3) + } + + func testChordParticipantButtons_profileChangeRebuilds() { + // First profile + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 0) + var cache = Set([chord1].flatMap { $0.buttons }) + XCTAssertTrue(cache.contains(.a)) + XCTAssertTrue(cache.contains(.b)) + XCTAssertFalse(cache.contains(.x)) + + // Simulate profile change - rebuild cache + let chord2 = ChordMapping(buttons: [.x, .y], keyCode: 1) + cache = Set([chord2].flatMap { $0.buttons }) + XCTAssertFalse(cache.contains(.a)) + XCTAssertFalse(cache.contains(.b)) + XCTAssertTrue(cache.contains(.x)) + XCTAssertTrue(cache.contains(.y)) + } + + func testChordParticipantButtons_buttonNotInAnyChord() { + let chord = ChordMapping(buttons: [.a, .b], keyCode: 0) + let result = Set([chord].flatMap { $0.buttons }) + + XCTAssertFalse(result.contains(.x)) + XCTAssertFalse(result.contains(.y)) + XCTAssertFalse(result.contains(.leftBumper)) + } + + func testChordParticipantButtons_fromProfile() { + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 0) + let chord2 = ChordMapping(buttons: [.leftBumper, .rightBumper], keyCode: 1) + let profile = Profile(name: "Test", chordMappings: [chord1, chord2]) + + let result = Set(profile.chordMappings.flatMap { $0.buttons }) + XCTAssertEqual(result.count, 4) + XCTAssertTrue(result.contains(.a)) + XCTAssertTrue(result.contains(.b)) + XCTAssertTrue(result.contains(.leftBumper)) + XCTAssertTrue(result.contains(.rightBumper)) + } + + // MARK: - Sequence Participant Cache + + func testSequenceParticipantButtons_emptySequences() { + let sequences: [SequenceMapping] = [] + let result: Set = Set(sequences.flatMap { $0.steps }) + XCTAssertTrue(result.isEmpty) + } + + func testSequenceParticipantButtons_containsAllStepButtons() { + let seq1 = SequenceMapping(steps: [.dpadDown, .dpadDown, .a], keyCode: 0) + let seq2 = SequenceMapping(steps: [.b, .x], keyCode: 1) + let result = Set([seq1, seq2].flatMap { $0.steps }) + + XCTAssertTrue(result.contains(.dpadDown)) + XCTAssertTrue(result.contains(.a)) + XCTAssertTrue(result.contains(.b)) + XCTAssertTrue(result.contains(.x)) + // dpadDown appears twice in seq1 but Set deduplicates + XCTAssertEqual(result.count, 4) + } + + func testSequenceParticipantButtons_multiStepSequence() { + let seq = SequenceMapping(steps: [.leftBumper, .rightBumper, .leftTrigger, .rightTrigger], keyCode: 0) + let result = Set([seq].flatMap { $0.steps }) + + XCTAssertTrue(result.contains(.leftBumper)) + XCTAssertTrue(result.contains(.rightBumper)) + XCTAssertTrue(result.contains(.leftTrigger)) + XCTAssertTrue(result.contains(.rightTrigger)) + XCTAssertEqual(result.count, 4) + } + + func testSequenceParticipantButtons_fromProfile() { + let seq = SequenceMapping(steps: [.dpadDown, .dpadDown, .a], keyCode: 0) + let profile = Profile(name: "Test", sequenceMappings: [seq]) + + let result = Set(profile.sequenceMappings.flatMap { $0.steps }) + XCTAssertEqual(result.count, 2) // dpadDown + a + XCTAssertTrue(result.contains(.dpadDown)) + XCTAssertTrue(result.contains(.a)) + } + + // MARK: - Chord Lookup Dictionary + + func testChordLookup_exactMatch() { + let chord = ChordMapping(buttons: [.a, .b], keyCode: 42) + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) + + let result = lookup[Set([.a, .b] as [ControllerButton])] + XCTAssertNotNil(result) + XCTAssertEqual(result?.keyCode, 42) + } + + func testChordLookup_subsetDoesNotMatch() { + let chord = ChordMapping(buttons: [.a, .b, .x], keyCode: 42) + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) + + let result = lookup[Set([.a, .b] as [ControllerButton])] + XCTAssertNil(result) + } + + func testChordLookup_supersetDoesNotMatch() { + let chord = ChordMapping(buttons: [.a, .b], keyCode: 42) + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) - let firstLookup = index.chordLookup[Set([.b, .a] as [ControllerButton])] - XCTAssertEqual(firstLookup?.keyCode, 10) - XCTAssertEqual(firstLookup?.hint, "first") + let result = lookup[Set([.a, .b, .x] as [ControllerButton])] + XCTAssertNil(result) + } + + func testChordLookup_multipleChordsEachFindable() { + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 10) + let chord2 = ChordMapping(buttons: [.x, .y], keyCode: 20) + let chord3 = ChordMapping(buttons: [.leftBumper, .rightBumper], keyCode: 30) + let lookup = Dictionary(uniqueKeysWithValues: [chord1, chord2, chord3].map { ($0.buttons, $0) }) + + XCTAssertEqual(lookup[Set([.a, .b] as [ControllerButton])]?.keyCode, 10) + XCTAssertEqual(lookup[Set([.x, .y] as [ControllerButton])]?.keyCode, 20) + XCTAssertEqual(lookup[Set([.leftBumper, .rightBumper] as [ControllerButton])]?.keyCode, 30) + } - let secondLookup = index.chordLookup[Set([.leftBumper, .rightBumper] as [ControllerButton])] - XCTAssertEqual(secondLookup?.keyCode, 20) - XCTAssertTrue(secondLookup?.modifiers.command == true) + func testChordLookup_emptyChords() { + let chords: [ChordMapping] = [] + let lookup = Dictionary(uniqueKeysWithValues: chords.map { ($0.buttons, $0) }) - XCTAssertNil(index.chordLookup[Set([.a] as [ControllerButton])], "Subsets should not match") - XCTAssertNil(index.chordLookup[Set([.a, .b, .x] as [ControllerButton])], "Supersets should not match") - XCTAssertEqual(index.layersById[layerID]?.name, "Navigation") - XCTAssertEqual(index.layerActivatorMap[.leftThumbstick], layerID) + XCTAssertTrue(lookup.isEmpty) + XCTAssertNil(lookup[Set([.a, .b] as [ControllerButton])]) } - func testMappingProfileIndexExpandsPhysicalEquivalentButtons() { - let chord = ChordMapping(buttons: [.leftPaddle, .rightPaddle], keyCode: 1) - let sequence = SequenceMapping(steps: [.leftFunction, .rightFunction], keyCode: 2) - let profile = Profile( - name: "Physical Equivalents", - chordMappings: [chord], - sequenceMappings: [sequence] - ) + func testChordLookup_setOrderDoesNotMatter() { + let chord = ChordMapping(buttons: [.a, .b, .x], keyCode: 99) + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) + + // Look up with buttons in different insertion order + let result = lookup[Set([.x, .a, .b] as [ControllerButton])] + XCTAssertNotNil(result) + XCTAssertEqual(result?.keyCode, 99) + } - let index = MappingProfileIndex(profile: profile) + func testChordLookup_preservesChordIdentity() { + let id = UUID() + let chord = ChordMapping(id: id, buttons: [.a, .b], keyCode: 42, hint: "test chord") + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) - XCTAssertEqual( - index.chordParticipantButtons, - Set([.leftPaddle, .rightPaddle, .xboxPaddle1, .xboxPaddle2] as [ControllerButton]) - ) - XCTAssertEqual( - index.sequenceParticipantButtons, - Set([.leftFunction, .rightFunction, .xboxPaddle3, .xboxPaddle4] as [ControllerButton]) - ) + let result = lookup[Set([.a, .b] as [ControllerButton])] + XCTAssertEqual(result?.id, id) + XCTAssertEqual(result?.hint, "test chord") } - func testMappingProfileIndexNilProfileIsEmpty() { - let index = MappingProfileIndex(profile: nil) + func testChordLookup_withModifiers() { + let chord = ChordMapping( + buttons: [.leftBumper, .a], + keyCode: 0, + modifiers: ModifierFlags(command: true) + ) + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) - XCTAssertTrue(index.chordParticipantButtons.isEmpty) - XCTAssertTrue(index.sequenceParticipantButtons.isEmpty) - XCTAssertTrue(index.chordLookup.isEmpty) - XCTAssertTrue(index.layersById.isEmpty) - XCTAssertTrue(index.layerActivatorMap.isEmpty) + let result = lookup[Set([.leftBumper, .a] as [ControllerButton])] + XCTAssertNotNil(result) + XCTAssertTrue(result!.modifiers.command) } - func testMappingProfileIndexProfileWithNoMappingsIsEmpty() { - let index = MappingProfileIndex(profile: Profile(name: "Empty")) + // MARK: - Integration: Full Cache Build from Profile - XCTAssertTrue(index.chordParticipantButtons.isEmpty) - XCTAssertTrue(index.sequenceParticipantButtons.isEmpty) - XCTAssertTrue(index.chordLookup.isEmpty) - XCTAssertTrue(index.layersById.isEmpty) - XCTAssertTrue(index.layerActivatorMap.isEmpty) + func testFullCacheBuild_fromProfile() { + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 10) + let chord2 = ChordMapping(buttons: [.x, .y], keyCode: 20) + let seq1 = SequenceMapping(steps: [.dpadDown, .dpadDown, .a], keyCode: 30) + let profile = Profile( + name: "Full", + chordMappings: [chord1, chord2], + sequenceMappings: [seq1] + ) + + // Simulate the same cache-building logic used in setupBindings + let chords = profile.chordMappings + let chordParticipants = Set(chords.flatMap { $0.buttons }) + let seqParticipants = Set(profile.sequenceMappings.flatMap { $0.steps }) + let chordLookup = Dictionary(uniqueKeysWithValues: chords.map { ($0.buttons, $0) }) + + // Chord participants + XCTAssertTrue(chordParticipants.contains(.a)) + XCTAssertTrue(chordParticipants.contains(.b)) + XCTAssertTrue(chordParticipants.contains(.x)) + XCTAssertTrue(chordParticipants.contains(.y)) + XCTAssertFalse(chordParticipants.contains(.leftBumper)) + + // Sequence participants + XCTAssertTrue(seqParticipants.contains(.dpadDown)) + XCTAssertTrue(seqParticipants.contains(.a)) + XCTAssertFalse(seqParticipants.contains(.b)) + + // Chord lookup + XCTAssertEqual(chordLookup[Set([.a, .b] as [ControllerButton])]?.keyCode, 10) + XCTAssertEqual(chordLookup[Set([.x, .y] as [ControllerButton])]?.keyCode, 20) + XCTAssertNil(chordLookup[Set([.a, .x] as [ControllerButton])]) } - func testMappingProfileIndexSingleButtonChordIsIndexed() { - let chord = ChordMapping(buttons: [.a], keyCode: 1) - let profile = Profile(name: "Single Button", chordMappings: [chord]) + func testFullCacheBuild_nilProfile() { + let profile: Profile? = nil - let index = MappingProfileIndex(profile: profile) + let chords = profile?.chordMappings ?? [] + let chordParticipants = Set(chords.flatMap { $0.buttons }) + let seqParticipants = Set((profile?.sequenceMappings ?? []).flatMap { $0.steps }) + let chordLookup = Dictionary(uniqueKeysWithValues: chords.map { ($0.buttons, $0) }) - XCTAssertEqual(index.chordParticipantButtons, Set([.a] as [ControllerButton])) - XCTAssertEqual(index.chordLookup[Set([.a] as [ControllerButton])]?.keyCode, 1) + XCTAssertTrue(chordParticipants.isEmpty) + XCTAssertTrue(seqParticipants.isEmpty) + XCTAssertTrue(chordLookup.isEmpty) } - func testMappingProfileIndexKeepsLastDuplicateChordButtonSet() { - let first = ChordMapping(buttons: [.a, .b], keyCode: 10, hint: "first") - let second = ChordMapping(buttons: [.b, .a], keyCode: 20, hint: "second") - let profile = Profile(name: "Duplicate Chords", chordMappings: [first, second]) + func testFullCacheBuild_profileWithNoMappings() { + let profile = Profile(name: "Empty") - let index = MappingProfileIndex(profile: profile) + let chords = profile.chordMappings + let chordParticipants = Set(chords.flatMap { $0.buttons }) + let seqParticipants = Set(profile.sequenceMappings.flatMap { $0.steps }) + let chordLookup = Dictionary(uniqueKeysWithValues: chords.map { ($0.buttons, $0) }) - let resolved = index.chordLookup[Set([.a, .b] as [ControllerButton])] - XCTAssertEqual(resolved?.keyCode, 20) - XCTAssertEqual(resolved?.hint, "second") + XCTAssertTrue(chordParticipants.isEmpty) + XCTAssertTrue(seqParticipants.isEmpty) + XCTAssertTrue(chordLookup.isEmpty) } - func testMappingProfileIndexKeepsFirstDuplicateLayerID() { - let id = UUID() - let first = Layer(id: id, name: "First", activatorButton: .a) - let second = Layer(id: id, name: "Second", activatorButton: .b) - let profile = Profile(name: "Duplicate Layers", layers: [first, second]) + // MARK: - Edge Cases - let index = MappingProfileIndex(profile: profile) + func testChordLookup_singleButtonChord() { + // ChordMapping allows single-button chords (though they're not "valid") + let chord = ChordMapping(buttons: [.a], keyCode: 1) + let lookup = Dictionary(uniqueKeysWithValues: [chord].map { ($0.buttons, $0) }) - XCTAssertEqual(index.layersById[id]?.name, "First") - XCTAssertEqual(index.layerActivatorMap[.a], id) - XCTAssertEqual(index.layerActivatorMap[.b], id) + XCTAssertEqual(lookup[Set([.a] as [ControllerButton])]?.keyCode, 1) } - func testMappingProfileIndexKeepsLastDuplicateLayerActivator() { - let first = Layer(id: UUID(), name: "First", activatorButton: .leftBumper) - let second = Layer(id: UUID(), name: "Second", activatorButton: .leftBumper) - let profile = Profile(name: "Duplicate Activator", layers: [first, second]) + func testChordParticipantContains_O1Lookup() { + // Verify that Set.contains is being used (O(1)) rather than array scan + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 0) + let chord2 = ChordMapping(buttons: [.x, .y], keyCode: 1) + let chord3 = ChordMapping(buttons: [.leftBumper, .rightBumper], keyCode: 2) + let participants = Set([chord1, chord2, chord3].flatMap { $0.buttons }) + + // These are O(1) lookups on Set + XCTAssertTrue(participants.contains(.a)) + XCTAssertTrue(participants.contains(.rightBumper)) + XCTAssertFalse(participants.contains(.menu)) + XCTAssertFalse(participants.contains(.leftTrigger)) + } - let index = MappingProfileIndex(profile: profile) + func testChordLookup_O1DictionaryLookup() { + // Verify dictionary lookup is O(1) vs linear scan + let chord1 = ChordMapping(buttons: [.a, .b], keyCode: 10) + let chord2 = ChordMapping(buttons: [.x, .y], keyCode: 20) + let chord3 = ChordMapping(buttons: [.leftBumper, .rightBumper], keyCode: 30) + let lookup = Dictionary(uniqueKeysWithValues: [chord1, chord2, chord3].map { ($0.buttons, $0) }) - XCTAssertEqual(index.layerActivatorMap[.leftBumper], second.id) + // Direct dictionary lookup - O(1) average case + XCTAssertNotNil(lookup[Set([.a, .b] as [ControllerButton])]) + XCTAssertNotNil(lookup[Set([.x, .y] as [ControllerButton])]) + XCTAssertNotNil(lookup[Set([.leftBumper, .rightBumper] as [ControllerButton])]) + XCTAssertNil(lookup[Set([.a, .x] as [ControllerButton])]) } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/ProfileDefaultMappingsTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ProfileDefaultMappingsTests.swift index c51cfc97..5dc3f8f1 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ProfileDefaultMappingsTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ProfileDefaultMappingsTests.swift @@ -21,53 +21,4 @@ final class ProfileDefaultMappingsTests: XCTestCase { XCTAssertFalse(inTerminal, "Center mouse command should run silently") XCTAssertTrue(command.contains("CGWarpMouseCursorPosition"), "Command should warp cursor to center") } - - func testCreateDefaultIncludesOuraGestureMappings() { - let profile = Profile.createDefault() - - XCTAssertEqual(profile.buttonMappings[.ouraTap]?.keyCode, KeyCodeMapping.mouseLeftClick) - XCTAssertEqual(profile.buttonMappings[.ouraDoubleTap]?.systemCommand, .centerOuraRing) - XCTAssertEqual(profile.buttonMappings[.ouraTripleTap]?.systemCommand, .toggleOuraMotion) - XCTAssertNil(profile.buttonMappings[.ouraFiveTap], "5x tap should be user-configurable without a default action") - XCTAssertNil(profile.buttonMappings[.ouraTapHold], "tap hold should be user-configurable without a default action") - XCTAssertNil(profile.buttonMappings[.ouraFlickUp], "flick gestures should be user-configurable without defaults") - XCTAssertNil(profile.buttonMappings[.ouraFlickDown], "flick gestures should be user-configurable without defaults") - XCTAssertNil(profile.buttonMappings[.ouraFlickLeft], "flick gestures should be user-configurable without defaults") - XCTAssertNil(profile.buttonMappings[.ouraFlickRight], "flick gestures should be user-configurable without defaults") - } - - func testCreateDefaultIncludesTunedOuraMotionDefaults() { - let settings = Profile.createDefault().joystickSettings.ouraMotion - - XCTAssertFalse(settings.enabled) - XCTAssertTrue(settings.motionOutputEnabled) - XCTAssertEqual(settings.orientation, .screenPlane) - XCTAssertEqual(settings.sensitivity, 0.0479656339031339, accuracy: 1e-12) - XCTAssertEqual(settings.horizontalBoost, 1.0, accuracy: 1e-12) - XCTAssertEqual(settings.leftTiltBoost, 1.0, accuracy: 1e-12) - XCTAssertEqual(settings.deadzone, 0.3505420470505618, accuracy: 1e-12) - XCTAssertEqual(settings.smoothing, 0.7516045616005551, accuracy: 1e-12) - } - - func testDecodingMigratesOuraDoubleAndTripleTapDefaultsOnlyWhenMissing() throws { - let profileId = UUID() - let json = #""" - { - "id": "\#(profileId.uuidString)", - "name": "Old", - "buttonMappings": { - "ouraTripleTap": {} - } - } - """#.data(using: .utf8)! - - let profile = try JSONDecoder().decode(Profile.self, from: json) - - XCTAssertEqual(profile.buttonMappings[.ouraDoubleTap]?.systemCommand, .centerOuraRing) - XCTAssertTrue(profile.buttonMappings[.ouraTripleTap]?.isEmpty == true) - XCTAssertNil(profile.buttonMappings[.ouraTap]) - XCTAssertNil(profile.buttonMappings[.ouraFiveTap]) - XCTAssertNil(profile.buttonMappings[.ouraTapHold]) - XCTAssertNil(profile.buttonMappings[.ouraFlickUp]) - } } diff --git a/XboxControllerMapper/XboxControllerMapperTests/ProfileEqualityTests.swift b/XboxControllerMapper/XboxControllerMapperTests/ProfileEqualityTests.swift index 8e5b600d..c9366194 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/ProfileEqualityTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/ProfileEqualityTests.swift @@ -135,7 +135,7 @@ final class ProfileEqualityTests: XCTestCase { func testProfilesWithDifferentJoystickSettingsAreNotEqual() { var js = JoystickSettings.default - js.leftStick.mouseSensitivity = 1.0 + js.mouseSensitivity = 1.0 let a = makeProfile(joystickSettings: js) let b = makeProfile(joystickSettings: .default) XCTAssertNotEqual(a, b) diff --git a/XboxControllerMapper/XboxControllerMapperTests/SequenceDetectionTests.swift b/XboxControllerMapper/XboxControllerMapperTests/SequenceDetectionTests.swift index 87e6b5cd..344edd4b 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/SequenceDetectionTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/SequenceDetectionTests.swift @@ -45,7 +45,9 @@ final class SequenceDetectionTests: XCTestCase { try? await Task.sleep(nanoseconds: 100_000_000) await MainActor.run { mockInputSimulator?.releaseAllModifiers() - controllerService?.onInputEvent = nil + controllerService?.onButtonPressed = nil + controllerService?.onButtonReleased = nil + controllerService?.onChordDetected = nil controllerService?.cleanup() mappingEngine = nil controllerService = nil @@ -80,8 +82,8 @@ final class SequenceDetectionTests: XCTestCase { // Press A → B → X with release between each for button: ControllerButton in [.a, .b, .x] { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(button)) - controllerService.emitInputEvent(.buttonReleased(button, holdDuration: 0.03)) + controllerService.onButtonPressed?(button) + controllerService.onButtonReleased?(button, 0.03) } await waitForTasks(0.05) } @@ -114,8 +116,8 @@ final class SequenceDetectionTests: XCTestCase { // Press L3 three times via direct callback for _ in 0..<3 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftThumbstick)) - controllerService.emitInputEvent(.buttonReleased(.leftThumbstick, holdDuration: 0.03)) + controllerService.onButtonPressed?(.leftThumbstick) + controllerService.onButtonReleased?(.leftThumbstick, 0.03) } await waitForTasks(0.05) } @@ -186,8 +188,8 @@ final class SequenceDetectionTests: XCTestCase { // Press L3 three times via direct callback for _ in 0..<3 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftThumbstick)) - controllerService.emitInputEvent(.buttonReleased(.leftThumbstick, holdDuration: 0.03)) + controllerService.onButtonPressed?(.leftThumbstick) + controllerService.onButtonReleased?(.leftThumbstick, 0.03) } await waitForTasks(0.05) } @@ -227,8 +229,8 @@ final class SequenceDetectionTests: XCTestCase { for _ in 0..<2 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.05) } @@ -310,8 +312,8 @@ final class SequenceDetectionTests: XCTestCase { // First press await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } // Wait longer than the step timeout await waitForTasks(0.2) @@ -319,8 +321,8 @@ final class SequenceDetectionTests: XCTestCase { // Second and third press for _ in 0..<2 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.a)) - controllerService.emitInputEvent(.buttonReleased(.a, holdDuration: 0.03)) + controllerService.onButtonPressed?(.a) + controllerService.onButtonReleased?(.a, 0.03) } await waitForTasks(0.05) } @@ -360,8 +362,8 @@ final class SequenceDetectionTests: XCTestCase { // Press L3 three times via direct callback for _ in 0..<3 { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(.leftThumbstick)) - controllerService.emitInputEvent(.buttonReleased(.leftThumbstick, holdDuration: 0.03)) + controllerService.onButtonPressed?(.leftThumbstick) + controllerService.onButtonReleased?(.leftThumbstick, 0.03) } await waitForTasks(0.05) } @@ -412,8 +414,8 @@ final class SequenceDetectionTests: XCTestCase { // Press A then B for button: ControllerButton in [.a, .b] { await MainActor.run { - controllerService.emitInputEvent(.buttonPressed(button)) - controllerService.emitInputEvent(.buttonReleased(button, holdDuration: 0.03)) + controllerService.onButtonPressed?(button) + controllerService.onButtonReleased?(button, 0.03) } await waitForTasks(0.05) } @@ -461,9 +463,9 @@ final class SequenceDetectionTests: XCTestCase { XCTAssertFalse(LaserPointerOverlay.shared.isShowing, "Laser should not be showing initially") } - // Simulate chord through the controller input-event boundary + // Simulate chord via onChordDetected callback await MainActor.run { - controllerService.emitInputEvent(.chordDetected([.leftBumper, .rightBumper])) + controllerService.onChordDetected?([.leftBumper, .rightBumper]) } await waitForTasks(0.15) diff --git a/XboxControllerMapper/XboxControllerMapperTests/TouchpadGestureTests.swift b/XboxControllerMapper/XboxControllerMapperTests/TouchpadGestureTests.swift index 15f1b76f..22df3e36 100644 --- a/XboxControllerMapper/XboxControllerMapperTests/TouchpadGestureTests.swift +++ b/XboxControllerMapper/XboxControllerMapperTests/TouchpadGestureTests.swift @@ -23,7 +23,7 @@ final class TouchpadGestureTests: MappingEngineTestCase { // Simulate tap gesture callback await MainActor.run { - controllerService.emitInputEvent(.touchpadTap) + controllerService.onTouchpadTap?() } await waitForTasks() @@ -53,7 +53,7 @@ final class TouchpadGestureTests: MappingEngineTestCase { // Simulate two-finger tap callback await MainActor.run { - controllerService.emitInputEvent(.touchpadTwoFingerTap) + controllerService.onTouchpadTwoFingerTap?() } await waitForTasks() diff --git a/appcast.xml b/appcast.xml index 45491955..ac382e35 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,12 +3,12 @@ ControllerKeys - 2.6.0 - Mon, 13 Jul 2026 14:28:39 -0700 - 19 - 2.6.0 + 2.2.1 + Fri, 19 Jun 2026 01:45:14 -0700 + 14 + 2.2.1 14.6 - + \ No newline at end of file diff --git a/community-profiles/Google Slides & PowerPoint.json b/community-profiles/Google Slides & PowerPoint.json deleted file mode 100644 index 808e4c79..00000000 --- a/community-profiles/Google Slides & PowerPoint.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "id" : "36DB65F5-54ED-419B-8FCC-1AC6438BA25D", - "name" : "Google Slides \/ PowerPoint", - "icon" : "rectangle.on.rectangle.angled", - "isDefault" : false, - "createdAt" : "2026-06-27T12:00:00Z", - "modifiedAt" : "2026-06-27T13:00:00Z", - "linkedApps" : [ - "com.microsoft.Powerpoint" - ], - "buttonMappings" : { - "a" : { - "hint" : "Next Slide", - "keyCode" : 124, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "b" : { - "hint" : "Previous Slide", - "keyCode" : 123, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "leftBumper" : { - "hint" : "Previous Slide", - "keyCode" : 123, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "rightBumper" : { - "hint" : "Next Slide", - "keyCode" : 124, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "dpadLeft" : { - "hint" : "Previous Slide", - "keyCode" : 123, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "dpadRight" : { - "hint" : "Next Slide", - "keyCode" : 124, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "rightTrigger" : { - "hint" : "Laser Pointer", - "keyCode" : 37, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - }, - "menu" : { - "hint" : "Start Presentation", - "keyCode" : 36, - "modifiers" : { - "command" : true, - "option" : false, - "shift" : true, - "control" : false - }, - "isHoldModifier" : false - }, - "view" : { - "hint" : "Exit Presentation", - "keyCode" : 53, - "modifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - }, - "isHoldModifier" : false - } - }, - "chordMappings" : [], - "sequenceMappings" : [], - "gestureMappings" : [], - "macros" : [], - "joystickSettings" : { - "mouseSensitivity" : 0.5, - "scrollSensitivity" : 0.5, - "mouseDeadzone" : 0.15, - "scrollDeadzone" : 0.15, - "invertMouseY" : false, - "invertScrollY" : false, - "mouseAcceleration" : 0.5, - "touchpadSensitivity" : 0.5, - "touchpadAcceleration" : 0.5, - "touchpadDeadzone" : 0.001, - "touchpadSmoothing" : 0.4, - "touchpadPanSensitivity" : 0.5, - "touchpadZoomToPanRatio" : 1.8, - "touchpadUseNativeZoom" : true, - "scrollAcceleration" : 0.5, - "scrollBoostMultiplier" : 2.0, - "focusModeSensitivity" : 0.1, - "focusModeModifier" : { - "command" : true, - "option" : false, - "shift" : false, - "control" : false - }, - "gyroAimingEnabled" : false, - "gyroAimingSensitivity" : 0.3 - }, - "dualSenseLEDSettings" : { - "lightBarColor" : { - "red" : 0.9, - "green" : 0.6, - "blue" : 0.1 - }, - "lightBarBrightness" : "bright", - "lightBarEnabled" : true, - "muteButtonLED" : "off", - "playerLEDs" : { - "led1" : true, - "led2" : false, - "led3" : false, - "led4" : false, - "led5" : false - } - }, - "onScreenKeyboardSettings" : { - "defaultTerminalApp" : "Terminal", - "typingDelay" : 0.03, - "showExtendedFunctionKeys" : false, - "activateAllWindows" : true, - "appBarItems" : [], - "websiteLinks" : [], - "quickTexts" : [], - "toggleShortcutModifiers" : { - "command" : false, - "option" : false, - "shift" : false, - "control" : false - } - } -} diff --git a/community-profiles/Google Slides & PowerPoint.md b/community-profiles/Google Slides & PowerPoint.md deleted file mode 100644 index 3d69d9c3..00000000 --- a/community-profiles/Google Slides & PowerPoint.md +++ /dev/null @@ -1,26 +0,0 @@ -# Google Slides / PowerPoint — Setup Guide - -A dead-simple presentation clicker. It does one thing well: drive a slideshow with as few buttons as possible, so it works great on tiny gamepads (like the 8BitDo Micro) that have no analog sticks. Every button does one obvious thing—no chords, no hold-modifiers, no layers. - -## Buttons - -| Button | Action | -| --- | --- | -| **A** · **Right Bumper** · **D-pad →** | Next slide | -| **B** · **Left Bumper** · **D-pad ←** | Previous slide | -| **Right Trigger** | Laser pointer | -| **Start (+)** | Start presentation (from the beginning) | -| **Select (−)** | Exit presentation | - -Next and previous are bound to three buttons each on purpose—people reach for different buttons by instinct, so any of them just works. Start and Exit sit on the two small recessed buttons (Start = begin the show, Select = end it), out of the way so you don't trigger them by accident. - -## Google Slides vs PowerPoint - -The navigation keys (next, previous, exit) work in **Google Slides, PowerPoint, and Keynote**. **Start presentation** sends **⌘+Shift+Enter**, which begins the show from the first slide in both **Google Slides and PowerPoint** (Keynote uses a different shortcut, so Start won't launch Keynote). One binding is Google-Slides-specific: - -- **Laser pointer** sends **L**, which toggles the laser in Google Slides present mode. In PowerPoint the laser is a click-and-drag gesture, so this button is inert there (harmless). - -## Activating the profile - -- **PowerPoint** — this profile auto-activates when PowerPoint is frontmost (it's linked to the PowerPoint app). -- **Google Slides** — Slides runs in a browser, so there's no app to link to. Select this profile manually in ControllerKeys before you start presenting. (Linking it to a browser would hijack your controller during normal browsing, which you don't want.) diff --git a/docs/CONTROLLER_INPUT_ISSUES.md b/docs/CONTROLLER_INPUT_ISSUES.md index ab4a8906..894589a7 100644 --- a/docs/CONTROLLER_INPUT_ISSUES.md +++ b/docs/CONTROLLER_INPUT_ISSUES.md @@ -1,6 +1,6 @@ # Controller Input Issues -Last updated: 2026-07-06 +Last updated: 2026-05-30 ## 1. Steam Controller gyro drifts bottom-left @@ -52,19 +52,3 @@ Investigation notes: - Replaced the blocking `NSAlert.runModal()` pairing-code prompt with a nonactivating floating panel. - Removed the settings-sheet pairing result alert; pairing progress and errors now stay inline in the settings UI. - The pairing code presenter does not activate ControllerKeys or require an OK click, so controller-driven pointer input can keep routing. - -## 4. First-run onboarding does not add ControllerKeys to Input Monitoring - -Status: fixed in local branch - -Observed: -- During first-run onboarding, ControllerKeys can fail to appear automatically in System Settings → Privacy & Security → Input Monitoring. -- The user must manually add ControllerKeys with `+` or by dragging the app/icon into the list before the switch can be enabled. - -Target: -- First-run setup should request Input Monitoring before Accessibility so macOS can register ControllerKeys in the Input Monitoring list before Accessibility masks the separate listen permission. -- The onboarding copy should explicitly tell users to add ControllerKeys manually if macOS does not insert it automatically. - -Investigation notes: -- `IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)` is the correct request API, but requesting Accessibility first can make listen access appear granted without a separate Input Monitoring list entry. -- The wizard now walks Input Monitoring before Accessibility, delays opening the pane briefly after requesting access, and makes the manual-add fallback part of the numbered instructions. diff --git a/docs/internal/INPUT_PIPELINE_REVIEW.md b/docs/internal/INPUT_PIPELINE_REVIEW.md index 9c9fac20..143944d9 100644 --- a/docs/internal/INPUT_PIPELINE_REVIEW.md +++ b/docs/internal/INPUT_PIPELINE_REVIEW.md @@ -390,12 +390,12 @@ Used for chord detection (tokens = buttons), sequence detection (tokens = button | 2 | Add `dispatchPrecondition` assertions on queue-sensitive functions | **Done** | MappingEngine, JoystickHandler, TouchpadInputHandler | | 3 | Normalize action handler signatures via Command pattern | **Done** | ActionCommand.swift (192 lines), MappingActionExecutor.swift rewritten | -### Tier 2: Medium Impact, Medium Risk — PARTIALLY COMPLETED +### Tier 2: Medium Impact, Medium Risk — ALL COMPLETED | # | Change | Status | Files | |---|--------|--------|-------| -| 4 | Introduce ControllerInputEvent enum | **Partially done** | `ControllerInputEvent` now envelopes MappingEngine callback routing; ControllerService still exposes legacy callbacks | +| 4 | Introduce ControllerInputEvent enum | **Done** | ControllerInputEvent.swift + MappingEngine.handleControllerInput() | | 5 | Command pattern for action execution | **Done** | ActionCommandFactory with 6 concrete commands | -| 6 | Split InputSimulator into KeyboardSimulator + MouseSimulator | **Partially done** | Macro code extracted; `ModifierKeyEmissionPolicy` now owns side-aware modifier selection | +| 6 | Split InputSimulator into KeyboardSimulator + MouseSimulator | **Deferred** | Macro code extracted; keyboard/mouse split not yet needed | ### Tier 3: Aspirational, Higher Risk — PARTIALLY COMPLETED | # | Change | Status | Files | @@ -403,27 +403,22 @@ Used for chord detection (tokens = buttons), sequence detection (tokens = button | 7 | Unified GestureDetector protocol | **Done** | GestureDetector.swift (GestureDetecting protocol), SequenceDetector.swift, MotionGestureDetector.swift | | 8 | Move chord detection from ControllerService to MappingEngine | **Deferred** | Decision: chord detection stays in ControllerService (see Decisions) | | 9 | Event bus for action side-effects | **Not started** | Future work | -| 10 | HID driver descriptors | **Partially done** | `HIDControllerDriverDescriptor` centralizes matching criteria; lifecycle/callback protocol still deferred | -| 11 | Data-driven controller visual descriptors | **Partially done** | `ControllerVisualDescriptor` centralizes preview capabilities; overlay builders still per-controller | ### Test Coverage Added - **ActionLayerCharacterizationTests** — 10 tests (priority dispatch, macro isolation, OSK notification) - **PipelineCharacterizationTests** — 9 tests (button press, chord, sequence, motion, touchpad, queue routing) +- **ControllerInputEventTests** — 8 tests (event enum equality, associated values) - **SequenceDetectorTests** — 7 tests (step matching, timeout, reset, concurrent sequences) - **MotionGestureDetectorTests** — 7 tests (peak detection, settling, cooldown, axis independence) - **ModifierRefCountingTests** — 11 tests (overlapping holds, underflow protection, stress) -- **ControllerVisualDescriptorTests** — preview capability and row-selection coverage -- **ControllerInputEventTests** — event queue routing coverage for discrete vs. continuous input -- **HIDControllerDriverDescriptorTests** — raw HID matching descriptor coverage -- **ModifierKeyEmissionPolicyTests** — side-aware modifier key selection coverage --- ## Decisions Made -1. **Chord detection stays in ControllerService.** It's hardware timing ("were these pressed together?"), not mapping logic ("what do they mean?"). MappingEngine now wraps incoming callbacks in a `ControllerInputEvent` envelope before queue dispatch, but ControllerService still publishes separate callback slots. A single ControllerService event callback remains future work. +1. **Chord detection stays in ControllerService.** It's hardware timing ("were these pressed together?"), not mapping logic ("what do they mean?"). ControllerService emits `ControllerInputEvent.chord` events. MappingEngine interprets them. -2. **120Hz polling for touchpad is fine.** Single-finger is already event-driven (low latency), polling handles momentum smoothing. The remaining inconsistency is callback registration shape, not the data flow itself. +2. **120Hz polling for touchpad is fine.** Single-finger is already event-driven (low latency), polling handles momentum smoothing. The inconsistency is in callback registration, not in the actual data flow. The `ControllerInputEvent` enum fixes the registration inconsistency. 3. **No Swift Concurrency adoption.** NSLock + DispatchQueue is the right choice for real-time input (120Hz polling, microsecond latency). Structured concurrency's scheduling overhead isn't suitable here. @@ -461,5 +456,5 @@ scriptQueue - JavaScript execution (serializes JSContext) --- -*Last updated: 2026-06-22* -*Status: Tier 1 implemented; Tier 2/Tier 3 partially implemented. Current app-hosted `xcodebuild test` on kmacstudio is blocked by the ControllerKeys test-host launch stall documented in the wiki; use build-for-testing plus focused direct XCTest checks until that CI issue is fixed.* +*Last updated: 2026-02-22* +*Status: Tier 1 and Tier 2 implemented. 52 new tests added. All regression tests passing (14/14).* diff --git a/docs/internal/REFACTORING_BACKLOG_2026-06.md b/docs/internal/REFACTORING_BACKLOG_2026-06.md index 19016244..788b3583 100644 --- a/docs/internal/REFACTORING_BACKLOG_2026-06.md +++ b/docs/internal/REFACTORING_BACKLOG_2026-06.md @@ -1,7 +1,7 @@ # Refactoring Backlog — June 2026 **Date:** 2026-06-10 -**Status:** Tier 1 + most Tier 2 landed; this doc tracks deferred and partial Tier 3 items. +**Status:** Tier 1 + 2 landed; this doc tracks the deferred Tier 3 items. Ranking method: git churn (commits/6 weeks) × file complexity. The hot files were `ControllerService` (33 commits, 2.7k lines + 7.9k in extensions), `ControllerVisualView` @@ -18,40 +18,17 @@ Ranking method: git churn (commits/6 weeks) × file complexity. The hot files we - `ControllerService` 15Hz display timer → `+DisplayUpdates.swift`, battery polling/LED animation → `+Battery.swift`. - `LEDSettingsView` + `LightBarColorPicker` → `LEDSettingsView.swift`. -- `ControllerVisualDescriptor` centralizes preview capabilities/row selection with tests. -- `ControllerInputEvent` centralizes MappingEngine callback queue routing with tests. -- `HIDControllerDriverDescriptor` centralizes raw-HID matching criteria with tests. -- `ModifierKeyEmissionPolicy` owns side-aware modifier-key selection with tests. ## Tier 3 — deferred, do when the area is next touched -### 0. Controller input event envelope (partially started; finish when callback surface changes) - -MappingEngine now wraps every ControllerService callback in a `ControllerInputEvent` before routing -to `inputQueue` or `pollingQueue`. That gives the input boundary a single dispatch function and a -unit-tested queue policy without changing chord timing, touchpad polling, or remote relay behavior. - -Remaining plan: replace ControllerService's many callback properties with one event callback, then -move tests from "callback slot fired" assertions to "event emitted" assertions. Do not move chord -detection out of ControllerService unless a latency/behavior bug forces that design decision. - -- Payoff: `setupBindings()` becomes subscription plumbing instead of input taxonomy. -- Risk: medium — the current partial step is low risk, but the full callback migration touches - hardware-facing event timing. -- Effort: ~3-5 hours. - -### 1. Data-driven controller layouts (partially started; continue with next new controller type) +### 1. Data-driven controller layouts (do alongside the next new controller type) `ControllerVisualView` + `ControllerAnalogOverlay` contain ~56 per-controller-type branches (`isXboxElite` / `isDualSense` / `isSteamController` / `isAppleTVRemote` / ...) and four near-duplicate mini-overlay builders (`xboxOverlay`, `steamOverlay`, `nintendoOverlay`, `dualSenseOverlay`) with hardcoded positions/spacing. -Progress: `ControllerVisualDescriptor` now owns preview family/capability resolution, shoulder -rows, system rows, touchpad section visibility, and special section labels. This removes a -chunk of boolean routing from `ControllerVisualView` and gives the behavior unit coverage. - -Remaining plan: introduce a per-controller `LayoutMetrics` value type (preview size, button positions, +Plan: introduce a per-controller `LayoutMetrics` value type (preview size, button positions, touchpad geometry) and collapse the overlay builders into one data-driven builder. Also unify `miniTouchpad()` vs `miniSteamTouchpad()` (shared quadrant divider/highlight/tap-zone code) and `compactActionTile()` vs `referenceRow()` (near-identical context menu + layer indicator logic). @@ -61,7 +38,7 @@ touchpad geometry) and collapse the overlay builders into one data-driven builde side-by-side screenshots per controller type. - Effort: ~3-5 hours. -### 2. HID driver protocol (partially started; do lifecycle/callback split with next HID controller) +### 2. HID driver protocol (do when adding the next HID controller) ~80% of the IOHIDManager setup (Create → SetDeviceMatching → RegisterCallback → Open, plus teardown) is duplicated across `+SteamHID.swift`, `+GenericHID.swift`, `+AppleTVRemoteHID.swift`, @@ -69,11 +46,7 @@ teardown) is duplicated across `+SteamHID.swift`, `+GenericHID.swift`, `+AppleTV etc. repeats per extension. Apple TV additionally has three separate button-routing paths (HID buttons, system events, touchpad events) where Steam/Generic centralize. -Progress: `HIDControllerDriverDescriptor` now centralizes the pure matching criteria used by -Generic HID, Nintendo HID, and 8BitDo D-input HID. This gives new backends a testable descriptor -before any IOHIDManager wiring is touched. - -Remaining plan: a `HIDControllerDriver` protocol (device matching, lifecycle, callback surface) + a setup +Plan: a `HIDControllerDriver` protocol (device matching, lifecycle, callback surface) + a setup helper for the manager boilerplate. ControllerService talks to drivers through the protocol. - Payoff: one place to debug HID lifecycle; drivers swappable for tests. diff --git a/fix.swift b/fix.swift new file mode 100644 index 00000000..9bec9ddc --- /dev/null +++ b/fix.swift @@ -0,0 +1,3 @@ +import Foundation + +// Check test coverage around this CommunityProfileRow or the UI components tests diff --git a/version.env b/version.env index c9d9bb61..8e7267e3 100644 --- a/version.env +++ b/version.env @@ -2,5 +2,5 @@ # This file is the single source of truth for version numbers. # Update these values when preparing a new release. -MARKETING_VERSION="2.6.0" -BUILD_NUMBER="19" +MARKETING_VERSION="2.2.1" +BUILD_NUMBER="14"