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 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
var body: some View {
ZStack {
CheckeredBackground()
.ignoresSafeArea()
@State private var scale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
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.")
// For gesture state
@State private var magnification: CGFloat = 1.0
var body: some View {
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

View file

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

View file

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