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,232 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
import QuickLookThumbnailing
|
||||
import SwiftUI
|
||||
|
||||
/// "Float": a borderless preview that hovers over other windows, the same
|
||||
/// gesture Tangerine offers from the Control wheel.
|
||||
final class FloatPanel: NSObject {
|
||||
static let shared = FloatPanel()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var urls: [URL] = []
|
||||
private var index = 0
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
@MainActor
|
||||
func show(urls: [URL]) {
|
||||
self.urls = urls
|
||||
index = 0
|
||||
let window = ensureWindow()
|
||||
if let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) ?? NSScreen.main {
|
||||
let frame = screen.visibleFrame
|
||||
let size = CGSize(width: 420, height: 320)
|
||||
window.setContentSize(size)
|
||||
window.setFrameOrigin(CGPoint(
|
||||
x: min(max(frame.minX + 16, NSEvent.mouseLocation.x - size.width / 2), frame.maxX - size.width - 16),
|
||||
y: min(max(frame.minY + 16, NSEvent.mouseLocation.y - size.height - 24), frame.maxY - size.height - 16)
|
||||
))
|
||||
}
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func ensureWindow() -> NSWindow {
|
||||
if let window { return window }
|
||||
let window = OverlayWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 320),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = true
|
||||
window.level = .floating
|
||||
window.isMovableByWindowBackground = true
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.contentView = NSHostingView(rootView: FloatView(controller: self))
|
||||
self.window = window
|
||||
return window
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func render() {
|
||||
guard let window, let content = window.contentView as? NSHostingView<FloatView> else { return }
|
||||
content.rootView = FloatView(controller: self)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func close() {
|
||||
guard let window else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
urls = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func step(_ delta: Int) {
|
||||
guard !urls.isEmpty else { return }
|
||||
index = ((index + delta) % urls.count + urls.count) % urls.count
|
||||
render()
|
||||
}
|
||||
|
||||
var currentURL: URL? {
|
||||
urls.indices.contains(index) ? urls[index] : nil
|
||||
}
|
||||
|
||||
var counter: String? {
|
||||
urls.count > 1 ? "\(index + 1) of \(urls.count)" : nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func openInDefaultApp() {
|
||||
guard let url = currentURL else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func reveal() {
|
||||
guard let url = currentURL else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
|
||||
struct FloatView: View {
|
||||
let controller: FloatPanel
|
||||
|
||||
@State private var image: NSImage?
|
||||
@State private var failure: String?
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
if let image {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(10)
|
||||
} else if let failure {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "eye.slash")
|
||||
.font(.system(size: 22))
|
||||
Text(failure)
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider().opacity(0.35)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "doc")
|
||||
.font(.system(size: 11))
|
||||
Text(controller.currentURL?.lastPathComponent ?? "")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if let counter = controller.counter {
|
||||
Text(counter)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
controller.step(-1)
|
||||
} label: { Image(systemName: "chevron.left") }
|
||||
.buttonStyle(.plain)
|
||||
Button {
|
||||
controller.step(1)
|
||||
} label: { Image(systemName: "chevron.right") }
|
||||
.buttonStyle(.plain)
|
||||
Button("Open") { controller.openInDefaultApp() }
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Button {
|
||||
controller.close()
|
||||
} label: { Image(systemName: "xmark") }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.1))
|
||||
)
|
||||
.task(id: controller.currentURL) {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let url = controller.currentURL else {
|
||||
image = nil
|
||||
failure = "Nothing to preview"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
failure = nil
|
||||
|
||||
// Images and PDFs get a real render; everything else falls back to a
|
||||
// Quick Look thumbnail.
|
||||
if FileKind.category(of: url) == .image, let loaded = NSImage(contentsOf: url) {
|
||||
image = loaded
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
if url.pathExtension.lowercased() == "pdf", let page = PDFKitPreview.image(for: url) {
|
||||
image = page
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: url,
|
||||
size: CGSize(width: 900, height: 700),
|
||||
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
||||
representationTypes: .all
|
||||
)
|
||||
let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
|
||||
if let cgImage = representation?.cgImage {
|
||||
image = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width / 2, height: cgImage.height / 2))
|
||||
} else {
|
||||
image = nil
|
||||
failure = "No preview available for .\(url.pathExtension)"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
enum PDFKitPreview {
|
||||
static func image(for url: URL) -> NSImage? {
|
||||
guard let document = PDFDocument(url: url), let page = document.page(at: 0) else { return nil }
|
||||
let bounds = page.bounds(for: .mediaBox)
|
||||
let scale: CGFloat = 1.6
|
||||
let size = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||
let thumbnail = page.thumbnail(of: size, for: .mediaBox)
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// The menu bar item: the app's only permanent UI, and the place to turn the
|
||||
/// wheel off or fall back to picking files by hand.
|
||||
final class MenuBarController: NSObject, NSMenuDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
|
||||
func install() {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = item.button {
|
||||
let image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "Konverter")
|
||||
image?.isTemplate = true
|
||||
button.image = image
|
||||
button.setAccessibilityLabel("Konverter")
|
||||
}
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
item.menu = menu
|
||||
statusItem = item
|
||||
rebuild(menu)
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
rebuild(menu)
|
||||
}
|
||||
|
||||
private func rebuild(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
|
||||
let enabled = Settings.shared.enabled
|
||||
let toggle = NSMenuItem(
|
||||
title: enabled ? "Konverter Active" : "Konverter Paused",
|
||||
action: #selector(toggleEnabled),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
toggle.target = self
|
||||
toggle.state = enabled ? .on : .off
|
||||
menu.addItem(toggle)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let convert = NSMenuItem(title: "Konvert Files…", action: #selector(convertFiles), keyEquivalent: "o")
|
||||
convert.target = self
|
||||
menu.addItem(convert)
|
||||
|
||||
let jobs = NSMenuItem(title: "Show Jobs", action: #selector(showJobs), keyEquivalent: "j")
|
||||
jobs.target = self
|
||||
menu.addItem(jobs)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let settings = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: ",")
|
||||
settings.target = self
|
||||
menu.addItem(settings)
|
||||
|
||||
let about = NSMenuItem(title: "About Konverter", action: #selector(showAbout), keyEquivalent: "")
|
||||
about.target = self
|
||||
menu.addItem(about)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let quit = NSMenuItem(title: "Quit Konverter", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
quit.target = NSApp
|
||||
menu.addItem(quit)
|
||||
}
|
||||
|
||||
@objc private func toggleEnabled() {
|
||||
Settings.shared.enabled.toggle()
|
||||
if !Settings.shared.enabled {
|
||||
WheelController.shared.cancel()
|
||||
}
|
||||
if let menu = statusItem?.menu {
|
||||
rebuild(menu)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func convertFiles() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.prompt = "Choose"
|
||||
panel.message = "Pick files to convert, then choose an output format"
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard panel.runModal() == .OK, !panel.urls.isEmpty else { return }
|
||||
// Let the panel close before the menu opens over it.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
QuickMenu.show(urls: panel.urls, at: NSEvent.mouseLocation)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showJobs() {
|
||||
JobStore.shared.panelVisible = true
|
||||
JobPanelController.shared.show()
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
PreferencesWindowController.shared.show()
|
||||
}
|
||||
|
||||
@objc private func showAbout() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.orderFrontStandardAboutPanel(options: [
|
||||
.applicationName: "Konverter",
|
||||
.applicationVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
|
||||
.credits: NSAttributedString(
|
||||
string: "Drag a file in Finder, then hold a key.\nShift converts · Command shows tools · Control shows file actions.\nEverything runs on this Mac.",
|
||||
attributes: [.font: NSFont.systemFont(ofSize: 11)]
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Small, non-modal confirmations ("Path copied", "3 files converted").
|
||||
/// Nothing here blocks the user's flow, which is the whole point of the app.
|
||||
enum Notifier {
|
||||
private static var toastWindow: NSWindow?
|
||||
private static var dismissWorkItem: DispatchWorkItem?
|
||||
|
||||
@MainActor
|
||||
static func flash(title: String, body: String? = nil, symbol: String = "checkmark.circle.fill") {
|
||||
guard Settings.shared.showProgressWindow else { return }
|
||||
show(title: title, body: body, symbol: symbol, tone: .success, duration: 2.0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func error(_ title: String, _ message: String) {
|
||||
show(title: title, body: message, symbol: "exclamationmark.triangle.fill", tone: .failure, duration: 6.0)
|
||||
}
|
||||
|
||||
private enum Tone { case success, failure }
|
||||
|
||||
@MainActor
|
||||
private static func show(title: String, body: String?, symbol: String, tone: Tone, duration: TimeInterval) {
|
||||
dismissWorkItem?.cancel()
|
||||
toastWindow?.close()
|
||||
|
||||
let accent: Color = tone == .success ? .green : .orange
|
||||
let view = HStack(spacing: 10) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
if let body, !body.isEmpty {
|
||||
Text(body)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minWidth: 220, maxWidth: 420, alignment: .leading)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.08))
|
||||
)
|
||||
|
||||
let controller = NSHostingController(rootView: view)
|
||||
let window = NSWindow(contentViewController: controller)
|
||||
window.styleMask = [.borderless]
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = true
|
||||
window.level = .statusBar
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
window.ignoresMouseEvents = true
|
||||
window.appearance = NSApp.effectiveAppearance
|
||||
|
||||
let size = controller.view.fittingSize
|
||||
let screen = NSScreen.main ?? NSScreen.screens.first
|
||||
if let frame = screen?.visibleFrame {
|
||||
let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.minY + 96)
|
||||
window.setFrame(CGRect(origin: origin, size: size), display: true)
|
||||
}
|
||||
window.orderFrontRegardless()
|
||||
toastWindow = window
|
||||
|
||||
let work = DispatchWorkItem { [weak window] in
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.25
|
||||
window?.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window?.close()
|
||||
}
|
||||
if toastWindow === window { toastWindow = nil }
|
||||
}
|
||||
dismissWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Settings window. Also the place where the active conversion engines are
|
||||
/// listed, so "why can't I export MKV?" has an answer in the app itself.
|
||||
final class PreferencesWindowController {
|
||||
static let shared = PreferencesWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
private init() {}
|
||||
|
||||
func show() {
|
||||
if let window {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 620),
|
||||
styleMask: [.titled, .closable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Konverter Settings"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.contentView = NSHostingView(rootView: PreferencesView())
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
|
||||
struct PreferencesView: View {
|
||||
@ObservedObject private var settings = Settings.shared
|
||||
@State private var loginItemError: String?
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
general.tabItem { Label("General", systemImage: "gearshape") }
|
||||
wheel.tabItem { Label("Wheel", systemImage: "circle.hexagongrid") }
|
||||
engines.tabItem { Label("Engines", systemImage: "cpu") }
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 520, height: 600)
|
||||
}
|
||||
|
||||
private var general: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Konverter is active", isOn: $settings.enabled)
|
||||
Toggle("Enable at login", isOn: Binding(
|
||||
get: { settings.openAtLogin },
|
||||
set: { value in
|
||||
if LoginItem.set(value) {
|
||||
settings.openAtLogin = value
|
||||
loginItemError = nil
|
||||
} else {
|
||||
settings.openAtLogin = LoginItem.isEnabled
|
||||
loginItemError = "Could not update the login item. Move Konverter to /Applications and try again."
|
||||
}
|
||||
}
|
||||
))
|
||||
if let loginItemError {
|
||||
Text(loginItemError)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} header: {
|
||||
Text("Startup")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Save converted files", selection: $settings.outputLocation) {
|
||||
ForEach(OutputLocation.allCases) { location in
|
||||
Text(location.title).tag(location)
|
||||
}
|
||||
}
|
||||
if settings.outputLocation == .chosenFolder {
|
||||
HStack {
|
||||
Text(settings.outputFolderPath.isEmpty ? "No folder chosen" : settings.outputFolderPath)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button("Choose…") { chooseFolder() }
|
||||
}
|
||||
}
|
||||
Picker("When a file already exists", selection: $settings.conflictPolicy) {
|
||||
ForEach(ConflictPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Output")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Show the job window", isOn: $settings.showProgressWindow)
|
||||
Toggle("Reveal files in Finder when finished", isOn: $settings.revealWhenFinished)
|
||||
Toggle("Play a sound when finished", isOn: $settings.playSound)
|
||||
} header: {
|
||||
Text("Feedback")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var wheel: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Convert", selection: $settings.convertKey) {
|
||||
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
|
||||
}
|
||||
Picker("Tools", selection: $settings.toolsKey) {
|
||||
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
|
||||
}
|
||||
Picker("Actions", selection: $settings.actionsKey) {
|
||||
ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) }
|
||||
}
|
||||
Toggle("Tools also require Shift", isOn: $settings.requireShiftForTools)
|
||||
} header: {
|
||||
Text("Trigger keys")
|
||||
} footer: {
|
||||
Text("Start dragging a file in Finder, then hold a key. The wheel stays open until you drop or press Escape. Keys must differ, otherwise the first match wins (Actions, then Tools, then Convert).")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Style", selection: $settings.wheelStyle) {
|
||||
ForEach(WheelStyle.allCases) { style in Text(style.title).tag(style) }
|
||||
}
|
||||
HStack {
|
||||
Text("Radius")
|
||||
Slider(value: $settings.wheelRadius, in: 120...260, step: 4)
|
||||
Text("\(Int(settings.wheelRadius))")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 28)
|
||||
}
|
||||
Toggle("Show labels", isOn: $settings.showLabels)
|
||||
Toggle("Dim the background", isOn: $settings.dimBackground)
|
||||
} header: {
|
||||
Text("Appearance")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var engines: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Use external tools", isOn: $settings.useExternalTools)
|
||||
Text("Konverter always works with Apple's frameworks. ffmpeg and LibreOffice only widen the format list.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("Conversion engines")
|
||||
}
|
||||
|
||||
Section {
|
||||
engineRow(name: "Core (ImageIO, PDFKit, AVFoundation, textutil)", available: true, detail: "Always available")
|
||||
engineRow(
|
||||
name: "ffmpeg",
|
||||
available: Toolchain.hasFFmpeg,
|
||||
detail: Toolchain.ffmpeg ?? "Install with: brew install ffmpeg"
|
||||
)
|
||||
engineRow(
|
||||
name: "LibreOffice",
|
||||
available: Toolchain.hasLibreOffice,
|
||||
detail: Toolchain.soffice ?? "Install with: brew install --cask libreoffice"
|
||||
)
|
||||
} header: {
|
||||
Text("Detected")
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Active: \(Toolchain.summary)")
|
||||
.font(.system(size: 11))
|
||||
if let advice = Toolchain.missingToolAdvice {
|
||||
Text(advice)
|
||||
.font(.system(size: 10.5, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Summary")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func engineRow(name: String, available: Bool, detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: available ? "checkmark.circle.fill" : "circle.dashed")
|
||||
.foregroundStyle(available ? Color.green : Color.secondary)
|
||||
.font(.system(size: 12))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(name).font(.system(size: 12, weight: .medium))
|
||||
Text(detail)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Use Folder"
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
settings.outputFolderPath = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Native menu fallback for the paths where there is no drag to follow: the
|
||||
/// menu bar's "Konvert Files…" item and the Finder Quick Action.
|
||||
enum QuickMenu {
|
||||
private final class Choice: NSObject {
|
||||
let handler: () -> Void
|
||||
|
||||
init(_ handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
@objc func fire() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu items hold their target weakly, so the choices are retained
|
||||
/// here for the duration of the (synchronous) popup.
|
||||
private static var retained: [Choice] = []
|
||||
|
||||
static func show(urls: [URL], at point: CGPoint) {
|
||||
guard !urls.isEmpty else { return }
|
||||
retained.removeAll()
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let header = NSMenuItem(
|
||||
title: WheelBuilder.summary(for: urls),
|
||||
action: nil,
|
||||
keyEquivalent: ""
|
||||
)
|
||||
header.isEnabled = false
|
||||
menu.addItem(header)
|
||||
menu.addItem(.separator())
|
||||
|
||||
let category = FileKind.category(of: urls[0])
|
||||
|
||||
if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty {
|
||||
let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
} else {
|
||||
let convert = NSMenu()
|
||||
for item in WheelBuilder.items(kind: .convert, urls: urls) {
|
||||
let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
JobStore.shared.submit(urls: urls, target: .format(format(from: item)))
|
||||
}
|
||||
entry.isEnabled = item.isEnabled
|
||||
if let reason = item.availability.reason { entry.toolTip = reason }
|
||||
convert.addItem(entry)
|
||||
}
|
||||
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
|
||||
submenu.submenu = convert
|
||||
menu.addItem(submenu)
|
||||
}
|
||||
|
||||
let tools = WheelBuilder.items(kind: .tools, urls: urls)
|
||||
if category != .unknown, !tools.isEmpty {
|
||||
let submenu = NSMenu()
|
||||
for item in tools {
|
||||
guard case .tool(let tool) = item.payload else { continue }
|
||||
let entry = NSMenuItem(title: tool.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
if tool.fields.isEmpty {
|
||||
JobStore.shared.submit(urls: urls, target: .tool(tool))
|
||||
} else {
|
||||
OptionsWindowController.shared.present(tool: tool, urls: urls, origin: point)
|
||||
}
|
||||
}
|
||||
entry.isEnabled = item.isEnabled
|
||||
if let reason = item.availability.reason { entry.toolTip = reason }
|
||||
submenu.addItem(entry)
|
||||
}
|
||||
let wrapper = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "")
|
||||
wrapper.submenu = submenu
|
||||
menu.addItem(wrapper)
|
||||
}
|
||||
|
||||
let actions = NSMenu()
|
||||
for item in WheelBuilder.items(kind: .actions, urls: urls) {
|
||||
guard case .action(let action) = item.payload else { continue }
|
||||
let entry = NSMenuItem(title: action.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
JobStore.shared.submit(urls: urls, target: .action(action))
|
||||
}
|
||||
actions.addItem(entry)
|
||||
}
|
||||
let actionsWrapper = NSMenuItem(title: "Actions", action: nil, keyEquivalent: "")
|
||||
actionsWrapper.submenu = actions
|
||||
menu.addItem(actionsWrapper)
|
||||
|
||||
menu.popUp(positioning: nil, at: point, in: nil)
|
||||
retained.removeAll()
|
||||
}
|
||||
|
||||
private static func makeChoice(_ handler: @escaping () -> Void) -> Choice {
|
||||
let choice = Choice(handler)
|
||||
retained.append(choice)
|
||||
return choice
|
||||
}
|
||||
|
||||
private static func format(from item: WheelItem) -> Format {
|
||||
if case .format(let format) = item.payload { return format }
|
||||
return .png
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Longer text results (Info, Read QR, Statistics, archive listings).
|
||||
final class ResultWindowController {
|
||||
static let shared = ResultWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
private init() {}
|
||||
|
||||
func show(title: String, body: String) {
|
||||
let view = ResultView(title: title, text: body, onClose: { [weak self] in self?.close() })
|
||||
close()
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 460, height: 320),
|
||||
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = title
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.level = .floating
|
||||
window.contentView = NSHostingView(rootView: view)
|
||||
window.center()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
}
|
||||
|
||||
func close() {
|
||||
window?.orderOut(nil)
|
||||
window = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ResultView: View {
|
||||
let title: String
|
||||
let text: String
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
Text(text)
|
||||
.font(.system(size: 11.5, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
}
|
||||
Divider().opacity(0.4)
|
||||
HStack {
|
||||
Button("Copy") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
}
|
||||
Spacer()
|
||||
Button("Done", action: onClose)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.frame(minWidth: 420, minHeight: 260)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user