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.
191 lines
6.2 KiB
Swift
191 lines
6.2 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// The form a tool needs before it can run (quality, trim points, password …).
|
|
/// Values are remembered per tool, so the second time is still one drop.
|
|
final class OptionsWindowController: NSObject {
|
|
static let shared = OptionsWindowController()
|
|
|
|
private var window: NSWindow?
|
|
private var tool: ToolID?
|
|
private var urls: [URL] = []
|
|
private var origin: CGPoint?
|
|
private var params = ToolParams()
|
|
|
|
private override init() { super.init() }
|
|
|
|
func present(tool: ToolID, urls: [URL], origin: CGPoint?) {
|
|
self.tool = tool
|
|
self.urls = urls
|
|
self.origin = origin
|
|
params = Settings.shared.params(for: tool)
|
|
params.fillMissing(from: tool)
|
|
|
|
let view = OptionsView(
|
|
tool: tool,
|
|
params: params,
|
|
fileCount: urls.count,
|
|
onApply: { [weak self] values in self?.apply(values) },
|
|
onCancel: { [weak self] in self?.close() }
|
|
)
|
|
|
|
close()
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 340, height: 120),
|
|
styleMask: [.titled, .closable, .fullSizeContentView],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = tool.title
|
|
window.titlebarAppearsTransparent = true
|
|
window.isMovableByWindowBackground = true
|
|
window.level = .floating
|
|
window.contentView = NSHostingView(rootView: view)
|
|
window.center()
|
|
if let origin, let screen = NSScreen.screens.first(where: { $0.frame.contains(origin) }) ?? NSScreen.main {
|
|
let frame = screen.visibleFrame
|
|
window.setFrameOrigin(CGPoint(
|
|
x: min(max(frame.minX + 20, origin.x - 170), frame.maxX - 360),
|
|
y: min(max(frame.minY + 20, origin.y - 180), frame.maxY - 220)
|
|
))
|
|
}
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
self.window = window
|
|
}
|
|
|
|
private func apply(_ values: ToolParams) {
|
|
guard let tool else { return }
|
|
Settings.shared.store(params: values, for: tool)
|
|
let captured = urls
|
|
let point = origin
|
|
close()
|
|
JobStore.shared.submit(urls: captured, target: .tool(tool), params: values, origin: point)
|
|
}
|
|
|
|
private func close() {
|
|
window?.orderOut(nil)
|
|
window = nil
|
|
}
|
|
}
|
|
|
|
struct OptionsView: View {
|
|
let tool: ToolID
|
|
let fileCount: Int
|
|
let onApply: (ToolParams) -> Void
|
|
let onCancel: () -> Void
|
|
|
|
@State private var values: [String: String] = [:]
|
|
|
|
init(
|
|
tool: ToolID,
|
|
params: ToolParams,
|
|
fileCount: Int,
|
|
onApply: @escaping (ToolParams) -> Void,
|
|
onCancel: @escaping () -> Void
|
|
) {
|
|
self.tool = tool
|
|
self.fileCount = fileCount
|
|
self.onApply = onApply
|
|
self.onCancel = onCancel
|
|
var initial: [String: String] = [:]
|
|
for field in tool.fields {
|
|
initial[field.id] = params[field.id] ?? field.defaultValue
|
|
}
|
|
_values = State(initialValue: initial)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: tool.symbol)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(tool.title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
Text(fileCount == 1 ? "1 file" : "\(fileCount) files")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
ForEach(tool.fields) { field in
|
|
fieldView(field)
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
Button("Cancel", action: onCancel)
|
|
.keyboardShortcut(.cancelAction)
|
|
Button("Apply") {
|
|
var params = ToolParams()
|
|
for (key, value) in values { params[key] = value }
|
|
onApply(params)
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(width: 340)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func fieldView(_ field: ToolField) -> some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack {
|
|
Text(field.label)
|
|
.font(.system(size: 11, weight: .medium))
|
|
Spacer()
|
|
if let unit = field.unit {
|
|
Text(unit)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
control(for: field)
|
|
if let help = field.help {
|
|
Text(help)
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func control(for field: ToolField) -> some View {
|
|
let binding = Binding<String>(
|
|
get: { values[field.id] ?? field.defaultValue },
|
|
set: { values[field.id] = $0 }
|
|
)
|
|
|
|
switch field.kind {
|
|
case .text:
|
|
TextField("", text: binding)
|
|
.textFieldStyle(.roundedBorder)
|
|
case .secret:
|
|
SecureField("", text: binding)
|
|
.textFieldStyle(.roundedBorder)
|
|
case .toggle:
|
|
Toggle("", isOn: Binding(
|
|
get: { (values[field.id] ?? field.defaultValue) == "true" },
|
|
set: { values[field.id] = $0 ? "true" : "false" }
|
|
))
|
|
.labelsHidden()
|
|
.toggleStyle(.switch)
|
|
case .choice(let options):
|
|
Picker("", selection: binding) {
|
|
ForEach(options, id: \.self) { option in
|
|
Text(option).tag(option)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
case .integer, .decimal, .timecode:
|
|
TextField("", text: binding)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.system(size: 12, design: .monospaced))
|
|
}
|
|
}
|
|
}
|