V0.1.1, added zoom
This commit is contained in:
parent
77d9acfb7a
commit
bee56ff8ae
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue