import AppKit import Combine import Foundation import SwiftUI /// State behind the wheel: what is offered, what is highlighted, and what the /// centre of the wheel says. final class WheelModel: ObservableObject { @Published var kind: WheelKind = .convert @Published var items: [WheelItem] = [] @Published var selection: Int? @Published var summary: String = "" @Published var subtitle: String = "" @Published var icon: NSImage = NSImage() @Published var radius: CGFloat = 168 var selectedItem: WheelItem? { guard let selection, items.indices.contains(selection) else { return nil } return items[selection] } } /// Accepts the drop. Being a real drag destination is what stops Finder from /// moving the file, and it is how the wheel learns about the drop without /// needing an Accessibility event tap. final class DropCatcher: NSView { var onMoved: ((CGPoint) -> Void)? var onExit: (() -> Void)? var onDrop: (() -> Bool)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) registerForDraggedTypes([.fileURL, .filePromise, NSPasteboard.PasteboardType("NSFilenamesPboardType")]) } required init?(coder: NSCoder) { nil } private func screenPoint(_ sender: NSDraggingInfo) -> CGPoint { guard let window else { return NSEvent.mouseLocation } let inWindow = sender.draggingLocation return window.convertPoint(toScreen: inWindow) } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { onMoved?(screenPoint(sender)) return .generic } override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { onMoved?(screenPoint(sender)) return .generic } override func draggingExited(_ sender: NSDraggingInfo?) { onExit?() } override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { onMoved?(screenPoint(sender)) return onDrop?() ?? false } } /// Owns the overlay window and turns drags into jobs. final class WheelController { static let shared = WheelController() private let model = WheelModel() private var window: NSWindow? private var catcher: DropCatcher? private var urls: [URL] = [] private var isShowing = false private var didHandleDrop = false private var lastLocation: CGPoint = .zero private var pendingDismiss: DispatchWorkItem? private init() {} // MARK: - Drag lifecycle func begin(urls: [URL]) { self.urls = urls didHandleDrop = false } func tick(location: CGPoint, modifiers: NSEvent.ModifierFlags) { guard !urls.isEmpty else { return } guard let kind = WheelBuilder.kind(for: modifiers) else { // No trigger key held: stay out of the way until one is pressed. if !isShowing { model.selection = nil } return } let items = WheelBuilder.items(kind: kind, urls: urls) guard !items.isEmpty else { dismiss() return } if !isShowing || model.kind != kind || model.items.map(\.id) != items.map(\.id) { present(kind: kind, items: items, at: location) } else { move(to: location) } } /// Drop committed by the drag destination. @discardableResult func commitDrop() -> Bool { guard let item = model.selectedItem else { dismiss() return false } guard item.isEnabled else { let reason = item.availability.reason ?? "Unavailable" DispatchQueue.main.async { Notifier.error(item.title, reason) } dismiss() return true } didHandleDrop = true let captured = urls let origin = lastLocation dismiss(animated: false) WheelController.dispatch(item: item, urls: captured, origin: origin) return true } func end() { guard isShowing else { return } // Give the drag destination a beat to deliver the drop first. let work = DispatchWorkItem { [weak self] in guard let self else { return } if self.didHandleDrop { return } if let item = self.model.selectedItem, item.isEnabled { let captured = self.urls let origin = self.lastLocation self.dismiss(animated: false) WheelController.dispatch(item: item, urls: captured, origin: origin) } else { self.dismiss() } } pendingDismiss = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work) } func cancel() { pendingDismiss?.cancel() dismiss() } func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { guard isShowing else { return false } switch keyCode { case 53: // Escape cancel() return true case 123, 126: // Left / Up step(-1) return true case 124, 125: // Right / Down step(1) return true case 36, 76: // Return / Enter commitDrop() return true default: return false } } private func step(_ delta: Int) { guard !model.items.isEmpty else { return } let count = model.items.count let current = model.selection ?? 0 model.selection = ((current + delta) % count + count) % count } // MARK: - Window private func present(kind: WheelKind, items: [WheelItem], at location: CGPoint) { pendingDismiss?.cancel() let radius = Self.radius(forItemCount: items.count) let itemExtent: CGFloat = 86 let size = (radius + itemExtent) * 2 model.kind = kind model.items = items model.radius = radius model.summary = WheelBuilder.summary(for: urls) model.subtitle = kind.title model.icon = WheelBuilder.icon(for: urls) let frame = CGRect( x: location.x - size / 2, y: location.y - size / 2, width: size, height: size ) let window: NSWindow if let existing = self.window { window = existing } else { let created = OverlayWindow( contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false ) created.isOpaque = false created.backgroundColor = .clear created.hasShadow = false created.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.screenSaverWindow)) - 1) created.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] created.ignoresMouseEvents = false created.isMovable = false created.animationBehavior = .none let catcher = DropCatcher(frame: CGRect(origin: .zero, size: CGSize(width: size, height: size))) catcher.autoresizingMask = [.width, .height] catcher.wantsLayer = true let hosting = NSHostingView(rootView: WheelView(model: model)) hosting.frame = catcher.bounds hosting.autoresizingMask = [.width, .height] catcher.addSubview(hosting) catcher.onMoved = { [weak self] point in self?.move(to: point) } catcher.onExit = { [weak self] in self?.model.selection = nil } catcher.onDrop = { [weak self] in self?.commitDrop() ?? false } created.contentView = catcher self.catcher = catcher self.window = created window = created } if !isShowing { window.alphaValue = 0 window.orderFrontRegardless() NSAnimationContext.runAnimationGroup { context in context.duration = 0.12 window.animator().alphaValue = 1 } } else { window.setFrame(frame, display: true) } isShowing = true move(to: location) } private func move(to location: CGPoint) { guard let window else { return } lastLocation = location var frame = window.frame frame.origin = CGPoint(x: location.x - frame.width / 2, y: location.y - frame.height / 2) window.setFrame(frame, display: true) model.selection = Self.selectionIndex(for: location, in: frame, items: model.items, radius: model.radius) } private func dismiss(animated: Bool = true) { pendingDismiss?.cancel() guard let window, isShowing else { isShowing = false return } isShowing = false model.selection = nil if animated { NSAnimationContext.runAnimationGroup { context in context.duration = 0.1 window.animator().alphaValue = 0 } completionHandler: { window.orderOut(nil) } } else { window.orderOut(nil) } } func reset() { urls = [] dismiss(animated: false) } // MARK: - Geometry /// Items are spread on a ring; the radius grows with the item count so the /// pills never touch. static func radius(forItemCount count: Int) -> CGFloat { let base = Settings.shared.wheelRadius guard count > 2 else { return base } let arc = CGFloat(count) * 84 / (2 * .pi) return max(base, arc + 40) } /// Nearest item to the pointer, or nil when the pointer is off the ring or /// in the dead zone at the centre. static func selectionIndex(for location: CGPoint, in frame: CGRect, items: [WheelItem], radius: CGFloat) -> Int? { guard !items.isEmpty else { return nil } let center = CGPoint(x: frame.midX, y: frame.midY) let dx = location.x - center.x let dy = location.y - center.y let distance = (dx * dx + dy * dy).squareRoot() guard distance > radius * 0.34 else { return nil } if items.count == 1 { return 0 } guard distance < radius * 1.75 else { return nil } var angle = atan2(dy, dx) * 180 / .pi if angle < 0 { angle += 360 } let step = 360 / Double(items.count) let firstItemAngle = 90.0 var offset = (firstItemAngle - angle).truncatingRemainder(dividingBy: 360) if offset < 0 { offset += 360 } let index = Int((offset / step).rounded()) % items.count return index } // MARK: - Dispatch private static func dispatch(item: WheelItem, urls: [URL], origin: CGPoint?) { switch item.payload { case .format(let format): JobStore.shared.submit(urls: urls, target: .format(format), origin: origin) case .tool(let tool): if tool.fields.isEmpty { JobStore.shared.submit(urls: urls, target: .tool(tool), origin: origin) } else { OptionsWindowController.shared.present(tool: tool, urls: urls, origin: origin) } case .action(let action): JobStore.shared.submit(urls: urls, target: .action(action), origin: origin) } } } /// A window that never steals focus and never becomes the key window, so the /// user's drag and the frontmost app keep working. final class OverlayWindow: NSWindow { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } }