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.
 
 
 

98 lines
3.3 KiB

import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@EnvironmentObject var model: AppModel
var body: some View {
VStack(spacing: 0) {
if !model.encoderReady {
banner("cjxl not found — run: brew install jpeg-xl", color: .red)
}
controls
Divider()
dropZone
Divider()
footer
}
}
private var controls: some View {
HStack(spacing: 16) {
Toggle("Lossless", isOn: $model.lossless)
if !model.lossless {
HStack {
Text("Quality")
Slider(value: $model.distance, in: 0.5...3.0)
.frame(width: 120)
Text(String(format: "d%.1f", model.distance)).monospacedDigit()
}
}
Stepper("Effort \(model.effort)", value: $model.effort, in: 1...9)
Toggle("Keep original", isOn: $model.keepOriginal)
Spacer()
}.padding(10)
}
private var dropZone: some View {
ZStack {
if model.items.isEmpty {
VStack(spacing: 6) {
Image(systemName: "square.and.arrow.down").font(.system(size: 34))
Text("Drop JPG / PNG / GIF here").foregroundColor(.secondary)
}
} else {
List(model.items) { ImageRow(item: $0) }
}
}
.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 footer: some View {
HStack {
if model.busy { ProgressView().scaleEffect(0.6); Text("Compressing…") }
Spacer()
Text("Saved \(human(model.totalSaved))").bold()
}.padding(8)
}
private func banner(_ s: String, color: Color) -> some View {
Text(s).foregroundColor(.white).frame(maxWidth: .infinity)
.padding(6).background(color)
}
}
struct ImageRow: View {
@ObservedObject var item: ImageItem
var body: some View {
HStack {
icon
Text(item.url.lastPathComponent).lineLimit(1)
Spacer()
switch item.status {
case .done:
Text("\(human(item.originalBytes)) → \(human(item.newBytes))").foregroundColor(.secondary)
Text(String(format: "−%.0f%%", item.savedFraction * 100)).foregroundColor(.green).bold()
case .failed: Text(item.message).foregroundColor(.red).lineLimit(1)
case .skipped: Text("no gain").foregroundColor(.secondary)
case .working: ProgressView().scaleEffect(0.5)
case .pending: Text("queued").foregroundColor(.secondary)
}
}.monospacedDigit()
}
@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")
}
}
}