Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
355 lines
12 KiB
Swift
355 lines
12 KiB
Swift
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 }
|
|
}
|