V0.1.1, added zoom

This commit is contained in:
ewen 2026-01-14 02:08:21 +01:00
parent 77d9acfb7a
commit bee56ff8ae
3 changed files with 257 additions and 75 deletions

View file

@ -1,45 +1,50 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View { struct ContentView: View {
@State private var viewModel = DitherViewModel() @State private var viewModel = DitherViewModel()
@State private var isImporting = false
@State private var isExporting = false @State private var isExporting = false
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
SidebarView(viewModel: viewModel, openFile: openFile, saveFile: saveFile) SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
.navigationSplitViewColumnWidth(min: 260, ideal: 300) .navigationSplitViewColumnWidth(min: 260, ideal: 300)
} detail: { } detail: {
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders) 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.brightness) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() } .onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() } .onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() } .onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() } .onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
} // File Importer at the very top level
.fileImporter(
private func openFile() { isPresented: $isImporting,
let panel = NSOpenPanel() allowedContentTypes: [.image],
panel.allowedContentTypes = [.image] allowsMultipleSelection: false
panel.allowsMultipleSelection = false ) { result in
panel.canChooseDirectories = false switch result {
case .success(let urls):
if panel.runModal() == .OK { if let url = urls.first {
if let url = panel.url { viewModel.load(url: url)
viewModel.load(url: url) }
case .failure(let error):
print("Import failed: \(error.localizedDescription)")
} }
} }
} .fileExporter(
isPresented: $isExporting,
private func saveFile() { document: ImageDocument(image: viewModel.processedImage),
let panel = NSSavePanel() contentType: .png,
panel.allowedContentTypes = [.png] defaultFilename: "dithered_image"
panel.canCreateDirectories = true ) { result in
panel.nameFieldStringValue = "dithered_image.png" if case .failure(let error) = result {
print("Export failed: \(error.localizedDescription)")
if panel.runModal() == .OK {
if let url = panel.url {
viewModel.exportResult(to: url)
} }
} }
} }
@ -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 { struct SidebarView: View {
@Bindable var viewModel: DitherViewModel @Bindable var viewModel: DitherViewModel
var openFile: () -> Void @Binding var isImporting: Bool
var saveFile: () -> Void @Binding var isExporting: Bool
var body: some View { var body: some View {
Form { Form {
@ -118,12 +150,12 @@ struct SidebarView: View {
.navigationTitle("iDither") .navigationTitle("iDither")
.toolbar { .toolbar {
ToolbarItemGroup(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
Button(action: openFile) { Button(action: { isImporting = true }) {
Label("Import", systemImage: "square.and.arrow.down") Label("Import", systemImage: "square.and.arrow.down")
} }
.help("Import Image") .help("Import Image")
Button(action: saveFile) { Button(action: { isExporting = true }) {
Label("Export", systemImage: "square.and.arrow.up") Label("Export", systemImage: "square.and.arrow.up")
} }
.disabled(viewModel.processedImage == nil) .disabled(viewModel.processedImage == nil)
@ -137,31 +169,164 @@ struct DetailView: View {
var viewModel: DitherViewModel var viewModel: DitherViewModel
var loadFromProviders: ([NSItemProvider]) -> Bool var loadFromProviders: ([NSItemProvider]) -> Bool
var body: some View { @State private var scale: CGFloat = 1.0
ZStack { @State private var offset: CGSize = .zero
CheckeredBackground() @State private var lastOffset: CGSize = .zero
.ignoresSafeArea()
if let processedImage = viewModel.processedImage { // For gesture state
Image(decorative: processedImage, scale: 1.0, orientation: .up) @State private var magnification: CGFloat = 1.0
.resizable()
.aspectRatio(contentMode: .fit) var body: some View {
.padding(40) GeometryReader { geometry in
.shadow(radius: 10) ZStack {
} else { // Layer 1: Background
ContentUnavailableView { CheckeredBackground()
Label("No Image Selected", systemImage: "photo.badge.plus") .ignoresSafeArea()
} description: {
Text("Drag and drop an image here to start dithering.") // 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 { struct CheckeredBackground: View {
var body: some View { var body: some View {
Canvas { context, size in Canvas { context, size in

View file

@ -1,15 +1,17 @@
import SwiftUI import SwiftUI
import Combine import CoreGraphics
import ImageIO
enum DitherAlgorithm: Int, CaseIterable, Identifiable { enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case none = 0 case noDither = 0
case bayer8x8 = 1 case bayer8x8 = 1
case bayer4x4 = 2 case bayer4x4 = 2
var id: Int { rawValue } var id: Int { rawValue }
var name: String { var name: String {
switch self { switch self {
case .none: return "No Dither" case .noDither: return "No Dither"
case .bayer8x8: return "Bayer 8x8" case .bayer8x8: return "Bayer 8x8"
case .bayer4x4: return "Bayer 4x4" case .bayer4x4: return "Bayer 4x4"
} }
@ -21,60 +23,68 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
class DitherViewModel { class DitherViewModel {
var inputImage: CGImage? var inputImage: CGImage?
var processedImage: CGImage? var processedImage: CGImage?
var inputImageId: UUID = UUID() // Unique ID to track when a NEW file is loaded
var brightness: Float = 0.0 // Parameters
var contrast: Float = 1.0 var brightness: Double = 0.0
var pixelScale: Float = 1.0 var contrast: Double = 1.0
var selectedAlgorithm: DitherAlgorithm = .none var pixelScale: Double = 4.0
var selectedAlgorithm: DitherAlgorithm = .bayer8x8
var isGrayscale: Bool = false var isGrayscale: Bool = false
private let renderer: MetalImageRenderer? private let renderer = MetalImageRenderer()
private var processingTask: Task<Void, Never>? private var renderTask: Task<Void, Never>?
init() { init() {}
self.renderer = MetalImageRenderer()
}
func load(url: URL) { func load(url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
print("Failed to load image") print("Failed to load image from \(url)")
return return
} }
self.inputImage = cgImage self.inputImage = cgImage
processImage() self.inputImageId = UUID() // Signal that a new image has been loaded
self.processImage()
} }
func 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( let params = RenderParameters(
brightness: brightness, brightness: Float(brightness),
contrast: contrast, contrast: Float(contrast),
pixelScale: pixelScale, pixelScale: Float(pixelScale),
algorithm: Int32(selectedAlgorithm.rawValue), algorithm: Int32(selectedAlgorithm.rawValue),
isGrayscale: isGrayscale ? 1 : 0 isGrayscale: isGrayscale ? 1 : 0
) )
processingTask = Task.detached(priority: .userInitiated) { [inputImage, renderer, params] in renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
if let result = renderer.render(input: inputImage, params: params) { if Task.isCancelled { return }
await MainActor.run {
self.processedImage = result let result = renderer.render(input: input, params: params)
}
if Task.isCancelled { return }
await MainActor.run {
self.processedImage = result
} }
} }
} }
func exportResult(to url: URL) { func exportResult(to url: URL) {
guard let processedImage = processedImage, guard let image = processedImage else { return }
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
print("Failed to create image destination")
return return
} }
CGImageDestinationAddImage(destination, processedImage, nil) CGImageDestinationAddImage(destination, image, nil)
CGImageDestinationFinalize(destination) CGImageDestinationFinalize(destination)
} }
} }

View file

@ -5,9 +5,16 @@ struct iDitherApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() 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) .windowToolbarStyle(.unified)
} }
} }