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.
142 lines
4.8 KiB
Swift
142 lines
4.8 KiB
Swift
import AppKit
|
|
import Foundation
|
|
|
|
/// One wedge of the wheel.
|
|
struct WheelItem: Identifiable {
|
|
enum Payload {
|
|
case format(Format)
|
|
case tool(ToolID)
|
|
case action(ActionID)
|
|
}
|
|
|
|
let id: String
|
|
let payload: Payload
|
|
let title: String
|
|
let subtitle: String?
|
|
let symbol: String
|
|
let availability: Availability
|
|
|
|
var isEnabled: Bool { availability.isAvailable }
|
|
}
|
|
|
|
enum WheelKind {
|
|
case convert
|
|
case tools
|
|
case actions
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .convert: return "Convert"
|
|
case .tools: return "Tools"
|
|
case .actions: return "Actions"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds the wheel contents for whatever is being dragged.
|
|
enum WheelBuilder {
|
|
static func kind(for modifiers: NSEvent.ModifierFlags, settings: Settings = .shared) -> WheelKind? {
|
|
if modifiers.contains(settings.actionsKey.flag) { return .actions }
|
|
if modifiers.contains(settings.toolsKey.flag) {
|
|
if settings.requireShiftForTools, !modifiers.contains(.shift) { return nil }
|
|
return .tools
|
|
}
|
|
if modifiers.contains(settings.convertKey.flag) { return .convert }
|
|
return nil
|
|
}
|
|
|
|
static func items(kind: WheelKind, urls: [URL], settings: Settings = .shared) -> [WheelItem] {
|
|
switch kind {
|
|
case .convert:
|
|
return conversionItems(urls: urls)
|
|
case .tools:
|
|
return toolItems(urls: urls)
|
|
case .actions:
|
|
return actionItems(urls: urls, settings: settings)
|
|
}
|
|
}
|
|
|
|
private static func conversionItems(urls: [URL]) -> [WheelItem] {
|
|
let categories = urls.map { FileKind.category(of: $0) }
|
|
let formats = ConversionCatalog.outputs(for: categories)
|
|
guard let category = categories.first, !formats.isEmpty else { return [] }
|
|
|
|
return formats.compactMap { format in
|
|
guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil }
|
|
let availability = Converter.availability(target: format, source: category)
|
|
return WheelItem(
|
|
id: "format-\(format.ext)",
|
|
payload: .format(format),
|
|
title: format.displayName,
|
|
subtitle: availability.isAvailable ? nil : availability.reason,
|
|
symbol: format.symbol,
|
|
availability: availability
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func toolItems(urls: [URL]) -> [WheelItem] {
|
|
let categories = Set(urls.map { FileKind.category(of: $0) })
|
|
guard categories.count == 1, let category = categories.first else { return [] }
|
|
|
|
return ToolID.tools(for: category).compactMap { tool in
|
|
guard urls.count >= tool.minimumFileCount else { return nil }
|
|
var availability = Availability.ok
|
|
if tool.producesArtifacts, urls.count > 1 {
|
|
availability = .ok
|
|
}
|
|
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
|
|
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
|
|
}
|
|
return WheelItem(
|
|
id: "tool-\(tool.rawValue)",
|
|
payload: .tool(tool),
|
|
title: tool.title,
|
|
subtitle: availability.isAvailable ? nil : availability.reason,
|
|
symbol: tool.symbol,
|
|
availability: availability
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func requiresFFmpeg(_ tool: ToolID) -> Bool {
|
|
switch tool {
|
|
case .normalizeAudio: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
private static func actionItems(urls: [URL], settings: Settings) -> [WheelItem] {
|
|
let allFolders = urls.allSatisfy { FileKind.category(of: $0) == .folder }
|
|
return ActionID.allCases.compactMap { action in
|
|
if allFolders, action != .airdrop, action != .copy, action != .move,
|
|
action != .copyPath, action != .terminal, action != .revealInFinder {
|
|
return nil
|
|
}
|
|
_ = settings
|
|
return WheelItem(
|
|
id: "action-\(action.rawValue)",
|
|
payload: .action(action),
|
|
title: action.title,
|
|
subtitle: nil,
|
|
symbol: action.symbol,
|
|
availability: .ok
|
|
)
|
|
}
|
|
}
|
|
|
|
/// A one-line description of the selection for the centre of the wheel.
|
|
static func summary(for urls: [URL]) -> String {
|
|
guard let first = urls.first else { return "" }
|
|
if urls.count > 1 { return "\(urls.count) files" }
|
|
return first.lastPathComponent
|
|
}
|
|
|
|
static func icon(for urls: [URL]) -> NSImage {
|
|
guard let first = urls.first else {
|
|
return NSImage(systemSymbolName: "doc", accessibilityDescription: nil) ?? NSImage()
|
|
}
|
|
return NSWorkspace.shared.icon(forFile: first.path)
|
|
}
|
|
}
|