Files
konverter/Sources/Konverter/Core/WheelOption.swift
T
TZGyn 2d031e58a4 Add Konverter: a local-only menu bar file converter
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.
2026-10-20 00:36:40 +08:00

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