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.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user