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) } }