You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

167 lines
6.7 KiB

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