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.
255 lines
8.6 KiB
Swift
255 lines
8.6 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// The small job window. Tangerine opens one per job; a single stack that
|
|
/// shows every job is less noisy and can be dismissed in one click.
|
|
final class JobPanelController: NSObject {
|
|
static let shared = JobPanelController()
|
|
|
|
private var panel: NSPanel?
|
|
|
|
private override init() { super.init() }
|
|
|
|
func refresh(store: JobStore) {
|
|
guard Thread.isMainThread else {
|
|
DispatchQueue.main.async { self.refresh(store: store) }
|
|
return
|
|
}
|
|
guard Settings.shared.showProgressWindow else { return }
|
|
guard !store.jobs.isEmpty else {
|
|
panel?.orderOut(nil)
|
|
panel = nil
|
|
return
|
|
}
|
|
|
|
let panel = ensurePanel()
|
|
if !panel.isVisible {
|
|
position(panel, near: store.jobs.last?.origin)
|
|
panel.alphaValue = 0
|
|
panel.orderFrontRegardless()
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.18
|
|
panel.animator().alphaValue = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func dismissIfAllFinished(store: JobStore) {
|
|
guard store.activeCount == 0 else { return }
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self, let panel = self.panel, JobStore.shared.activeCount == 0 else { return }
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.2
|
|
panel.animator().alphaValue = 0
|
|
} completionHandler: {
|
|
panel.orderOut(nil)
|
|
JobStore.shared.dismissFinished()
|
|
self.panel = nil
|
|
}
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6, execute: work)
|
|
}
|
|
|
|
func show() {
|
|
refresh(store: .shared)
|
|
}
|
|
|
|
private func ensurePanel() -> NSPanel {
|
|
if let panel { return panel }
|
|
let panel = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 320, height: 120),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
panel.isFloatingPanel = true
|
|
panel.level = .floating
|
|
panel.isOpaque = false
|
|
panel.backgroundColor = .clear
|
|
panel.hasShadow = true
|
|
panel.hidesOnDeactivate = false
|
|
panel.becomesKeyOnlyIfNeeded = true
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
panel.contentView = NSHostingView(rootView: JobsView(store: .shared))
|
|
self.panel = panel
|
|
return panel
|
|
}
|
|
|
|
private func position(_ panel: NSPanel, near origin: CGPoint?) {
|
|
let size = panel.contentView?.fittingSize ?? CGSize(width: 320, height: 140)
|
|
panel.setContentSize(size)
|
|
|
|
let screen = NSScreen.screens.first { $0.frame.contains(origin ?? .zero) } ?? NSScreen.main
|
|
guard let frame = screen?.visibleFrame else { return }
|
|
|
|
var point = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 80)
|
|
if let origin {
|
|
point = CGPoint(x: origin.x + 26, y: origin.y - size.height / 2)
|
|
}
|
|
point.x = min(max(frame.minX + 12, point.x), frame.maxX - size.width - 12)
|
|
point.y = min(max(frame.minY + 12, point.y), frame.maxY - size.height - 12)
|
|
panel.setFrameOrigin(point)
|
|
}
|
|
|
|
/// Re-centres and resizes once the content changes.
|
|
func layout() {
|
|
guard let panel, panel.isVisible else { return }
|
|
let size = panel.contentView?.fittingSize ?? panel.frame.size
|
|
let anchor = CGPoint(x: panel.frame.minX, y: panel.frame.midY)
|
|
panel.setContentSize(size)
|
|
panel.setFrameOrigin(CGPoint(x: anchor.x, y: anchor.y - size.height / 2))
|
|
}
|
|
}
|
|
|
|
struct JobsView: View {
|
|
@ObservedObject var store: JobStore
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
Text(store.activeCount > 0 ? "Working…" : "Finished")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
Spacer()
|
|
Button {
|
|
store.cancelAll()
|
|
} label: {
|
|
Image(systemName: "stop.circle")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Cancel everything")
|
|
.opacity(store.activeCount > 0 ? 1 : 0)
|
|
Button {
|
|
store.dismissFinished()
|
|
JobPanelController.shared.performClose()
|
|
} label: {
|
|
Image(systemName: "xmark.circle")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Close")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 6)
|
|
|
|
Divider().opacity(0.4)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(store.jobs) { job in
|
|
JobRow(job: job, store: store)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
.frame(maxHeight: 260)
|
|
}
|
|
.frame(width: 320)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(Color.primary.opacity(0.09))
|
|
)
|
|
.onReceive(store.$jobs) { _ in
|
|
DispatchQueue.main.async {
|
|
JobPanelController.shared.layout()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct JobRow: View {
|
|
let job: Job
|
|
@ObservedObject var store: JobStore
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(tint)
|
|
Text(job.title)
|
|
.font(.system(size: 11.5, weight: .medium))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer(minLength: 4)
|
|
if job.status == .running {
|
|
Button {
|
|
store.cancel(job.id)
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 9, weight: .bold))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Cancel this job")
|
|
} else {
|
|
Button {
|
|
store.remove(job.id)
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
.font(.system(size: 9))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Remove")
|
|
}
|
|
}
|
|
|
|
if job.status == .running {
|
|
ProgressView(value: max(0.02, job.progress))
|
|
.progressViewStyle(.linear)
|
|
}
|
|
|
|
HStack(spacing: 6) {
|
|
Text(detail)
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
Spacer(minLength: 4)
|
|
if !job.outputs.isEmpty, job.status == .finished {
|
|
Button("Reveal") {
|
|
NSWorkspace.shared.activateFileViewerSelecting(job.outputs)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var detail: String {
|
|
if let error = job.errorMessage { return error }
|
|
if job.sourceCount > 1, job.status == .finished, !job.outputs.isEmpty {
|
|
return "\(job.outputs.count) files written"
|
|
}
|
|
return job.detail.isEmpty ? job.title : job.detail
|
|
}
|
|
|
|
private var symbol: String {
|
|
switch job.status {
|
|
case .running: return "hourglass"
|
|
case .finished: return "checkmark.circle.fill"
|
|
case .failed: return "exclamationmark.triangle.fill"
|
|
case .cancelled: return "slash.circle"
|
|
}
|
|
}
|
|
|
|
private var tint: Color {
|
|
switch job.status {
|
|
case .running: return .secondary
|
|
case .finished: return .green
|
|
case .failed: return .orange
|
|
case .cancelled: return .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
extension JobPanelController {
|
|
func performClose() {
|
|
panel?.orderOut(nil)
|
|
panel = nil
|
|
JobStore.shared.dismissFinished()
|
|
}
|
|
}
|