diff --git a/.changeset/9e3c3b8a.md b/.changeset/9e3c3b8a.md new file mode 100644 index 0000000..84a8cd9 --- /dev/null +++ b/.changeset/9e3c3b8a.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Add cmdcmd:// URL commands for gesture launchers, plus an optional interactive 3-finger trackpad swipe setting. Swipe up starts after a small threshold and commits at 33% to reveal cmdcmd; swipe down 33% returns to the originally focused window; reversing direction restores the prior state. Settings now warns about conflicting macOS 3-finger gestures and can help disable them. Keyboard/click selection stays binary and tied to the explicitly highlighted or clicked tile. A new preview quality setting can keep the grid efficient and switch to sharper Retina captures only when picking, clicking, peeking, or popping a tile. Fast cmd-cmd taps are handled more reliably by using one event source, preserving the opposite Command side on side-bit-only events, and removing per-trigger debug state dumps. Live capture is now bounded to reduce CPU and memory pressure, idle indicators work again by sampling tiny frame signatures instead of treating every poll as activity, and a perf-audit helper captures ps/vmmap/sample/log snapshots. diff --git a/Package.swift b/Package.swift index 58d3621..9eab8b2 100644 --- a/Package.swift +++ b/Package.swift @@ -8,15 +8,23 @@ let package = Package( .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), ], targets: [ + .target( + name: "CMultitouch", + linkerSettings: [ + .linkedFramework("CoreFoundation"), + ] + ), .executableTarget( name: "cmdcmd", dependencies: [ + "CMultitouch", .product(name: "Sparkle", package: "Sparkle"), ], linkerSettings: [ .linkedFramework("AppKit"), .linkedFramework("ScreenCaptureKit"), .linkedFramework("Carbon"), + .linkedFramework("CoreFoundation"), ] ), ] diff --git a/README.md b/README.md index 3fac967..57b68d8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ Or download `cmdcmd.zip` from the [latest release](https://github.com/peterp/cmd **⌘ + ⌘** — tap left and right Command at the same time (no other key in between). Tap again, or press `esc`, to dismiss. +**3-finger trackpad swipe** — optional in Settings. Swipe up after a small movement threshold and 33% to commit opening the overlay; swipe down 33% to return to the originally focused window. Keyboard and click/tap are binary selection modes: they immediately pick the explicitly highlighted/clicked tile with no threshold. Gesture mode is analog: vertical finger travel drives the reveal/dismiss animation, and reversing direction before release restores the prior state. If macOS 3-finger Mission Control/App Exposé is enabled, Settings can help disable those system gestures. + +## URL commands + +cmdcmd registers the `cmdcmd://` URL scheme for external launchers and gesture tools: + +```sh +open 'cmdcmd://show' # show/focus the overlay +open 'cmdcmd://toggle' # toggle the overlay +open 'cmdcmd://swipe-up' # show/focus the overlay +open 'cmdcmd://swipe-down' # dismiss the overlay +open 'cmdcmd://dismiss' # hide the overlay +``` + ## Keybindings (overlay) | Key | Action | @@ -74,6 +88,8 @@ Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that opens ` `animationSpeed` is a multiplier for animated transitions: `1.0` is normal, `2.0` is twice as fast, and `0.5` is half speed. +`previewQuality` controls preview capture resolution: `"efficient"` uses nominal-resolution captures for every tile, `"balanced"` switches to Retina only when you pick, click, peek, or pop a tile, and `"sharp"` asks for Retina captures for every tile. + `trigger` chooses what summons the overlay. Default `"cmd-cmd"` is the both-Command-keys chord. Anything else is treated as a regular hotkey spec — e.g. `"cmd+shift+space"` or `"f13"` (uses the same shortcut grammar as `bindings`). Hotkeys other than the chord require Accessibility permission to be globally observable. Binding spec — modifier tokens: `cmd`, `shift`, `opt` (or `option`/`alt`), `ctrl`. Special keys: `esc`, `space`, `return`, `delete`, `left`, `right`, `up`, `down`. Anything else is a single character. @@ -95,6 +111,12 @@ swift build .build/debug/cmdcmd ``` +Quick performance snapshot of a running app: + +```sh +scripts/perf-audit.sh +``` + ## Releasing Each user-visible change drops a markdown entry into `.changeset/`: @@ -137,11 +159,16 @@ Sources/cmdcmd/ Config.swift # JSON config loader (animations, speed, trigger, bindings) Keymap.swift # default shortcuts + override resolver HotkeyMonitor.swift # global hotkey trigger (alternative to CmdChord) + TrackpadSwipeMonitor.swift # 3-finger trackpad swipe trigger + TrackpadGestureSettings.swift # detects/disables conflicting macOS gestures + InputInertiaShield.swift # swallows trailing swipe/scroll inertia after dismiss + MouseClickInterceptor.swift # catches overlay clicks before they fall through Tile.swift # per-window SCStream preview layer GridLayout.swift # grid sizing for N tiles at the screen aspect ratio CmdChord.swift # left+right Command chord detector SpaceTracker.swift # private CGS/SkyLight space + window enumeration Log.swift # stderr logger +Sources/CMultitouch/ # tiny MultitouchSupport bridge for trackpad swipes Resources/ # Info.plist + AppIcon.icns + AppIcon.png build-app.sh # swift build → .app bundle + ad-hoc codesign make-icon.sh # regenerate Resources/AppIcon.icns + .png diff --git a/Resources/Info.plist b/Resources/Info.plist index 904c110..34960de 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -18,6 +18,17 @@ ⌘ ⌘ CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLName + com.p4p8.cmdcmd + CFBundleURLSchemes + + cmdcmd + + + CFBundleShortVersionString 0.5.0 CFBundleVersion diff --git a/Sources/CMultitouch/CMultitouch.c b/Sources/CMultitouch/CMultitouch.c new file mode 100644 index 0000000..c10c48b --- /dev/null +++ b/Sources/CMultitouch/CMultitouch.c @@ -0,0 +1,228 @@ +#include "CMultitouch.h" + +#include +#include +#include +#include + +#define CMD_MAX_TOUCH_DEVICES 16 + +typedef void *MTDeviceRef; + +typedef struct { + float x; + float y; +} MTPoint; + +typedef struct { + MTPoint position; + MTPoint velocity; +} MTVector; + +typedef struct { + int frame; + double timestamp; + int identifier; + int state; + int fingerID; + int handID; + MTVector normalized; + float size; + int zero1; + float angle; + float majorAxis; + float minorAxis; + MTVector mm; + int zero2[2]; + float density; +} Finger; + +typedef int (*MTContactCallbackFunction)(int, Finger *, int, double, int); +typedef CFArrayRef (*MTDeviceCreateListFunction)(void); +typedef void (*MTRegisterContactFrameCallbackFunction)(MTDeviceRef, MTContactCallbackFunction); +typedef int (*MTDeviceStartFunction)(MTDeviceRef, int); +typedef int (*MTDeviceStopFunction)(MTDeviceRef); + +static void *gFramework = NULL; +static MTDeviceCreateListFunction gCreateList = NULL; +static MTRegisterContactFrameCallbackFunction gRegisterCallback = NULL; +static MTDeviceStartFunction gStartDevice = NULL; +static MTDeviceStopFunction gStopDevice = NULL; + +static MTDeviceRef gDevices[CMD_MAX_TOUCH_DEVICES]; +static CFIndex gDeviceCount = 0; +static CFArrayRef gDeviceList = NULL; +static CMDSwipeCallback gCallback = NULL; +static void *gContext = NULL; + +static bool gTracking = false; +static double gStartX = 0.0; +static double gStartY = 0.0; +static double gLastDX = 0.0; +static double gLastDY = 0.0; + +static bool gTapTracking = false; +static double gTapStartTime = 0.0; +static double gTapStartX = 0.0; +static double gTapStartY = 0.0; +static double gTapMaxMovement = 0.0; +static double gTapSuppressedUntil = 0.0; + +static const double CMD_TAP_MAX_DURATION = 0.30; +static const double CMD_TAP_MAX_MOVEMENT = 0.035; +static const double CMD_TAP_SUPPRESS_AFTER_SWIPE = 0.25; + +static bool load_framework(void) { + if (gFramework) { return true; } + + gFramework = dlopen("/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport", RTLD_LAZY); + if (!gFramework) { return false; } + + gCreateList = (MTDeviceCreateListFunction)dlsym(gFramework, "MTDeviceCreateList"); + gRegisterCallback = (MTRegisterContactFrameCallbackFunction)dlsym(gFramework, "MTRegisterContactFrameCallback"); + gStartDevice = (MTDeviceStartFunction)dlsym(gFramework, "MTDeviceStart"); + gStopDevice = (MTDeviceStopFunction)dlsym(gFramework, "MTDeviceStop"); + + return gCreateList && gRegisterCallback && gStartDevice; +} + +static void centroid(Finger *fingers, int count, double *x, double *y) { + double sx = 0.0; + double sy = 0.0; + for (int i = 0; i < count; i++) { + sx += fingers[i].normalized.position.x; + sy += fingers[i].normalized.position.y; + } + *x = sx / (double)count; + *y = sy / (double)count; +} + +static void finish_tap_if_needed(CMDSwipeCallback callback, double now, int fingerCount) { + if (!gTapTracking) { return; } + + double duration = now - gTapStartTime; + bool isTap = fingerCount == 0 && + duration >= 0.01 && + duration <= CMD_TAP_MAX_DURATION && + gTapMaxMovement <= CMD_TAP_MAX_MOVEMENT; + gTapTracking = false; + + if (isTap) { + callback(CMDSwipePhaseTap, 0.0, 0.0, gContext); + } +} + +static int contact_callback(int device, Finger *fingers, int fingerCount, double timestamp, int frame) { + (void)device; + (void)frame; + + CMDSwipeCallback callback = gCallback; + if (!callback) { return 0; } + + double now = timestamp > 0.0 ? timestamp : CFAbsoluteTimeGetCurrent(); + + if (fingerCount == 3 && fingers) { + gTapTracking = false; + double x = 0.0; + double y = 0.0; + centroid(fingers, fingerCount, &x, &y); + + if (!gTracking) { + gTracking = true; + gStartX = x; + gStartY = y; + gLastDX = 0.0; + gLastDY = 0.0; + callback(CMDSwipePhaseBegin, 0.0, 0.0, gContext); + return 0; + } + + gLastDX = x - gStartX; + gLastDY = y - gStartY; + callback(CMDSwipePhaseUpdate, gLastDX, gLastDY, gContext); + return 0; + } + + if (gTracking) { + gTracking = false; + callback(CMDSwipePhaseEnd, gLastDX, gLastDY, gContext); + gLastDX = 0.0; + gLastDY = 0.0; + gTapTracking = false; + gTapSuppressedUntil = now + CMD_TAP_SUPPRESS_AFTER_SWIPE; + return 0; + } + + if (fingerCount == 1 && fingers && now >= gTapSuppressedUntil) { + double x = fingers[0].normalized.position.x; + double y = fingers[0].normalized.position.y; + if (!gTapTracking) { + gTapTracking = true; + gTapStartTime = now; + gTapStartX = x; + gTapStartY = y; + gTapMaxMovement = 0.0; + } else { + double movement = hypot(x - gTapStartX, y - gTapStartY); + if (movement > gTapMaxMovement) { gTapMaxMovement = movement; } + } + } else { + finish_tap_if_needed(callback, now, fingerCount); + } + + return 0; +} + +bool CMDSwipeMonitorStart(CMDSwipeCallback callback, void *context) { + if (!callback || !load_framework()) { return false; } + + if (gDeviceCount > 0) { + gCallback = callback; + gContext = context; + return true; + } + + CFArrayRef devices = gCreateList(); + if (!devices) { return false; } + + CFIndex count = CFArrayGetCount(devices); + if (count <= 0) { + CFRelease(devices); + return false; + } + + if (count > CMD_MAX_TOUCH_DEVICES) { count = CMD_MAX_TOUCH_DEVICES; } + + gCallback = callback; + gContext = context; + gDeviceList = devices; + gDeviceCount = count; + + for (CFIndex i = 0; i < count; i++) { + MTDeviceRef device = (MTDeviceRef)CFArrayGetValueAtIndex(devices, i); + gDevices[i] = device; + gRegisterCallback(device, contact_callback); + gStartDevice(device, 0); + } + + return true; +} + +void CMDSwipeMonitorStop(void) { + for (CFIndex i = 0; i < gDeviceCount; i++) { + if (gStopDevice && gDevices[i]) { + gStopDevice(gDevices[i]); + } + gDevices[i] = NULL; + } + gDeviceCount = 0; + gTracking = false; + gTapTracking = false; + gCallback = NULL; + gContext = NULL; + + if (gDeviceList) { + CFRelease(gDeviceList); + gDeviceList = NULL; + } +} diff --git a/Sources/CMultitouch/include/CMultitouch.h b/Sources/CMultitouch/include/CMultitouch.h new file mode 100644 index 0000000..0f6297c --- /dev/null +++ b/Sources/CMultitouch/include/CMultitouch.h @@ -0,0 +1,28 @@ +#ifndef CMULTITOUCH_H +#define CMULTITOUCH_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum CMDSwipePhase: int32_t { + CMDSwipePhaseBegin = 0, + CMDSwipePhaseUpdate = 1, + CMDSwipePhaseEnd = 2, + CMDSwipePhaseCancel = 3, + CMDSwipePhaseTap = 4, +} CMDSwipePhase; + +typedef void (*CMDSwipeCallback)(CMDSwipePhase phase, double deltaX, double deltaY, void *context); + +bool CMDSwipeMonitorStart(CMDSwipeCallback callback, void *context); +void CMDSwipeMonitorStop(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Sources/cmdcmd/CmdChord.swift b/Sources/cmdcmd/CmdChord.swift index 5b74ea6..b684d48 100644 --- a/Sources/cmdcmd/CmdChord.swift +++ b/Sources/cmdcmd/CmdChord.swift @@ -14,11 +14,31 @@ private struct CmdChordStateMachine { mutating func handleFlags(keyCode: Int, flags: CGEventFlags) -> Bool { let wasBothDown = leftDown && rightDown let raw = flags.rawValue + let commandDown = raw & CGEventFlags.maskCommand.rawValue != 0 + let leftBit = commandDown && raw & 0x8 != 0 + let rightBit = commandDown && raw & 0x10 != 0 switch keyCode { case kVK_Command: - leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0 + if !commandDown { + leftDown = false + rightDown = false + } else { + // Side-specific flags are not perfectly consistent for very + // fast chords: sometimes both sides are present, sometimes + // only the side that changed is present. Treat side bits as + // authoritative when they exist, but preserve the previously + // known opposite side unless this event is that side changing. + leftDown = leftBit || (!rightBit && commandDown) + if rightBit { rightDown = true } + } case kVK_RightCommand: - rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0 + if !commandDown { + leftDown = false + rightDown = false + } else { + rightDown = rightBit || (!leftBit && commandDown) + if leftBit { leftDown = true } + } default: return false } @@ -57,7 +77,23 @@ final class CmdChord { init(handler: @escaping () -> Void) { self.handler = handler + if installEventTap() { + Log.write("cmd-cmd event tap installed") + } else { + Log.write("cmd-cmd event tap unavailable; using NSEvent monitors") + installFallbackMonitors() + } + } + deinit { + for m in monitors { NSEvent.removeMonitor(m) } + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } + if let eventTapRunLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapRunLoopSource, .commonModes) + } + } + + private func installFallbackMonitors() { let global = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] e in self?.handleFlags(e) } @@ -73,30 +109,20 @@ final class CmdChord { return e } monitors = [global, local, globalKey, localKey].compactMap { $0 } - installEventTap() } - deinit { - for m in monitors { NSEvent.removeMonitor(m) } - if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } - if let eventTapRunLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapRunLoopSource, .commonModes) - } - } - - private func installEventTap() { + @discardableResult + private func installEventTap() -> Bool { let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) let callback: CGEventTapCallBack = { _, type, event, userInfo in guard let userInfo else { return Unmanaged.passUnretained(event) } let chord = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode)) let flags = event.flags - DispatchQueue.main.async { - if type == .keyDown { - chord.markContaminated() - } else if type == .flagsChanged { - chord.handleFlags(keyCode: keyCode, flags: flags) - } + if type == .keyDown { + chord.markContaminated() + } else if type == .flagsChanged { + chord.handleFlags(keyCode: keyCode, flags: flags) } return Unmanaged.passUnretained(event) } @@ -109,14 +135,14 @@ final class CmdChord { callback: callback, userInfo: ref ) else { - Log.write("cmd-cmd event tap unavailable") - return + return false } eventTap = tap let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) eventTapRunLoopSource = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) + return true } private func markContaminated() { @@ -130,6 +156,7 @@ final class CmdChord { private func handleFlags(keyCode: Int, flags: CGEventFlags) { if state.handleFlags(keyCode: keyCode, flags: flags) { + Log.debug("cmd-cmd chord fired keyCode=\(keyCode) flags=0x\(String(flags.rawValue, radix: 16))") DispatchQueue.main.async { self.handler() } } } @@ -153,6 +180,23 @@ final class CmdChord { expect(s.handleFlags(keyCode: kVK_RightCommand, flags: both), "left-held second right-tap did not fire") } + do { + var s = CmdChordStateMachine() + expect(s.handleFlags(keyCode: kVK_Command, flags: both), "coalesced both-command event did not fire") + } + + do { + var s = CmdChordStateMachine() + expect(!s.handleFlags(keyCode: kVK_Command, flags: left), "left down alone fired before side-bit fallback") + expect(s.handleFlags(keyCode: kVK_RightCommand, flags: right), "right-side-only second event did not fire") + } + + do { + var s = CmdChordStateMachine() + expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: right), "right down alone fired before side-bit fallback") + expect(s.handleFlags(keyCode: kVK_Command, flags: left), "left-side-only second event did not fire") + } + do { var s = CmdChordStateMachine() expect(!s.handleFlags(keyCode: kVK_RightCommand, flags: right), "right down alone fired") diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index cec68f6..ecf305c 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -16,12 +16,20 @@ enum WindowScope: String, Codable, CaseIterable { case allSpaces = "all-spaces" } +enum PreviewQuality: String, Codable, CaseIterable { + case efficient + case balanced + case sharp +} + struct Config: Codable { var animations: Bool var animationSpeed: Double? var trigger: String? var bindings: [String: Action] var livePreviews: Bool? + var trackpadSwipe: Bool? + var previewQuality: String? var displayMode: DisplayMode? var letterJump: Bool? var usageOrdering: Bool? @@ -34,13 +42,15 @@ struct Config: Codable { } var triggerSpec: String { trigger ?? "cmd-cmd" } var livePreviewsEnabled: Bool { livePreviews ?? true } + var trackpadSwipeEnabled: Bool { trackpadSwipe ?? true } + var previewQualityMode: PreviewQuality { PreviewQuality(rawValue: previewQuality ?? "") ?? .balanced } var displayModeOrDefault: DisplayMode { displayMode ?? .dock } var letterJumpEnabled: Bool { letterJump ?? true } var usageOrderingEnabled: Bool { usageOrdering ?? false } var tilePicksMode: TilePicks { tilePicks ?? .letters } var windowScopeMode: WindowScope { WindowScope(rawValue: windowScope ?? "") ?? .currentSpace } - static let `default` = Config(animations: true, animationSpeed: nil, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil, windowScope: nil) + static let `default` = Config(animations: true, animationSpeed: nil, trigger: nil, bindings: [:], livePreviews: nil, trackpadSwipe: nil, previewQuality: nil, displayMode: nil, letterJump: nil, usageOrdering: nil, tilePicks: nil, windowScope: nil) static var fileURL: URL { URL(fileURLWithPath: NSHomeDirectory()) @@ -377,6 +387,16 @@ struct Config: Codable { lines.append(" // faster and lighter, especially with many windows open.") lines.append(" \"livePreviews\": true,") lines.append("") + lines.append(" // Built-in three-finger trackpad swipe gestures. If macOS also uses") + lines.append(" // three-finger swipes for Mission Control / App Exposé, disable those") + lines.append(" // system gestures or turn this off.") + lines.append(" \"trackpadSwipe\": true,") + lines.append("") + lines.append(" // Preview capture quality: \"efficient\" uses nominal-resolution captures;") + lines.append(" // \"balanced\" switches to Retina only when you pick, click, peek,") + lines.append(" // or pop a tile; \"sharp\" asks for Retina captures for every tile.") + lines.append(" \"previewQuality\": \"balanced\",") + lines.append("") lines.append(" // Which windows to show: \"current-space\" keeps existing behavior;") lines.append(" // \"all-spaces\" also includes windows from other macOS Spaces.") lines.append(" \"windowScope\": \"current-space\",") diff --git a/Sources/cmdcmd/InputInertiaShield.swift b/Sources/cmdcmd/InputInertiaShield.swift new file mode 100644 index 0000000..755cc05 --- /dev/null +++ b/Sources/cmdcmd/InputInertiaShield.swift @@ -0,0 +1,74 @@ +import CoreGraphics +import Foundation + +final class InputInertiaShield { + static let shared = InputInertiaShield() + + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var suppressUntil: CFAbsoluteTime = 0 + + private static func mask(_ rawType: Int) -> CGEventMask { + CGEventMask(1) << CGEventMask(rawType) + } + + private static let swallowedEventMask: CGEventMask = + mask(Int(CGEventType.scrollWheel.rawValue)) | + mask(18) | // rotate + mask(19) | // begin gesture + mask(20) | // end gesture + mask(29) | // gesture + mask(30) | // magnify + mask(31) // swipe + + private init() {} + + func suppress(for duration: TimeInterval) { + suppressUntil = max(suppressUntil, CFAbsoluteTimeGetCurrent() + duration) + installIfNeeded() + if let tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + } + + private func installIfNeeded() { + guard tap == nil else { return } + guard let newTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: Self.swallowedEventMask, + callback: Self.callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + Log.write("input inertia shield unavailable") + return + } + + tap = newTap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newTap, 0) + if let runLoopSource { + CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + Log.write("input inertia shield installed") + } + + private static let callback: CGEventTapCallBack = { _, type, event, refcon in + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let refcon { + let shield = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + if let tap = shield.tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + } + return Unmanaged.passUnretained(event) + } + + guard let refcon else { return Unmanaged.passUnretained(event) } + let shield = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + if CFAbsoluteTimeGetCurrent() < shield.suppressUntil { + return nil + } + return Unmanaged.passUnretained(event) + } +} diff --git a/Sources/cmdcmd/MouseClickInterceptor.swift b/Sources/cmdcmd/MouseClickInterceptor.swift new file mode 100644 index 0000000..f4a1296 --- /dev/null +++ b/Sources/cmdcmd/MouseClickInterceptor.swift @@ -0,0 +1,70 @@ +import CoreGraphics +import Foundation + +final class MouseClickInterceptor { + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private let handler: (CGEventType, CGPoint) -> Void + private var enabled = false + + private static let eventMask: CGEventMask = + (CGEventMask(1) << CGEventMask(CGEventType.leftMouseDown.rawValue)) | + (CGEventMask(1) << CGEventMask(CGEventType.leftMouseDragged.rawValue)) | + (CGEventMask(1) << CGEventMask(CGEventType.leftMouseUp.rawValue)) + + init(handler: @escaping (CGEventType, CGPoint) -> Void) { + self.handler = handler + install() + } + + func setEnabled(_ enabled: Bool) { + self.enabled = enabled + if let tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + } + + private func install() { + guard tap == nil else { return } + guard let newTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: Self.eventMask, + callback: Self.callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + Log.write("mouse click interceptor unavailable") + return + } + + tap = newTap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newTap, 0) + if let runLoopSource { + CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + Log.write("mouse click interceptor installed") + } + + private static let callback: CGEventTapCallBack = { _, type, event, refcon in + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let refcon { + let interceptor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + if let tap = interceptor.tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + } + return Unmanaged.passUnretained(event) + } + + guard let refcon else { return Unmanaged.passUnretained(event) } + let interceptor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + guard interceptor.enabled else { return Unmanaged.passUnretained(event) } + + let location = event.location + DispatchQueue.main.async { + interceptor.handler(type, location) + } + return nil + } +} diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 6a2be88..0bcc34d 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -14,6 +14,7 @@ final class Overlay { private var windowsByDisplay: [String: NSWindow] = [:] private var backgroundLayersByDisplay: [String: CALayer] = [:] private var visible = false + private var ignoreResignUntil: CFAbsoluteTime = 0 private var allTiles: [Tile] = [] private var tiles: [Tile] = [] private var gridCols: Int = 1 @@ -28,6 +29,20 @@ final class Overlay { private var preferredSelection: PreferredSelection? private var returnFocus: PreferredSelection? private var dragState: DragState? + private var firstTapFallbackArmed = false + private var pendingTapPick: DispatchWorkItem? + private var backgroundTapPickArmed = false + private var backgroundTapStartPoint: CGPoint? + private var swipeGesture: SwipeGestureState? + private var interactiveFrames: [InteractiveFrame] = [] + private var interactiveProgress: CGFloat = 1 + private var interactiveFinishTargetVisible: Bool? + private var interactiveFinishShouldPick = false + private var isInteractiveTransition = false + private var interactivePickFrames: [InteractivePickFrame] = [] + private var interactivePickProgress: CGFloat = 0 + private var interactivePickIndex: Int? + private var isInteractivePickTransition = false private var lastLetterJump: String? private let tracker: SpaceTracker private var config: Config @@ -38,12 +53,23 @@ final class Overlay { if config.tilePicksMode != .letters { pickBuffer = "" } + applyPreviewQuality() } private var displayKey: String = "main" private var activeScreen: NSScreen? + private enum InputMode { + // Keyboard is binary: key presses immediately show/pick/dismiss the + // highlighted tile. No fractional thresholds apply in this mode. + case keyboard + // Gesture is analog: three-finger swipes scrub the animation and use + // thresholds before committing. Mouse/tap input also uses this mode. + case gesture + } + private var paneColors: [CGWindowID: String] = [:] + private var inputMode: InputMode = .keyboard private struct DragState { var index: Int @@ -52,6 +78,30 @@ final class Overlay { var moved: Bool } + private struct InteractiveFrame { + let tile: Tile + let source: CGRect + let grid: CGRect + } + + private struct InteractivePickFrame { + let tile: Tile + let start: CGRect + let end: CGRect + let startOpacity: Float + let endOpacity: Float + let selected: Bool + } + + private struct SwipeGestureState { + let startedVisible: Bool + var progress: CGFloat + var interactiveStarted: Bool + var rejected: Bool + var pickIndex: Int? + var preactivatedPick: Bool + } + private struct PreferredSelection { var windowID: CGWindowID? let processID: pid_t @@ -85,6 +135,7 @@ final class Overlay { private var workspaceObserver: NSObjectProtocol? private var appActivationObserver: NSObjectProtocol? private var activeSpaceObserver: NSObjectProtocol? + private var clickInterceptor: MouseClickInterceptor? private var activityTimer: Timer? private let search = SearchField() private var searchQuery: String = "" @@ -121,12 +172,16 @@ final class Overlay { init(tracker: SpaceTracker, config: Config) { self.tracker = tracker self.config = config + clickInterceptor = MouseClickInterceptor { [weak self] type, location in + self?.handleInterceptedMouseEvent(type: type, cgLocation: location) + } workspaceObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let self, self.visible, !self.isPicking else { return } + guard CFAbsoluteTimeGetCurrent() >= self.ignoreResignUntil else { return } // In all-spaces mode the overlay intentionally follows Space // switches, so don't dismiss just because macOS briefly changes // focus while moving between desktops. @@ -165,20 +220,540 @@ final class Overlay { } } + func enterKeyboardMode() { + inputMode = .keyboard + } + func toggle() { + inputMode = .keyboard if visible { if NSApp.isActive { dismiss() } else { - NSApp.activate(ignoringOtherApps: true) - window?.makeKeyAndOrderFront(nil) - if let v = view { window?.makeFirstResponder(v) } + focusOverlayWindow() } } else { show() } } + func showFromExternalTrigger() { + inputMode = .keyboard + ignoreResignBriefly() + if visible { + focusOverlayWindow() + } else { + show() + } + } + + func hideFromExternalTrigger() { + inputMode = .keyboard + guard visible, !isPicking else { return } + InputInertiaShield.shared.suppress(for: 0.75) + hide() + } + + func performExternalAction(_ action: Action) { + inputMode = .keyboard + ignoreResignBriefly() + if visible { + focusOverlayWindow() + dispatch(action) + } else { + show() + } + } + + func handleTrackpadSwipe(_ event: TrackpadSwipeEvent) { + guard !isPicking else { return } + let fullTravel: CGFloat = 0.34 + let deadZone: CGFloat = 0.025 + let swipeCommitFraction: CGFloat = 0.33 + let swipeUpActivationFraction: CGFloat = 0.08 + + switch event.phase { + case .began: + inputMode = .gesture + swipeGesture = SwipeGestureState( + startedVisible: visible, + progress: visible ? 1 : 0, + interactiveStarted: false, + rejected: false, + pickIndex: nil, + preactivatedPick: false + ) + case .changed: + guard var gesture = swipeGesture else { return } + if !gesture.interactiveStarted, + abs(event.deltaX) > max(0.08, abs(event.deltaY) * 1.35) { + gesture.rejected = true + swipeGesture = gesture + return + } + guard !gesture.rejected else { return } + + if gesture.startedVisible { + let progress = Self.clamp01(1 + (event.deltaY / fullTravel)) + gesture.progress = progress + if progress < 1 - deadZone { + InputInertiaShield.shared.suppress(for: 0.35) + } + + if !gesture.interactiveStarted, progress < 1 - deadZone { + gesture.interactiveStarted = beginInteractiveVisibilityTransition(progress: progress) + } + if gesture.interactiveStarted { + updateInteractiveVisibilityTransition(progress: progress) + } + } else { + let progress = Self.clamp01(event.deltaY / fullTravel) + gesture.progress = progress + + if !gesture.interactiveStarted, progress >= swipeUpActivationFraction { + gesture.interactiveStarted = beginInteractiveVisibilityTransition(progress: progress) + } + if gesture.interactiveStarted { + updateInteractiveVisibilityTransition(progress: progress) + } + } + swipeGesture = gesture + case .ended, .cancelled: + guard let gesture = swipeGesture else { return } + swipeGesture = nil + if gesture.interactiveStarted { + // Swipe gestures commit once they cross 33% of the full + // travel: swipe up opens, swipe down returns to the original + // focused window. Reversing back across that line before + // lifting cancels the commit. + if gesture.startedVisible { + let swipeDownFraction = 1 - gesture.progress + let shouldHide = event.phase == .ended && swipeDownFraction >= swipeCommitFraction + finishInteractiveVisibilityTransition(visible: !shouldHide) + } else { + let shouldShow = event.phase == .ended && gesture.progress >= swipeCommitFraction + finishInteractiveVisibilityTransition(visible: shouldShow) + } + } else if !gesture.rejected, event.phase == .ended { + if gesture.startedVisible, (-event.deltaY / fullTravel) >= swipeCommitFraction { + hideFromExternalTrigger() + } else if !gesture.startedVisible, (event.deltaY / fullTravel) >= swipeCommitFraction { + showFromExternalTrigger() + } + } + case .tap: + pickFromTrackpadTap() + } + } + + private func pickFromTrackpadTap() { + guard visible, !isPicking else { return } + inputMode = .gesture + cancelPendingTapPick() + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + + if selectTileUnderCursorIfPresent() { + firstTapFallbackArmed = false + pick() + return + } + + if firstTapFallbackArmed, tiles.indices.contains(selectedIndex) { + firstTapFallbackArmed = false + pick() + } + } + + @discardableResult + private func selectTileUnderCursorIfPresent() -> Bool { + guard let idx = tileIndexUnderCursor() else { return false } + selectedIndex = idx + updateSelection() + return true + } + + private func tileIndexUnderCursor() -> Int? { + let screenPoint = NSEvent.mouseLocation + let candidates = windowsByDisplay.isEmpty + ? window.map { [$0] } ?? [] + : Array(windowsByDisplay.values) + guard let targetWindow = candidates.first(where: { $0.frame.contains(screenPoint) }) ?? window else { return nil } + let point = CGPoint( + x: screenPoint.x - targetWindow.frame.minX, + y: screenPoint.y - targetWindow.frame.minY + ) + return tiles.firstIndex(where: { tileContainsPoint($0, point: point) }) + } + + private func preactivateInteractiveSwipePick(index: Int?) { + guard visible, !isPicking else { return } + let resolvedIndex = index.flatMap { tiles.indices.contains($0) ? $0 : nil } + ?? (tiles.indices.contains(selectedIndex) ? selectedIndex : nil) + guard let resolvedIndex else { return } + selectedIndex = resolvedIndex + updateSelection() + let tile = tiles[resolvedIndex] + let windowID = CGWindowID(tile.window.windowID) + Log.debug("swipe-down preactivating index=\(resolvedIndex) wid=\(windowID)") + ignoreResignBriefly() + activateSpaceIfNeeded(for: tile.window) + activateWindow(pid: tile.ownerPID, windowID: windowID, title: tile.window.title, reassert: false) + } + + private func pickFromSwipeDown(index: Int? = nil, selectUnderCursor: Bool = true) { + guard visible, !isPicking else { return } + InputInertiaShield.shared.suppress(for: 0.75) + cancelPendingTapPick() + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + + if let index, tiles.indices.contains(index) { + selectedIndex = index + updateSelection() + Log.debug("swipe-down picking armed tile index=\(index) wid=\(tiles[index].window.windowID)") + } else if selectUnderCursor, inputMode != .keyboard { + selectTileUnderCursorIfPresent() + } + + guard tiles.indices.contains(selectedIndex) else { + window?.alphaValue = 0 + hide(activatePrevious: false) + return + } + + firstTapFallbackArmed = false + pick() + } + + private func completeInteractiveSwipePick(index: Int?) { + guard visible, !isPicking else { return } + InputInertiaShield.shared.suppress(for: 0.75) + cancelPendingTapPick() + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + + if let index, tiles.indices.contains(index) { + selectedIndex = index + updateSelection() + Log.debug("swipe-down completing interactive pick index=\(index) wid=\(tiles[index].window.windowID)") + } + + guard tiles.indices.contains(selectedIndex) else { + window?.alphaValue = 0 + hide(activatePrevious: false) + return + } + + let tile = tiles[selectedIndex] + promoteFocusedPreview(tile) + let pid = tile.ownerPID + let windowID = CGWindowID(tile.window.windowID) + let title = tile.window.title + prevFrontPID = 0 + prevPickedWindowID = windowID + firstTapFallbackArmed = false + isPicking = true + + // The swipe gesture already scrubbed/finished the zoom-in animation. + // Activate directly here instead of starting pick()'s second animation, + // so the gesture's animation is the full end-to-end pick transition. + activateSpaceIfNeeded(for: tile.window) + activateWindow(pid: pid, windowID: windowID, title: title) + window?.alphaValue = 0 + hide(activatePrevious: false) + isPicking = false + } + + private func ignoreResignBriefly() { + ignoreResignUntil = CFAbsoluteTimeGetCurrent() + 1.0 + } + + private func focusOverlayWindow() { + NSApp.activate(ignoringOtherApps: true) + window?.orderFrontRegardless() + window?.makeKeyAndOrderFront(nil) + if let v = view { window?.makeFirstResponder(v) } + } + + private func handleInterceptedMouseEvent(type: CGEventType, cgLocation: CGPoint) { + guard visible, !isPicking else { return } + let screenPoint = Self.nsScreenPoint(fromCGEventLocation: cgLocation) + let candidates = windowsByDisplay.isEmpty + ? window.map { [$0] } ?? [] + : Array(windowsByDisplay.values) + guard let targetWindow = candidates.first(where: { $0.frame.contains(screenPoint) }) ?? window else { return } + let point = CGPoint( + x: screenPoint.x - targetWindow.frame.minX, + y: screenPoint.y - targetWindow.frame.minY + ) + switch type { + case .leftMouseDown: + mouseDownAt(point) + case .leftMouseDragged: + mouseDraggedAt(point) + case .leftMouseUp: + mouseUpAt(point) + default: + break + } + } + + private func armFirstTapFallback() { + firstTapFallbackArmed = true + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + cancelPendingTapPick() + } + + private func cancelPendingTapPick() { + pendingTapPick?.cancel() + pendingTapPick = nil + } + + private func scheduleTapPickFallback(index: Int, requireMouseDownState: Bool) { + cancelPendingTapPick() + let work = DispatchWorkItem { [weak self] in + guard let self, + self.visible, + !self.isPicking, + self.tiles.indices.contains(index) else { return } + if requireMouseDownState { + guard let state = self.dragState, + state.index == index, + !state.moved else { return } + } else { + guard self.backgroundTapPickArmed else { return } + } + self.selectedIndex = index + self.updateSelection() + self.dragState = nil + self.backgroundTapPickArmed = false + self.backgroundTapStartPoint = nil + self.pick() + } + pendingTapPick = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.14, execute: work) + } + + private func beginInteractiveVisibilityTransition(progress: CGFloat) -> Bool { + guard config.animations, config.windowScopeMode == .currentSpace else { return false } + ignoreResignBriefly() + isInteractiveTransition = true + interactiveProgress = Self.clamp01(progress) + interactiveFinishTargetVisible = nil + interactiveFinishShouldPick = false + + if visible { + guard !tiles.isEmpty else { + isInteractiveTransition = false + return false + } + prepareInteractiveFramesFromCurrentTiles() + applyInteractiveVisibilityProgress(interactiveProgress) + } else { + show(interactive: true, initialProgress: interactiveProgress) + } + return true + } + + private func updateInteractiveVisibilityTransition(progress: CGFloat) { + interactiveProgress = Self.clamp01(progress) + guard isInteractiveTransition, !interactiveFrames.isEmpty else { return } + applyInteractiveVisibilityProgress(interactiveProgress) + } + + private func beginInteractivePickTransition(index requestedIndex: Int?) -> Bool { + guard visible, + config.animations, + config.windowScopeMode == .currentSpace else { return false } + let index = requestedIndex.flatMap { tiles.indices.contains($0) ? $0 : nil } + ?? tileIndexUnderCursor() + ?? (tiles.indices.contains(selectedIndex) ? selectedIndex : nil) + guard let index, tiles.indices.contains(index) else { return false } + + ignoreResignBriefly() + selectedIndex = index + updateSelection() + promoteFocusedPreview(tiles[index]) + guard let overlayWindow = overlayWindow(for: tiles[index].window) ?? window else { return false } + interactivePickFrames = tiles.enumerated().map { i, tile in + let start = tile.layer.presentation()?.frame ?? tile.layer.frame + let end = Self.contentLocalRect(forSourceCGFrame: tile.window.frame, overlayWindow: overlayWindow) + return InteractivePickFrame( + tile: tile, + start: start, + end: end, + startOpacity: Float(tile.layer.presentation()?.opacity ?? tile.layer.opacity), + endOpacity: 1, + selected: i == index + ) + } + interactivePickProgress = 0 + interactivePickIndex = index + isInteractivePickTransition = true + isInteractiveTransition = false + interactiveFrames = [] + suspendFrames() + applyInteractivePickProgress(0) + return true + } + + private func updateInteractivePickTransition(progress: CGFloat) { + interactivePickProgress = Self.clamp01(progress) + guard isInteractivePickTransition, !interactivePickFrames.isEmpty else { return } + applyInteractivePickProgress(interactivePickProgress) + } + + private func finishInteractivePickTransition(commit: Bool, index requestedIndex: Int?) { + guard isInteractivePickTransition, !interactivePickFrames.isEmpty else { + if commit { pickFromSwipeDown(index: requestedIndex, selectUnderCursor: requestedIndex == nil) } + return + } + if let requestedIndex, tiles.indices.contains(requestedIndex) { + selectedIndex = requestedIndex + interactivePickIndex = requestedIndex + updateSelection() + } + if commit { + InputInertiaShield.shared.suppress(for: 0.75) + } + + let targetProgress: CGFloat = commit ? 1 : 0 + let distance = abs(targetProgress - interactivePickProgress) + let duration = max(0.06, animationDuration(Self.baseShowDuration) * TimeInterval(distance)) + CATransaction.begin() + CATransaction.setAnimationDuration(duration) + CATransaction.setAnimationTimingFunction(Self.smoothEasing) + CATransaction.setCompletionBlock { [weak self] in + guard let self else { return } + let finalIndex = self.interactivePickIndex ?? self.selectedIndex + self.isInteractivePickTransition = false + self.interactivePickFrames = [] + self.interactivePickProgress = targetProgress + self.interactivePickIndex = nil + if commit { + self.completeInteractiveSwipePick(index: finalIndex) + } else { + self.backgroundLayer?.opacity = 1 + for t in self.tiles { + t.layer.zPosition = 0 + t.layer.opacity = 1 + } + self.resumeFrames(after: 0) + self.updateSelection() + self.focusOverlayWindow() + } + } + for frame in interactivePickFrames { + frame.tile.setFrame(commit ? frame.end : frame.start) + frame.tile.layer.opacity = commit ? frame.endOpacity : frame.startOpacity + } + backgroundLayer?.opacity = Float(commit ? 0 : 1) + CATransaction.commit() + } + + private func applyInteractivePickProgress(_ rawProgress: CGFloat) { + let progress = Self.clamp01(rawProgress) + CATransaction.begin() + CATransaction.setDisableActions(true) + for frame in interactivePickFrames { + frame.tile.setFrame(Self.interpolate(from: frame.start, to: frame.end, progress: progress)) + frame.tile.layer.opacity = frame.startOpacity + (frame.endOpacity - frame.startOpacity) * Float(progress) + if frame.selected { + frame.tile.layer.zPosition = 1_000_000 + frame.tile.layer.borderWidth = 3 * (1 - progress) + frame.tile.layer.shadowOpacity = Float(0.6 * (1 - progress) + 0.35 * progress) + } else { + frame.tile.layer.zPosition = 0 + } + } + backgroundLayer?.opacity = Float(1 - progress) + CATransaction.commit() + } + + private func finishInteractiveVisibilityTransition(visible targetVisible: Bool, pickSelected: Bool = false, pickIndex: Int? = nil) { + guard isInteractiveTransition else { + if targetVisible { showFromExternalTrigger() } + else if pickSelected { pickFromSwipeDown(index: pickIndex, selectUnderCursor: pickIndex == nil) } + else { hideFromExternalTrigger() } + return + } + guard !interactiveFrames.isEmpty else { + interactiveFinishTargetVisible = targetVisible + interactiveFinishShouldPick = pickSelected + return + } + + if pickSelected { + if let pickIndex, tiles.indices.contains(pickIndex) { + selectedIndex = pickIndex + updateSelection() + } else { + selectTileUnderCursorIfPresent() + } + } + if !targetVisible { + InputInertiaShield.shared.suppress(for: 0.75) + if !pickSelected { + promoteFocusedPreview(returnFocusTile()) + } + } + + let targetProgress: CGFloat = targetVisible ? 1 : 0 + let distance = abs(targetProgress - interactiveProgress) + let duration = max(0.06, animationDuration(Self.baseShowDuration) * TimeInterval(distance)) + + CATransaction.begin() + CATransaction.setAnimationDuration(duration) + CATransaction.setAnimationTimingFunction(Self.smoothEasing) + CATransaction.setCompletionBlock { [weak self] in + guard let self else { return } + self.interactiveFinishTargetVisible = nil + self.interactiveFinishShouldPick = false + self.interactiveProgress = targetProgress + self.isInteractiveTransition = false + self.interactiveFrames = [] + if targetVisible { + self.resumeFrames(after: 0) + self.updateSelection() + } else if pickSelected { + self.completeInteractiveSwipePick(index: pickIndex) + } else { + self.window?.alphaValue = 0 + self.hide(activatePrevious: true) + } + } + for frame in interactiveFrames { + frame.tile.setFrame(targetVisible ? frame.grid : frame.source) + } + backgroundLayer?.opacity = Float(targetProgress) + CATransaction.commit() + } + + private func prepareInteractiveFramesFromCurrentTiles() { + guard let w = window else { return } + interactiveFrames = tiles.map { tile in + let grid = tile.layer.presentation()?.frame ?? tile.layer.frame + let source = Self.contentLocalRect(forSourceCGFrame: tile.window.frame, overlayWindow: w) + return InteractiveFrame(tile: tile, source: source, grid: grid) + } + suspendFrames() + } + + private func applyInteractiveVisibilityProgress(_ rawProgress: CGFloat) { + let progress = Self.clamp01(rawProgress) + CATransaction.begin() + CATransaction.setDisableActions(true) + for frame in interactiveFrames { + frame.tile.highlight = .none + frame.tile.setFrame(Self.interpolate(from: frame.source, to: frame.grid, progress: progress)) + frame.tile.layer.opacity = 1 + } + backgroundLayer?.opacity = Float(progress) + CATransaction.commit() + } + private func dismiss() { guard visible, !isPicking else { return } if tiles.indices.contains(selectedIndex) { @@ -188,7 +763,24 @@ final class Overlay { } } - private func show() { + private func show(interactive: Bool = false, initialProgress: CGFloat = 1) { + interactivePickFrames = [] + interactivePickIndex = nil + isInteractivePickTransition = false + if interactive { + isInteractiveTransition = true + interactiveProgress = Self.clamp01(initialProgress) + interactiveFrames = [] + interactiveFinishTargetVisible = nil + interactiveFinishShouldPick = false + } else { + isInteractiveTransition = false + interactiveFrames = [] + interactiveFinishTargetVisible = nil + interactiveFinishShouldPick = false + interactiveProgress = 1 + } + let t0 = CFAbsoluteTimeGetCurrent() let prevApp = NSWorkspace.shared.frontmostApplication prevFrontPID = prevApp?.processIdentifier ?? 0 @@ -202,6 +794,8 @@ final class Overlay { let baseDisplayKey = Self.displayKeyString(for: screen) displayKey = config.windowScopeMode == .allSpaces ? "all-displays" : baseDisplayKey visible = true + clickInterceptor?.setEnabled(true) + armFirstTapFallback() refreshGeneration &+= 1 let gen = refreshGeneration startActivityTimer() @@ -266,13 +860,15 @@ final class Overlay { } } // Capture each tile's final grid frame, then teleport to its source - // window frame so animateShow can fly all tiles in Exposé-style. + // window frame so animateShow (or an interactive swipe) can fly all + // tiles in Exposé-style. let gridFrames = tiles.map { $0.layer.frame } - if config.animations && scope == .currentSpace { - backgroundLayer?.opacity = 0 - for t in tiles { - let src = Self.contentLocalRect(forSourceCGFrame: t.window.frame, overlayWindow: primaryWindow) - t.setFrame(src) + let sourceFrames = tiles.map { Self.contentLocalRect(forSourceCGFrame: $0.window.frame, overlayWindow: primaryWindow) } + let canAnimateVisibility = config.animations && scope == .currentSpace + if canAnimateVisibility { + backgroundLayer?.opacity = Float(isInteractiveTransition ? interactiveProgress : 0) + for (i, t) in tiles.enumerated() { + t.setFrame(sourceFrames[i]) } } CATransaction.commit() @@ -284,7 +880,16 @@ final class Overlay { NSApp.activate(ignoringOtherApps: true) if let v = view { primaryWindow.makeFirstResponder(v) } let tFront = CFAbsoluteTimeGetCurrent() - if scope == .allSpaces { updateSelection() } + if isInteractiveTransition, canAnimateVisibility { + interactiveFrames = zip(zip(tiles, sourceFrames), gridFrames).map { pair, grid in + InteractiveFrame(tile: pair.0, source: pair.1, grid: grid) + } + suspendFrames() + applyInteractiveVisibilityProgress(interactiveProgress) + if let targetVisible = interactiveFinishTargetVisible { + finishInteractiveVisibilityTransition(visible: targetVisible, pickSelected: interactiveFinishShouldPick) + } + } else if scope == .allSpaces { updateSelection() } else { animateShow(gridFrames: gridFrames) } let tEnd = CFAbsoluteTimeGetCurrent() Log.debug(String(format: "render: filter=%.1f window=%.1f(new=%@) installTiles=%.1f orderFront+activate=%.1f animate=%.1f total=%.1f n=%d", @@ -302,6 +907,25 @@ final class Overlay { return NSScreen.screens.first(where: { $0.frame.contains(p) }) ?? NSScreen.main ?? NSScreen.screens.first! } + private static func clamp01(_ value: CGFloat) -> CGFloat { + min(1, max(0, value)) + } + + private static func nsScreenPoint(fromCGEventLocation point: CGPoint) -> CGPoint { + guard let primary = NSScreen.screens.first else { return point } + return CGPoint(x: point.x, y: primary.frame.maxY - point.y) + } + + private static func interpolate(from a: CGRect, to b: CGRect, progress rawProgress: CGFloat) -> CGRect { + let p = clamp01(rawProgress) + return CGRect( + x: a.origin.x + (b.origin.x - a.origin.x) * p, + y: a.origin.y + (b.origin.y - a.origin.y) * p, + width: a.width + (b.width - a.width) * p, + height: a.height + (b.height - a.height) * p + ) + } + private static func displayID(for screen: NSScreen) -> CGDirectDisplayID { let key = NSDeviceDescriptionKey("NSScreenNumber") return (screen.deviceDescription[key] as? NSNumber)?.uint32Value ?? CGMainDisplayID() @@ -393,6 +1017,7 @@ final class Overlay { private func frontmostApplicationChanged(_ app: NSRunningApplication) { guard app.processIdentifier != getpid(), app.activationPolicy == .regular else { return } + guard !(visible && (isInteractivePickTransition || isInteractiveTransition)) else { return } prevFrontPID = app.processIdentifier let focused = focusedWindow(pid: prevFrontPID) prevFrontWindowID = focused?.windowID @@ -630,6 +1255,7 @@ final class Overlay { if preferredMatch != nil { preferredSelection = nil } + applyPreviewQuality(captureIfNeeded: false) let live = config.livePreviewsEnabled Task { await withTaskGroup(of: Void.self) { group in @@ -948,6 +1574,7 @@ final class Overlay { } private func selectApp(startingWith letter: String) { + inputMode = .keyboard guard config.tilePicksMode != .letters, config.letterJumpEnabled, !tiles.isEmpty else { return } let needle = letter.lowercased() @@ -964,6 +1591,7 @@ final class Overlay { } private func dispatch(_ action: Action) { + inputMode = .keyboard switch action { case .pick: pick() case .dismiss: @@ -1197,12 +1825,31 @@ final class Overlay { private func activatePreferredWindow(_ selection: PreferredSelection) { let resolvedID = resolveMovedWindowID(for: selection) ?? selection.windowID ?? 0 - raiseAXWindow(pid: selection.processID, windowID: resolvedID, title: selection.title) - if let app = NSRunningApplication(processIdentifier: selection.processID) { + activateWindow(pid: selection.processID, windowID: resolvedID, title: selection.title) + Log.write("return focus wid=\(resolvedID) pid=\(selection.processID) title=\(selection.title ?? "")") + } + + private func activateWindow(pid: pid_t, windowID: CGWindowID, title: String?, reassert: Bool = true) { + raiseAXWindow(pid: pid, windowID: windowID, title: title) + if let app = NSRunningApplication(processIdentifier: pid) { + app.unhide() app.activate(options: [.activateAllWindows]) } - raiseAXWindow(pid: selection.processID, windowID: resolvedID, title: selection.title) - Log.write("return focus wid=\(resolvedID) pid=\(selection.processID) title=\(selection.title ?? "")") + raiseAXWindow(pid: pid, windowID: windowID, title: title) + + guard reassert else { return } + // WindowServer/AppKit can occasionally settle focus after the overlay + // orders out. Re-assert both AX focus and WindowServer z-order once + // more after the overlay is gone so the chosen document is frontmost, + // not only focused/main inside its app. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + guard let self else { return } + if let app = NSRunningApplication(processIdentifier: pid) { + app.unhide() + app.activate(options: [.activateAllWindows]) + } + self.raiseAXWindow(pid: pid, windowID: windowID, title: title) + } } private func resolveMovedWindowID(for selection: PreferredSelection) -> CGWindowID? { @@ -1324,6 +1971,19 @@ final class Overlay { private func hide(activatePrevious: Bool = true) { refreshGeneration &+= 1 + clickInterceptor?.setEnabled(false) + cancelPendingTapPick() + firstTapFallbackArmed = false + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + swipeGesture = nil + interactiveFrames = [] + interactiveFinishTargetVisible = nil + interactiveFinishShouldPick = false + isInteractiveTransition = false + interactivePickFrames = [] + interactivePickIndex = nil + isInteractivePickTransition = false let toStop = allTiles for t in toStop { t.suppressFrames = true } stopActivityTimer() @@ -1489,6 +2149,49 @@ final class Overlay { } } + private func applyPreviewQuality(captureIfNeeded: Bool = true) { + let live = config.livePreviewsEnabled + switch config.previewQualityMode { + case .efficient: + for t in allTiles { t.setBestResolution(false, live: live, captureIfNeeded: captureIfNeeded) } + case .balanced: + // Balanced mode keeps selection movement cheap. It does not follow + // the highlighted tile; explicit actions promote a tile via + // promoteFocusedPreview(_:): enter/click pick, peek, or pop. + for t in allTiles { t.setBestResolution(false, live: live, captureIfNeeded: captureIfNeeded) } + case .sharp: + for t in allTiles { t.setBestResolution(true, live: live, captureIfNeeded: captureIfNeeded) } + } + } + + private func promoteFocusedPreview(_ tile: Tile?) { + guard config.previewQualityMode == .balanced, let tile else { return } + // Don't show an old high-resolution cache during peek/pick/pop. Keep + // the current nominal frame visible, then replace it with a fresh + // Retina capture as soon as WindowServer returns one. + tile.setBestResolution( + true, + live: config.livePreviewsEnabled, + useCachedImage: false, + forceRefresh: true + ) + } + + private func returnFocusTile() -> Tile? { + if let focus = returnFocus, + let idx = preferredSelectionIndex(in: tiles, for: focus) { + return tiles[idx] + } + if let id = prevFrontWindowID, + let tile = tiles.first(where: { CGWindowID($0.window.windowID) == id }) { + return tile + } + if let tile = tiles.first(where: { $0.ownerPID == prevFrontPID && ($0.window.title ?? "") == prevFrontTitle }) { + return tile + } + return tiles.first(where: { $0.ownerPID == prevFrontPID }) + } + private func move(dx: Int, dy: Int) { guard !tiles.isEmpty, !isZoomed else { return } if config.windowScopeMode == .allSpaces { @@ -1579,6 +2282,7 @@ final class Overlay { private func pick() { guard tiles.indices.contains(selectedIndex), !isPicking else { return } let tile = tiles[selectedIndex] + promoteFocusedPreview(tile) let pid = tile.ownerPID let windowID = CGWindowID(tile.window.windowID) let title = tile.window.title @@ -1590,11 +2294,7 @@ final class Overlay { config.animations, config.windowScopeMode != .allSpaces else { activateSpaceIfNeeded(for: tile.window) - raiseAXWindow(pid: pid, windowID: windowID, title: title) - if let app = NSRunningApplication(processIdentifier: pid) { - app.activate(options: [.activateAllWindows]) - } - raiseAXWindow(pid: pid, windowID: windowID, title: title) + activateWindow(pid: pid, windowID: windowID, title: title) hide(activatePrevious: false) isPicking = false return @@ -1634,11 +2334,7 @@ final class Overlay { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self else { return } self.activateSpaceIfNeeded(for: tile.window) - self.raiseAXWindow(pid: pid, windowID: windowID, title: title) - if let app = NSRunningApplication(processIdentifier: pid) { - app.activate(options: [.activateAllWindows]) - } - self.raiseAXWindow(pid: pid, windowID: windowID, title: title) + self.activateWindow(pid: pid, windowID: windowID, title: title) // Give WindowServer time to actually reorder before we drop // the overlay; without this the pre-activation window order // flashes through between hide() and activation taking @@ -1687,9 +2383,7 @@ final class Overlay { for win in windows { var wid: CGWindowID = 0 if _AXUIElementGetWindow(win, &wid) == .success, wid == windowID { - AXUIElementSetAttributeValue(app, kAXFocusedWindowAttribute as CFString, win) - AXUIElementSetAttributeValue(win, kAXMainAttribute as CFString, kCFBooleanTrue) - AXUIElementPerformAction(win, kAXRaiseAction as CFString) + selectAXWindow(win, in: app) return } } @@ -1698,22 +2392,39 @@ final class Overlay { var titleRef: CFTypeRef? AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef) if let t = titleRef as? String, t == title { - AXUIElementSetAttributeValue(app, kAXFocusedWindowAttribute as CFString, win) - AXUIElementSetAttributeValue(win, kAXMainAttribute as CFString, kCFBooleanTrue) - AXUIElementPerformAction(win, kAXRaiseAction as CFString) + selectAXWindow(win, in: app) return } } } + private func selectAXWindow(_ win: AXUIElement, in app: AXUIElement) { + // Focus is not always enough for AppKit apps: a window can be raised + // while a different document window is still considered the app's + // selected/main window. Set every writable AX hint we can, then raise. + AXUIElementSetAttributeValue(app, kAXMainWindowAttribute as CFString, win) + AXUIElementSetAttributeValue(app, kAXFocusedWindowAttribute as CFString, win) + AXUIElementSetAttributeValue(win, kAXMainAttribute as CFString, kCFBooleanTrue) + AXUIElementSetAttributeValue(win, kAXFocusedAttribute as CFString, kCFBooleanTrue) + AXUIElementSetAttributeValue(win, kAXSelectedAttribute as CFString, kCFBooleanTrue) + AXUIElementPerformAction(win, kAXRaiseAction as CFString) + } + private func mouseDownAt(_ point: NSPoint) { + inputMode = .gesture if isZoomed { dragState = nil pick() return } - guard let i = tiles.firstIndex(where: { $0.layer.frame.contains(point) }) else { + guard let i = tiles.firstIndex(where: { tileContainsPoint($0, point: point) }) else { dragState = nil + if firstTapFallbackArmed, tiles.indices.contains(selectedIndex) { + firstTapFallbackArmed = false + backgroundTapPickArmed = true + backgroundTapStartPoint = point + scheduleTapPickFallback(index: selectedIndex, requireMouseDownState: false) + } return } let tile = tiles[i] @@ -1727,13 +2438,33 @@ final class Overlay { tile.layer.zPosition = 1 selectedIndex = i updateSelection() + if firstTapFallbackArmed { + firstTapFallbackArmed = false + scheduleTapPickFallback(index: i, requireMouseDownState: true) + } + } + + private func tileContainsPoint(_ tile: Tile, point: CGPoint) -> Bool { + if tile.layer.frame.contains(point) { return true } + if let frame = tile.layer.presentation()?.frame, frame.contains(point) { return true } + return false } private func mouseDraggedAt(_ point: NSPoint) { + if backgroundTapPickArmed, + let start = backgroundTapStartPoint, + hypot(point.x - start.x, point.y - start.y) > 5 { + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + cancelPendingTapPick() + } guard var state = dragState, tiles.indices.contains(state.index) else { return } if !state.moved { let dist = hypot(point.x - state.startPoint.x, point.y - state.startPoint.y) - if dist > 5 { state.moved = true } + if dist > 5 { + state.moved = true + cancelPendingTapPick() + } } if state.moved { let tile = tiles[state.index] @@ -1754,8 +2485,17 @@ final class Overlay { private func mouseUpAt(_ point: NSPoint) { guard let state = dragState, tiles.indices.contains(state.index) else { dragState = nil + if backgroundTapPickArmed, tiles.indices.contains(selectedIndex) { + cancelPendingTapPick() + backgroundTapPickArmed = false + backgroundTapStartPoint = nil + pick() + } return } + cancelPendingTapPick() + backgroundTapPickArmed = false + backgroundTapStartPoint = nil let tile = tiles[state.index] tile.layer.zPosition = 0 if state.moved { @@ -1833,6 +2573,7 @@ final class Overlay { let w = avail.height * srcAR target = CGRect(x: avail.midX - w / 2, y: avail.minY, width: w, height: avail.height) } + promoteFocusedPreview(tiles[selectedIndex]) savedFrames = tiles.map { $0.layer.frame } isZoomed = true let duration = animationDuration(Self.basePeekDuration) @@ -1937,6 +2678,7 @@ final class Overlay { } private func appendPickBuffer(_ ch: String) { + inputMode = .keyboard guard config.tilePicksMode == .letters else { return } let candidate = pickBuffer + ch let matches = tiles.filter { tile in @@ -1959,6 +2701,7 @@ final class Overlay { } private func popPickBuffer() { + inputMode = .keyboard guard config.tilePicksMode == .letters else { return } guard !pickBuffer.isEmpty else { return } pickBuffer.removeLast() diff --git a/Sources/cmdcmd/OverlayView.swift b/Sources/cmdcmd/OverlayView.swift index faba35a..0b7efab 100644 --- a/Sources/cmdcmd/OverlayView.swift +++ b/Sources/cmdcmd/OverlayView.swift @@ -21,6 +21,10 @@ final class OverlayView: NSView { override var acceptsFirstResponder: Bool { true } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + override func keyDown(with event: NSEvent) { let bareMods = event.modifierFlags.intersection([.command, .shift, .option, .control]) if event.keyCode == 49 && bareMods.isEmpty { diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index ae2aa32..99dcf05 100644 --- a/Sources/cmdcmd/SettingsWindow.swift +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -11,7 +11,7 @@ final class SettingsWindowController: NSWindowController { init(config: Config) { model = SettingsModel(config: config) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 740), + contentRect: NSRect(x: 0, y: 0, width: 500, height: 860), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -30,6 +30,8 @@ private final class SettingsModel: ObservableObject { @Published var animations: Bool { didSet { save() } } @Published var animationSpeed: Double { didSet { save() } } @Published var livePreviews: Bool { didSet { save() } } + @Published var trackpadSwipe: Bool { didSet { save(); refreshTrackpadConflicts() } } + @Published var previewQuality: PreviewQuality { didSet { save() } } @Published var displayMode: DisplayMode { didSet { save() } } @Published var letterJump: Bool { didSet { save() } } @Published var usageOrdering: Bool { didSet { save() } } @@ -37,18 +39,22 @@ private final class SettingsModel: ObservableObject { @Published var windowScope: WindowScope { didSet { save() } } private var base: Config @Published var status: String = "" + @Published var trackpadConflicts: [TrackpadGestureConflict] = [] var onSave: ((Config) -> Void)? init(config: Config) { animations = config.animations animationSpeed = config.animationSpeedOrDefault livePreviews = config.livePreviewsEnabled + trackpadSwipe = config.trackpadSwipeEnabled + previewQuality = config.previewQualityMode displayMode = config.displayModeOrDefault letterJump = config.letterJumpEnabled usageOrdering = config.usageOrderingEnabled tilePicks = config.tilePicksMode windowScope = config.windowScopeMode base = config + refreshTrackpadConflicts() } func save() { @@ -56,6 +62,8 @@ private final class SettingsModel: ObservableObject { config.animations = animations config.animationSpeed = animationSpeed config.livePreviews = livePreviews + config.trackpadSwipe = trackpadSwipe + config.previewQuality = previewQuality.rawValue config.displayMode = displayMode config.letterJump = letterJump config.usageOrdering = usageOrdering @@ -66,6 +74,8 @@ private final class SettingsModel: ObservableObject { ("animations", animations ? "true" : "false"), ("animationSpeed", jsonNumber(animationSpeed)), ("livePreviews", livePreviews ? "true" : "false"), + ("trackpadSwipe", trackpadSwipe ? "true" : "false"), + ("previewQuality", "\"\(previewQuality.rawValue)\""), ("windowScope", "\"\(windowScope.rawValue)\""), ("displayMode", "\"\(displayMode.rawValue)\""), ("letterJump", letterJump ? "true" : "false"), @@ -88,6 +98,22 @@ private final class SettingsModel: ObservableObject { return s } + func refreshTrackpadConflicts() { + trackpadConflicts = TrackpadGestureSettings.conflicts() + } + + func disableMacOSThreeFingerSwipes() { + TrackpadGestureSettings.disableMacOSThreeFingerSwipes() + refreshTrackpadConflicts() + status = trackpadConflicts.isEmpty + ? "Disabled macOS 3-finger swipes" + : "Some system gestures are still enabled" + } + + func openTrackpadSettings() { + TrackpadGestureSettings.openTrackpadSettings() + } + func openConfig() { do { let url = try Config.ensureExists() @@ -148,6 +174,53 @@ private struct SettingsRootView: View { } .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 6) { + Text("Preview quality").font(.system(size: 13, weight: .medium)) + Picker("", selection: $model.previewQuality) { + Text("Efficient").tag(PreviewQuality.efficient) + Text("Balanced").tag(PreviewQuality.balanced) + Text("Sharp").tag(PreviewQuality.sharp) + } + .labelsHidden() + .pickerStyle(.segmented) + Text("Balanced keeps the grid light and switches to Retina only when you pick, click, peek, or pop a tile. Sharp asks for Retina captures for every tile.") + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: $model.trackpadSwipe) { + VStack(alignment: .leading, spacing: 2) { + Text("3-finger trackpad swipe").font(.system(size: 13, weight: .medium)) + Text("Swipe up to reveal cmdcmd; swipe down to return to the original window.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + + if model.trackpadSwipe && !model.trackpadConflicts.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("macOS also has 3-finger swipe gestures enabled, which can conflict with cmdcmd.") + .font(.caption) + .foregroundStyle(.secondary) + Text(model.trackpadConflicts.map { $0.name }.uniqued().joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Button("Disable macOS 3-finger swipes") { + model.disableMacOSThreeFingerSwipes() + } + Button("Open Trackpad Settings") { + model.openTrackpadSettings() + } + } + } + .padding(10) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } + } + Toggle(isOn: $model.letterJump) { VStack(alignment: .leading, spacing: 2) { Text("First-letter app jump").font(.system(size: 13, weight: .medium)) @@ -220,6 +293,13 @@ private struct SettingsRootView: View { } } .padding(24) - .frame(minWidth: 420, minHeight: 700) + .frame(minWidth: 460, minHeight: 820) + } +} + +private extension Array where Element == String { + func uniqued() -> [String] { + var seen = Set() + return filter { seen.insert($0).inserted } } } diff --git a/Sources/cmdcmd/SkyLightCapture.swift b/Sources/cmdcmd/SkyLightCapture.swift index 285d37d..222136b 100644 --- a/Sources/cmdcmd/SkyLightCapture.swift +++ b/Sources/cmdcmd/SkyLightCapture.swift @@ -48,11 +48,23 @@ final class SkyLightCapture: @unchecked Sendable { /// One-shot capture of a single window. Returns nil if the WindowServer /// produces no image (e.g. window minimized, gone, or wrong CGS state). - func captureImage(windowID: CGWindowID) -> CGImage? { + /// + /// `bestResolution` asks WindowServer for the backing-store-sized image + /// instead of forcing nominal/logical resolution. On Retina displays this + /// can be ~2x width/height (4x pixels). Some macOS/SkyLight combinations + /// may reject the higher-resolution option, so callers should tolerate nil + /// and fall back to nominal capture. + func captureImage(windowID: CGWindowID, bestResolution: Bool = false) -> CGImage? { + let opts = Self.kCGSCaptureIgnoreGlobalClipShape | + (bestResolution ? 0 : Self.kCGSCaptureNominalResolution) + return captureImage(windowID: windowID, options: opts) + ?? (bestResolution ? captureImage(windowID: windowID, options: Self.kCGSCaptureNominalResolution | Self.kCGSCaptureIgnoreGlobalClipShape) : nil) + } + + private func captureImage(windowID: CGWindowID, options: UInt32) -> CGImage? { var wid: UInt32 = windowID - let opts = Self.kCGSCaptureNominalResolution | Self.kCGSCaptureIgnoreGlobalClipShape guard let unmanaged = withUnsafePointer(to: &wid, { ptr in - captureWindowList(cid, ptr, 1, opts) + captureWindowList(cid, ptr, 1, options) }) else { return nil } diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 03874c1..e16f53f 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -4,33 +4,83 @@ import CoreGraphics final class Tile: NSObject { static let colorNames = ["green", "blue", "red", "yellow", "orange", "purple"] - private static let cacheLock = NSLock() - private static var frameCache: [CGWindowID: CGImage] = [:] - private static var frameCacheOrder: [CGWindowID] = [] - private static let frameCacheLimit = 100 - private static let captureQueue = DispatchQueue(label: "cmdcmd.tile.capture", qos: .userInteractive, attributes: .concurrent) - private static let pollInterval: TimeInterval = 1.0 / 15.0 + private struct FrameCacheKey: Hashable { + let windowID: CGWindowID + let bestResolution: Bool + } + + private struct FrameCacheEntry { + let image: CGImage + let bytes: Int + } - static func cachedFrame(for id: CGWindowID) -> CGImage? { + private static let cacheLock = NSLock() + private static var frameCache: [FrameCacheKey: FrameCacheEntry] = [:] + private static var frameCacheOrder: [FrameCacheKey] = [] + private static var frameCacheBytes = 0 + private static let frameCacheLimit = 64 + private static let frameCacheByteLimit = 64 * 1024 * 1024 + // Keep WindowServer capture pressure bounded. With many tiles, a + // concurrent queue can stampede CGSHWCaptureWindowList every frame. + private static let captureQueue = DispatchQueue(label: "cmdcmd.tile.capture", qos: .userInitiated) + private static let schedulerInterval: TimeInterval = 1.0 / 10.0 + private static let retinaPollInterval: TimeInterval = 1.0 / 10.0 + private static let nominalPollInterval: TimeInterval = 1.0 / 5.0 + private static let idlePollInterval: TimeInterval = 1.0 / 2.0 + private static let activitySampleInterval: TimeInterval = 0.5 + + static func cachedFrame(for id: CGWindowID, bestResolution: Bool = false) -> CGImage? { cacheLock.lock(); defer { cacheLock.unlock() } - return frameCache[id] + return frameCache[FrameCacheKey(windowID: id, bestResolution: bestResolution)]?.image } - static func setCachedFrame(_ image: CGImage, for id: CGWindowID) { + static func setCachedFrame(_ image: CGImage, for id: CGWindowID, bestResolution: Bool = false) { cacheLock.lock(); defer { cacheLock.unlock() } - if frameCache[id] == nil { - frameCacheOrder.append(id) - } else { - frameCacheOrder.removeAll { $0 == id } - frameCacheOrder.append(id) + let key = FrameCacheKey(windowID: id, bestResolution: bestResolution) + if let old = frameCache[key] { + frameCacheBytes -= old.bytes + frameCacheOrder.removeAll { $0 == key } } - frameCache[id] = image - while frameCacheOrder.count > frameCacheLimit { + let bytes = max(1, image.bytesPerRow * image.height) + frameCacheOrder.append(key) + frameCache[key] = FrameCacheEntry(image: image, bytes: bytes) + frameCacheBytes += bytes + while frameCacheOrder.count > 1 && + (frameCacheOrder.count > frameCacheLimit || frameCacheBytes > frameCacheByteLimit) { let evict = frameCacheOrder.removeFirst() - frameCache.removeValue(forKey: evict) + if let old = frameCache.removeValue(forKey: evict) { + frameCacheBytes -= old.bytes + } } } + private static func frameSignature(_ image: CGImage) -> UInt64? { + let width = 8 + let height = 8 + let bytesPerRow = width * 4 + var pixels = [UInt8](repeating: 0, count: bytesPerRow * height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue + guard let context = CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { return nil } + context.interpolationQuality = .low + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + var hash: UInt64 = 0xcbf29ce484222325 + for byte in pixels { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return hash + } + static func color(forName name: String) -> NSColor? { switch name { case "red": return hex(0xFF5F57) @@ -68,6 +118,10 @@ final class Tile: NSObject { private var hasRenderedLiveFrame = false var suppressFrames = false private var loggedFirstLiveFrame = false + private var bestResolution = false + private var lastCaptureAt: CFAbsoluteTime = 0 + private var lastActivitySampleAt: CFAbsoluteTime = 0 + private var lastFrameSignature: UInt64? init(window: WindowInfo, ownerPID: pid_t) { self.window = window @@ -163,7 +217,7 @@ final class Tile: NSObject { fallback.string = self.windowTitle super.init() - if let cached = Tile.cachedFrame(for: window.windowID) { + if let cached = Tile.cachedFrame(for: window.windowID, bestResolution: bestResolution) { CATransaction.begin() CATransaction.setDisableActions(true) inner.contents = cached @@ -176,6 +230,36 @@ final class Tile: NSObject { didSet { applyTint() } } + func setBestResolution( + _ enabled: Bool, + live: Bool, + captureIfNeeded: Bool = true, + useCachedImage: Bool = true, + forceRefresh: Bool = false + ) { + guard bestResolution != enabled || forceRefresh else { return } + bestResolution = enabled + hasRenderedLiveFrame = false + loggedFirstLiveFrame = false + lastCaptureAt = 0 + lastActivitySampleAt = 0 + let cached = useCachedImage ? Tile.cachedFrame(for: window.windowID, bestResolution: enabled) : nil + if let cached { + CATransaction.begin() + CATransaction.setDisableActions(true) + content.contents = cached + fallbackText.isHidden = true + CATransaction.commit() + hasRenderedFrame = true + } + if captureIfNeeded && (cached == nil || forceRefresh) { + Task { await snapshot(force: forceRefresh) } + } + if live, pollTimer == nil { + Task { await start() } + } + } + private func applyTint() { CATransaction.begin() CATransaction.setDisableActions(true) @@ -364,29 +448,37 @@ final class Tile: NSObject { /// Single-shot SkyLight capture used to seed the tile before the live /// poll has a frame. Cheap to call: returns nil and lets the cached /// thumbnail keep showing if SkyLight has nothing for this window yet. - func snapshot() async { - if cancelled || hasRenderedLiveFrame { return } + func snapshot(force: Bool = false) async { + if cancelled || (!force && hasRenderedLiveFrame) { return } guard let sl = SkyLightCapture.shared else { return } let wid = window.windowID - let image: CGImage? = await withCheckedContinuation { (cont: CheckedContinuation) in - Tile.captureQueue.async { cont.resume(returning: sl.captureImage(windowID: wid)) } + let best = bestResolution + let result: (image: CGImage, signature: UInt64?)? = await withCheckedContinuation { cont in + Tile.captureQueue.async { + guard let image = sl.captureImage(windowID: wid, bestResolution: best) else { + cont.resume(returning: nil) + return + } + cont.resume(returning: (image, Tile.frameSignature(image))) + } } - guard let image else { return } - if cancelled || hasRenderedLiveFrame { return } - Tile.setCachedFrame(image, for: wid) + guard let result else { return } + let image = result.image + if cancelled || bestResolution != best || (!force && hasRenderedLiveFrame) { return } + Tile.setCachedFrame(image, for: wid, bestResolution: best) await MainActor.run { - guard !self.hasRenderedLiveFrame else { return } + guard self.bestResolution == best, force || !self.hasRenderedLiveFrame else { return } CATransaction.begin() CATransaction.setDisableActions(true) self.content.contents = image self.fallbackText.isHidden = true CATransaction.commit() self.hasRenderedFrame = true - self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() + self.noteFrameSignature(result.signature, now: CFAbsoluteTimeGetCurrent()) } } - /// Start a 15 fps SkyLight poll. No-op if SkyLight is unavailable on this + /// Start a bounded SkyLight poll. No-op if SkyLight is unavailable on this /// macOS — tiles then just keep their cached thumbnail. func start() async { if cancelled { return } @@ -398,13 +490,20 @@ final class Tile: NSObject { stopPolling() let wid = window.windowID let t = DispatchSource.makeTimerSource(queue: Tile.captureQueue) - t.schedule(deadline: .now(), repeating: Tile.pollInterval, leeway: .milliseconds(10)) + t.schedule(deadline: .now(), repeating: Tile.schedulerInterval, leeway: .milliseconds(25)) t.setEventHandler { [weak self] in guard let self, !self.cancelled, !self.suppressFrames else { return } - guard let image = sl.captureImage(windowID: wid) else { return } - Tile.setCachedFrame(image, for: wid) + let now = CFAbsoluteTimeGetCurrent() + guard now - self.lastCaptureAt >= self.currentPollInterval() else { return } + self.lastCaptureAt = now + + let best = self.bestResolution + guard let image = sl.captureImage(windowID: wid, bestResolution: best) else { return } + let shouldSample = now - self.lastActivitySampleAt >= Tile.activitySampleInterval + let signature = shouldSample ? Tile.frameSignature(image) : nil + Tile.setCachedFrame(image, for: wid, bestResolution: best) DispatchQueue.main.async { [weak self] in - guard let self, !self.cancelled, !self.suppressFrames else { return } + guard let self, !self.cancelled, !self.suppressFrames, self.bestResolution == best else { return } CATransaction.begin() CATransaction.setDisableActions(true) self.content.contents = image @@ -412,10 +511,13 @@ final class Tile: NSObject { CATransaction.commit() self.hasRenderedFrame = true self.hasRenderedLiveFrame = true - self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() + if shouldSample { + self.lastActivitySampleAt = now + self.noteFrameSignature(signature, now: now) + } if !self.loggedFirstLiveFrame { self.loggedFirstLiveFrame = true - Log.write("tile first live frame wid=\(wid) size=\(image.width)x\(image.height)") + Log.write("tile first live frame wid=\(wid) size=\(image.width)x\(image.height) best=\(best)") } } } @@ -423,6 +525,23 @@ final class Tile: NSObject { pollTimer = t } + private func currentPollInterval() -> TimeInterval { + if bestResolution { return Tile.retinaPollInterval } + if isIdle { return Tile.idlePollInterval } + return Tile.nominalPollInterval + } + + private func noteFrameSignature(_ signature: UInt64?, now: CFAbsoluteTime) { + guard let signature else { + if lastFrameSignature == nil { lastSignificantChangeAt = now } + return + } + if lastFrameSignature != signature { + lastFrameSignature = signature + lastSignificantChangeAt = now + } + } + private func stopPolling() { pollTimer?.cancel() pollTimer = nil @@ -467,6 +586,7 @@ final class Tile: NSObject { } guard next != isIdle else { return } isIdle = next + Log.debug("tile idle wid=\(window.windowID) idle=\(next)") let target: Float = next ? 1 : 0 CATransaction.begin() CATransaction.setAnimationDuration(0.25) diff --git a/Sources/cmdcmd/TrackpadGestureSettings.swift b/Sources/cmdcmd/TrackpadGestureSettings.swift new file mode 100644 index 0000000..2b30d05 --- /dev/null +++ b/Sources/cmdcmd/TrackpadGestureSettings.swift @@ -0,0 +1,66 @@ +import AppKit +import Foundation + +struct TrackpadGestureConflict: Identifiable, Equatable { + let id: String + let domain: String + let key: String + let name: String + let value: Int +} + +enum TrackpadGestureSettings { + private static let domains = [ + "com.apple.AppleMultitouchTrackpad", + "com.apple.driver.AppleBluetoothMultitouch.trackpad", + ] + + private static let gestureKeys: [(key: String, name: String)] = [ + ("TrackpadThreeFingerVertSwipeGesture", "Mission Control / App Exposé"), + ("TrackpadThreeFingerHorizSwipeGesture", "Swipe between full-screen apps"), + ] + + static func conflicts() -> [TrackpadGestureConflict] { + var result: [TrackpadGestureConflict] = [] + for domain in domains { + for gesture in gestureKeys { + guard let value = intValue(forKey: gesture.key, domain: domain), value != 0 else { continue } + result.append(TrackpadGestureConflict( + id: "\(domain).\(gesture.key)", + domain: domain, + key: gesture.key, + name: gesture.name, + value: value + )) + } + } + return result + } + + static func disableMacOSThreeFingerSwipes() { + for domain in domains { + for gesture in gestureKeys { + CFPreferencesSetAppValue(gesture.key as CFString, NSNumber(value: 0), domain as CFString) + } + CFPreferencesAppSynchronize(domain as CFString) + UserDefaults(suiteName: domain)?.synchronize() + } + } + + static func openTrackpadSettings() { + let urls = [ + "x-apple.systempreferences:com.apple.Trackpad-Settings.extension", + "x-apple.systempreferences:com.apple.preference.trackpad", + ] + for raw in urls { + if let url = URL(string: raw), NSWorkspace.shared.open(url) { return } + } + } + + private static func intValue(forKey key: String, domain: String) -> Int? { + guard let value = CFPreferencesCopyAppValue(key as CFString, domain as CFString) else { return nil } + if let number = value as? NSNumber { return number.intValue } + if let string = value as? String { return Int(string) } + return nil + } +} diff --git a/Sources/cmdcmd/TrackpadSwipeMonitor.swift b/Sources/cmdcmd/TrackpadSwipeMonitor.swift new file mode 100644 index 0000000..c07685d --- /dev/null +++ b/Sources/cmdcmd/TrackpadSwipeMonitor.swift @@ -0,0 +1,59 @@ +import Foundation +import CMultitouch + +struct TrackpadSwipeEvent { + enum Phase { + case began + case changed + case ended + case cancelled + case tap + } + + let phase: Phase + let deltaX: CGFloat + let deltaY: CGFloat +} + +final class TrackpadSwipeMonitor { + private let handler: (TrackpadSwipeEvent) -> Void + private var running = false + + init?(handler: @escaping (TrackpadSwipeEvent) -> Void) { + self.handler = handler + let context = Unmanaged.passUnretained(self).toOpaque() + running = CMDSwipeMonitorStart({ phase, dx, dy, context in + guard let context else { return } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + let mappedPhase: TrackpadSwipeEvent.Phase + switch phase { + case CMDSwipePhaseBegin: + mappedPhase = .began + case CMDSwipePhaseUpdate: + mappedPhase = .changed + case CMDSwipePhaseEnd: + mappedPhase = .ended + case CMDSwipePhaseTap: + mappedPhase = .tap + default: + mappedPhase = .cancelled + } + let event = TrackpadSwipeEvent(phase: mappedPhase, deltaX: CGFloat(dx), deltaY: CGFloat(dy)) + DispatchQueue.main.async { + monitor.handler(event) + } + }, context) + + if !running { + Log.write("trackpad swipe monitor unavailable") + return nil + } + Log.write("trackpad swipe monitor started") + } + + deinit { + if running { + CMDSwipeMonitorStop() + } + } +} diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index 1751b41..b870b82 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -49,8 +49,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) var settingsFactory: (() -> SettingsWindowController)? + var urlHandler: ((URL) -> Bool)? { + didSet { flushPendingURLs() } + } private var settingsController: SettingsWindowController? private var statusItem: NSStatusItem? + private var pendingURLs: [URL] = [] func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { return buildAppMenu() @@ -111,6 +115,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return true } + func application(_ application: NSApplication, open urls: [URL]) { + pendingURLs.append(contentsOf: urls) + flushPendingURLs() + } + + private func flushPendingURLs() { + guard let urlHandler else { return } + let urls = pendingURLs + pendingURLs.removeAll() + for url in urls where !urlHandler(url) { + Log.write("unhandled URL: \(url.absoluteString)") + } + } + @objc func openSettings() { let controller = settingsController ?? settingsFactory?() settingsController = controller @@ -138,6 +156,72 @@ appDelegate.applyDisplayMode(appConfig.displayModeOrDefault) let tracker = SpaceTracker() let overlay = Overlay(tracker: tracker, config: appConfig) var trigger: AnyObject? +var trackpadSwipeMonitor: TrackpadSwipeMonitor? + +func updateTrackpadSwipeMonitor() { + guard appConfig.trackpadSwipeEnabled else { + trackpadSwipeMonitor = nil + Log.write("trackpad swipe monitor disabled") + return + } + if trackpadSwipeMonitor == nil { + trackpadSwipeMonitor = TrackpadSwipeMonitor { event in + overlay.handleTrackpadSwipe(event) + } + } + let conflicts = TrackpadGestureSettings.conflicts() + if !conflicts.isEmpty { + let names = Set(conflicts.map(\.name)).sorted().joined(separator: ", ") + Log.write("macOS 3-finger swipe gestures may conflict with cmdcmd: \(names)") + } +} + +updateTrackpadSwipeMonitor() + +func cmdcmdURLCommand(_ url: URL) -> String? { + guard url.scheme?.lowercased() == "cmdcmd" else { return nil } + var parts: [String] = [] + if let host = url.host(percentEncoded: false), !host.isEmpty { + parts.append(host) + } + parts.append(contentsOf: url.pathComponents.filter { $0 != "/" }) + let raw = parts.joined(separator: "-") + if raw.isEmpty { return "show" } + return raw + .lowercased() + .replacingOccurrences(of: "_", with: "-") + .replacingOccurrences(of: " ", with: "-") +} + +@discardableResult +func handleCmdcmdURL(_ url: URL) -> Bool { + guard let command = cmdcmdURLCommand(url) else { return false } + switch command { + case "show", "open", "activate": + overlay.showFromExternalTrigger() + case "toggle": + overlay.toggle() + case "hide", "dismiss", "close": + overlay.hideFromExternalTrigger() + case "swipe-up": + overlay.showFromExternalTrigger() + case "swipe-down": + overlay.hideFromExternalTrigger() + case "swipe-left": + overlay.performExternalAction(.moveLeft) + case "swipe-right": + overlay.performExternalAction(.moveRight) + default: + if let action = Action(rawValue: command) { + overlay.performExternalAction(action) + } else { + Log.write("unknown cmdcmd URL command '\(command)' from \(url.absoluteString)") + } + } + return true +} + +appDelegate.urlHandler = handleCmdcmdURL appDelegate.settingsFactory = { let controller = SettingsWindowController(config: appConfig) @@ -145,14 +229,15 @@ appDelegate.settingsFactory = { appConfig = newConfig overlay.updateConfig(newConfig) appDelegate.applyDisplayMode(newConfig.displayModeOrDefault) + updateTrackpadSwipeMonitor() } return controller } func startApp() { let fire = { + overlay.enterKeyboardMode() overlay.toggle() - dumpState(tracker: tracker) } if appConfig.triggerSpec.lowercased() == "cmd-cmd" { trigger = CmdChord(handler: fire) diff --git a/scripts/perf-audit.sh b/scripts/perf-audit.sh new file mode 100755 index 0000000..5ef2a49 --- /dev/null +++ b/scripts/perf-audit.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="cmdcmd" +PID="${1:-}" + +if [[ -z "$PID" ]]; then + PID="$(pgrep -x "$APP_NAME" | head -1 || true)" +fi + +if [[ -z "$PID" ]]; then + echo "cmdcmd is not running" >&2 + exit 1 +fi + +OUT_DIR="${TMPDIR:-/tmp}/cmdcmd-perf-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$OUT_DIR" + +echo "cmdcmd perf audit" +echo "pid: $PID" +echo "output: $OUT_DIR" +echo + +echo "== ps ==" | tee "$OUT_DIR/summary.txt" +ps -o pid,ppid,%cpu,%mem,rss,vsz,time,command -p "$PID" | tee -a "$OUT_DIR/summary.txt" + +echo | tee -a "$OUT_DIR/summary.txt" +echo "== vmmap summary ==" | tee -a "$OUT_DIR/summary.txt" +vmmap -summary "$PID" > "$OUT_DIR/vmmap-summary.txt" 2>&1 || true +grep -E "Physical footprint" "$OUT_DIR/vmmap-summary.txt" | tee -a "$OUT_DIR/summary.txt" || true +tail -60 "$OUT_DIR/vmmap-summary.txt" | tee -a "$OUT_DIR/summary.txt" + +echo | tee -a "$OUT_DIR/summary.txt" +echo "== 3s sample ==" | tee -a "$OUT_DIR/summary.txt" +sample "$PID" 3 -file "$OUT_DIR/sample.txt" >/dev/null 2>&1 || true +if [[ -s "$OUT_DIR/sample.txt" ]]; then + grep -E "^Call graph|^Total number|^Sort by|^Process:" "$OUT_DIR/sample.txt" | tee -a "$OUT_DIR/summary.txt" || true +else + echo "sample unavailable" | tee -a "$OUT_DIR/summary.txt" +fi + +echo | tee -a "$OUT_DIR/summary.txt" +echo "== recent cmdcmd log ==" | tee -a "$OUT_DIR/summary.txt" +tail -80 /tmp/cmdcmd.log > "$OUT_DIR/cmdcmd.log" 2>/dev/null || true +tail -20 "$OUT_DIR/cmdcmd.log" | tee -a "$OUT_DIR/summary.txt" || true + +echo +echo "Wrote $OUT_DIR"