import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
/// 1:1 ImageOptim layout: a two-column table (File | Savings) filling the
|
|
/// window, a thin grey header, and a bottom status bar with file count, an
|
|
/// overall savings percentage, and a settings gear. No inline controls.
|
|
struct ContentView: View {
|
|
@EnvironmentObject var model: AppModel
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if !model.encoderReady {
|
|
Text("\(model.format.encoder.cmd) not found — run: brew install \(model.format.encoder.brew)")
|
|
.foregroundColor(.white).frame(maxWidth: .infinity)
|
|
.padding(6).background(Color.red)
|
|
}
|
|
header
|
|
Divider()
|
|
table
|
|
Divider()
|
|
statusBar
|
|
}
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 0) {
|
|
Text("File").padding(.leading, 28)
|
|
Spacer()
|
|
Text("Savings").padding(.trailing, 16)
|
|
}
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 22)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
|
|
private var table: some View {
|
|
ZStack {
|
|
if model.items.isEmpty {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "photo.on.rectangle.angled")
|
|
.font(.system(size: 40)).foregroundColor(.secondary)
|
|
Text("Drop images here").font(.title3).foregroundColor(.secondary)
|
|
Text("JPG · PNG · GIF → JPEG XL").font(.caption).foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
List(model.items) { ImageRow(item: $0) }
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onDrop(of: [UTType.fileURL], isTargeted: nil) { providers in
|
|
for p in providers {
|
|
_ = p.loadObject(ofClass: URL.self) { url, _ in
|
|
if let url { DispatchQueue.main.async { model.add(urls: [url]) } }
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var statusBar: some View {
|
|
HStack(spacing: 12) {
|
|
Button(action: openPanel) { Image(systemName: "plus") }
|
|
.buttonStyle(.borderless).help("Add files or a folder")
|
|
SettingsGear().environmentObject(model)
|
|
Text("\(model.items.count) files").foregroundColor(.secondary)
|
|
if model.busy { ProgressView().scaleEffect(0.5) }
|
|
Spacer()
|
|
Text("saved").foregroundColor(.secondary)
|
|
Text(String(format: "%.1f%%", model.overallSavedPercent))
|
|
.foregroundColor(.green).bold().monospacedDigit()
|
|
}
|
|
.font(.system(size: 12))
|
|
.padding(.horizontal, 12).frame(height: 34)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
|
|
/// "+" → pick files and/or folders; folders recurse via AppModel.add.
|
|
private func openPanel() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = true
|
|
panel.allowsMultipleSelection = true
|
|
if panel.runModal() == .OK { model.add(urls: panel.urls) }
|
|
}
|
|
}
|
|
|
|
/// Gear button → popover with the quality/effort/lossless settings ImageOptim
|
|
/// keeps in Preferences.
|
|
struct SettingsGear: View {
|
|
@EnvironmentObject var model: AppModel
|
|
@State private var open = false
|
|
var body: some View {
|
|
Button { open.toggle() } label: { Image(systemName: "gearshape") }
|
|
.buttonStyle(.borderless)
|
|
.popover(isPresented: $open) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Picker("Format", selection: $model.format) {
|
|
ForEach(Compressor.Format.allCases) { Text($0.label).tag($0) }
|
|
}.pickerStyle(.segmented)
|
|
Toggle("Lossless", isOn: $model.lossless)
|
|
if !model.lossless {
|
|
HStack { Text("Quality"); Slider(value: $model.distance, in: 0.5...3.0)
|
|
Text(String(format: "d%.1f", model.distance)).monospacedDigit() }
|
|
}
|
|
Stepper("Effort \(model.effort)", value: $model.effort, in: 1...9)
|
|
Toggle("Keep original", isOn: $model.keepOriginal)
|
|
}.padding(16).frame(width: 240)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ImageRow: View {
|
|
@ObservedObject var item: ImageItem
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
if let t = item.thumb {
|
|
Image(nsImage: t).resizable().frame(width: 22, height: 22).cornerRadius(3)
|
|
} else {
|
|
Image(systemName: "photo").frame(width: 22, height: 22).foregroundColor(.secondary)
|
|
}
|
|
icon.frame(width: 14)
|
|
Text(item.url.lastPathComponent).lineLimit(1)
|
|
Spacer()
|
|
Group {
|
|
switch item.status {
|
|
case .done: Text(String(format: "%.1f%%", item.savedFraction * 100))
|
|
.foregroundColor(.green).bold()
|
|
case .failed: Text("error").foregroundColor(.red).help(item.message)
|
|
case .skipped: Text("—").foregroundColor(.secondary)
|
|
case .working: ProgressView().scaleEffect(0.5)
|
|
case .pending: Text("…").foregroundColor(.secondary)
|
|
}
|
|
}.monospacedDigit()
|
|
}
|
|
.font(.system(size: 13))
|
|
.padding(.vertical, 2)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(count: 2) {
|
|
if let out = item.outputURL { NSWorkspace.shared.activateFileViewerSelecting([out]) }
|
|
}
|
|
.onTapGesture(count: 1) { QuickLook.shared.preview(item.url) }
|
|
.contextMenu {
|
|
if let out = item.outputURL {
|
|
Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([out]) }
|
|
if out.pathExtension == "jxl" {
|
|
Button("Decode back to PNG (compare)") {
|
|
DispatchQueue.global().async {
|
|
if let png = try? Compressor.decode(out) {
|
|
DispatchQueue.main.async { NSWorkspace.shared.activateFileViewerSelecting([png]) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
@ViewBuilder var icon: some View {
|
|
switch item.status {
|
|
case .done: Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
|
|
case .failed: Image(systemName: "xmark.circle.fill").foregroundColor(.red)
|
|
default: Image(systemName: "photo")
|
|
}
|
|
}
|
|
}
|