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.
233 lines
7.7 KiB
Swift
233 lines
7.7 KiB
Swift
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
|
|
}
|
|
}
|