Files
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

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