diff --git a/Sources/iDither/ContentView.swift b/Sources/iDither/ContentView.swift index 031a179..db8e6dc 100644 --- a/Sources/iDither/ContentView.swift +++ b/Sources/iDither/ContentView.swift @@ -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 diff --git a/Sources/iDither/ViewModel/DitherViewModel.swift b/Sources/iDither/ViewModel/DitherViewModel.swift index 8a66a49..73d666b 100644 --- a/Sources/iDither/ViewModel/DitherViewModel.swift +++ b/Sources/iDither/ViewModel/DitherViewModel.swift @@ -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? + private let renderer = MetalImageRenderer() + private var renderTask: Task? - 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) } } diff --git a/Sources/iDither/iDitherApp.swift b/Sources/iDither/iDitherApp.swift index 20a45fe..19e5869 100644 --- a/Sources/iDither/iDitherApp.swift +++ b/Sources/iDither/iDitherApp.swift @@ -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) } }