A Drag of different kinds showed an empty wheel, because the catalog only answers for a single category. Filee's answer is a mixed profile, so add Zip (one archive, flat names) and Merge (non-PDFs are converted to PDF first); a multi-file drag of one kind gets Zip too.
162 lines
5.7 KiB
Swift
162 lines
5.7 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)
|
|
// A mixed drag has no single output format, but it can still become one
|
|
// file, so the convert wheel offers the combine tools instead of
|
|
// vanishing (this is Filee's mixed profile).
|
|
guard let category = categories.first, !formats.isEmpty else {
|
|
return combineItems(urls: urls)
|
|
}
|
|
|
|
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) })
|
|
if categories.count == 1, let category = categories.first,
|
|
category != .folder, category != .unknown {
|
|
var tools = ToolID.tools(for: category)
|
|
// Any multi-file selection can be packed into one archive.
|
|
if urls.count >= 2 { tools.append(.createArchive) }
|
|
return tools.compactMap { item(for: $0, urls: urls) }
|
|
}
|
|
return combineItems(urls: urls)
|
|
}
|
|
|
|
/// The slices a selection of mixed kinds can still act on.
|
|
private static func combineItems(urls: [URL]) -> [WheelItem] {
|
|
let subtitles: [ToolID: String] = [
|
|
.mergePDF: "One PDF",
|
|
.createArchive: "One ZIP"
|
|
]
|
|
return ToolID.combineTools(for: urls).compactMap { tool in
|
|
item(for: tool, urls: urls, subtitle: subtitles[tool])
|
|
}
|
|
}
|
|
|
|
private static func item(for tool: ToolID, urls: [URL], subtitle: String? = nil) -> WheelItem? {
|
|
guard urls.count >= tool.minimumFileCount else { return nil }
|
|
var availability = 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 ? subtitle : 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)
|
|
}
|
|
}
|