V0.1.1, added zoom
This commit is contained in:
parent
77d9acfb7a
commit
bee56ff8ae
|
|
@ -1,45 +1,50 @@
|
|||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var viewModel = DitherViewModel()
|
||||
@State private var isImporting = false
|
||||
@State private var isExporting = false
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(viewModel: viewModel, openFile: openFile, saveFile: saveFile)
|
||||
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
|
||||
.navigationSplitViewColumnWidth(min: 260, ideal: 300)
|
||||
} detail: {
|
||||
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
||||
}
|
||||
// Global Cursor Fix: Force arrow cursor to prevent I-Beam
|
||||
.onHover { _ in
|
||||
NSCursor.arrow.push()
|
||||
}
|
||||
.onChange(of: viewModel.brightness) { _, _ in viewModel.processImage() }
|
||||
.onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() }
|
||||
.onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() }
|
||||
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
|
||||
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
|
||||
}
|
||||
|
||||
private func openFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.image]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
if let url = panel.url {
|
||||
viewModel.load(url: url)
|
||||
// File Importer at the very top level
|
||||
.fileImporter(
|
||||
isPresented: $isImporting,
|
||||
allowedContentTypes: [.image],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
if let url = urls.first {
|
||||
viewModel.load(url: url)
|
||||
}
|
||||
case .failure(let error):
|
||||
print("Import failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveFile() {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.png]
|
||||
panel.canCreateDirectories = true
|
||||
panel.nameFieldStringValue = "dithered_image.png"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
if let url = panel.url {
|
||||
viewModel.exportResult(to: url)
|
||||
.fileExporter(
|
||||
isPresented: $isExporting,
|
||||
document: ImageDocument(image: viewModel.processedImage),
|
||||
contentType: .png,
|
||||
defaultFilename: "dithered_image"
|
||||
) { result in
|
||||
if case .failure(let error) = result {
|
||||
print("Export failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,10 +66,37 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Helper for FileExporter
|
||||
struct ImageDocument: FileDocument {
|
||||
var image: CGImage?
|
||||
|
||||
init(image: CGImage?) {
|
||||
self.image = image
|
||||
}
|
||||
|
||||
static var readableContentTypes: [UTType] { [.png] }
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
// Read not implemented for export-only
|
||||
self.image = nil
|
||||
}
|
||||
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
guard let image = image else { throw CocoaError(.fileWriteUnknown) }
|
||||
let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
|
||||
guard let tiffData = nsImage.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
return FileWrapper(regularFileWithContents: pngData)
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
@Bindable var viewModel: DitherViewModel
|
||||
var openFile: () -> Void
|
||||
var saveFile: () -> Void
|
||||
@Binding var isImporting: Bool
|
||||
@Binding var isExporting: Bool
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -118,12 +150,12 @@ struct SidebarView: View {
|
|||
.navigationTitle("iDither")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button(action: openFile) {
|
||||
Button(action: { isImporting = true }) {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.help("Import Image")
|
||||
|
||||
Button(action: saveFile) {
|
||||
Button(action: { isExporting = true }) {
|
||||
Label("Export", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(viewModel.processedImage == nil)
|
||||
|
|
@ -137,31 +169,164 @@ struct DetailView: View {
|
|||
var viewModel: DitherViewModel
|
||||
var loadFromProviders: ([NSItemProvider]) -> Bool
|
||||
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var lastOffset: CGSize = .zero
|
||||
|
||||
// For gesture state
|
||||
@State private var magnification: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CheckeredBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
if let processedImage = viewModel.processedImage {
|
||||
Image(decorative: processedImage, scale: 1.0, orientation: .up)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(40)
|
||||
.shadow(radius: 10)
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label("No Image Selected", systemImage: "photo.badge.plus")
|
||||
} description: {
|
||||
Text("Drag and drop an image here to start dithering.")
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Layer 1: Background
|
||||
CheckeredBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Layer 2: Image
|
||||
if let processedImage = viewModel.processedImage {
|
||||
Image(decorative: processedImage, scale: 1.0, orientation: .up)
|
||||
.resizable()
|
||||
.interpolation(.none) // Nearest Neighbor for sharp pixels
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.scaleEffect(scale * magnification)
|
||||
.offset(
|
||||
x: offset.width + (magnification - 1) * 0,
|
||||
y: offset.height
|
||||
)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
offset = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastOffset = offset
|
||||
}
|
||||
)
|
||||
.gesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
magnification = value
|
||||
}
|
||||
.onEnded { value in
|
||||
scale = min(max(scale * value, 0.1), 20.0)
|
||||
magnification = 1.0
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label("No Image Selected", systemImage: "photo.badge.plus")
|
||||
} description: {
|
||||
Text("Drag and drop an image here to start dithering.")
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Floating HUD
|
||||
if viewModel.processedImage != nil {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
FloatingToolbar(scale: $scale, offset: $offset, lastOffset: $lastOffset, onFit: {
|
||||
fitToWindow(geometry: geometry)
|
||||
})
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDrop(of: [.image], isTargeted: nil) { providers in
|
||||
loadFromProviders(providers)
|
||||
}
|
||||
// CRITICAL FIX: Only fit to window when a NEW image is loaded (inputImageId changes)
|
||||
// This prevents the image from shrinking/resetting when adjusting sliders
|
||||
.onChange(of: viewModel.inputImageId) { _, _ in
|
||||
fitToWindow(geometry: geometry)
|
||||
}
|
||||
}
|
||||
.onDrop(of: [.image], isTargeted: nil) { providers in
|
||||
loadFromProviders(providers)
|
||||
}
|
||||
|
||||
private func fitToWindow(geometry: GeometryProxy) {
|
||||
guard let image = viewModel.processedImage else { return }
|
||||
let imageSize = CGSize(width: image.width, height: image.height)
|
||||
let viewSize = geometry.size
|
||||
|
||||
guard imageSize.width > 0, imageSize.height > 0 else { return }
|
||||
|
||||
let widthScale = viewSize.width / imageSize.width
|
||||
let heightScale = viewSize.height / imageSize.height
|
||||
let fitScale = min(widthScale, heightScale)
|
||||
|
||||
// Apply fit
|
||||
withAnimation {
|
||||
scale = min(fitScale * 0.9, 1.0) // 90% fit or 1.0 max
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FloatingToolbar: View {
|
||||
@Binding var scale: CGFloat
|
||||
@Binding var offset: CGSize
|
||||
@Binding var lastOffset: CGSize
|
||||
var onFit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
scale = max(scale - 0.2, 0.1)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "minus.magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("\(Int(scale * 100))%")
|
||||
.monospacedDigit()
|
||||
.frame(width: 50)
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
scale = min(scale + 0.2, 20.0)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus.magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
.frame(height: 20)
|
||||
|
||||
Button("1:1") {
|
||||
withAnimation {
|
||||
scale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button("Fit") {
|
||||
onFit()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.shadow(radius: 5)
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckeredBackground: View {
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
|
||||
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||
case none = 0
|
||||
case noDither = 0
|
||||
case bayer8x8 = 1
|
||||
case bayer4x4 = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .none: return "No Dither"
|
||||
case .noDither: return "No Dither"
|
||||
case .bayer8x8: return "Bayer 8x8"
|
||||
case .bayer4x4: return "Bayer 4x4"
|
||||
}
|
||||
|
|
@ -21,60 +23,68 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
|||
class DitherViewModel {
|
||||
var inputImage: CGImage?
|
||||
var processedImage: CGImage?
|
||||
var inputImageId: UUID = UUID() // Unique ID to track when a NEW file is loaded
|
||||
|
||||
var brightness: Float = 0.0
|
||||
var contrast: Float = 1.0
|
||||
var pixelScale: Float = 1.0
|
||||
var selectedAlgorithm: DitherAlgorithm = .none
|
||||
// Parameters
|
||||
var brightness: Double = 0.0
|
||||
var contrast: Double = 1.0
|
||||
var pixelScale: Double = 4.0
|
||||
var selectedAlgorithm: DitherAlgorithm = .bayer8x8
|
||||
var isGrayscale: Bool = false
|
||||
|
||||
private let renderer: MetalImageRenderer?
|
||||
private var processingTask: Task<Void, Never>?
|
||||
private let renderer = MetalImageRenderer()
|
||||
private var renderTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
self.renderer = MetalImageRenderer()
|
||||
}
|
||||
init() {}
|
||||
|
||||
func load(url: URL) {
|
||||
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||
let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
|
||||
print("Failed to load image")
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
||||
print("Failed to load image from \(url)")
|
||||
return
|
||||
}
|
||||
|
||||
self.inputImage = cgImage
|
||||
processImage()
|
||||
self.inputImageId = UUID() // Signal that a new image has been loaded
|
||||
self.processImage()
|
||||
}
|
||||
|
||||
func processImage() {
|
||||
guard let inputImage = inputImage, let renderer = renderer else { return }
|
||||
guard let input = inputImage, let renderer = renderer else { return }
|
||||
|
||||
processingTask?.cancel()
|
||||
// Cancel previous task to prevent UI freezing and Metal overload
|
||||
renderTask?.cancel()
|
||||
|
||||
let params = RenderParameters(
|
||||
brightness: brightness,
|
||||
contrast: contrast,
|
||||
pixelScale: pixelScale,
|
||||
brightness: Float(brightness),
|
||||
contrast: Float(contrast),
|
||||
pixelScale: Float(pixelScale),
|
||||
algorithm: Int32(selectedAlgorithm.rawValue),
|
||||
isGrayscale: isGrayscale ? 1 : 0
|
||||
)
|
||||
|
||||
processingTask = Task.detached(priority: .userInitiated) { [inputImage, renderer, params] in
|
||||
if let result = renderer.render(input: inputImage, params: params) {
|
||||
await MainActor.run {
|
||||
self.processedImage = result
|
||||
}
|
||||
renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
|
||||
if Task.isCancelled { return }
|
||||
|
||||
let result = renderer.render(input: input, params: params)
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.processedImage = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportResult(to url: URL) {
|
||||
guard let processedImage = processedImage,
|
||||
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
|
||||
guard let image = processedImage else { return }
|
||||
|
||||
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
|
||||
print("Failed to create image destination")
|
||||
return
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(destination, processedImage, nil)
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
CGImageDestinationFinalize(destination)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,16 @@ struct iDitherApp: App {
|
|||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear {
|
||||
// Fix for file picker auto-dismissal: Force app activation on launch
|
||||
DispatchQueue.main.async {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let window = NSApp.windows.first {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Style de fenêtre standard macOS
|
||||
.windowStyle(.titleBar)
|
||||
.windowToolbarStyle(.unified)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue