From 13df1f64b02fb79635c825a11973beaed12b5f9a Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 23 Mar 2026 20:12:10 -0700 Subject: [PATCH] feat: add menu bar --- cull/CullApp.swift | 103 ++++++++++++++++++++++++++++++++++ cull/Views/ContentView.swift | 103 ++++++++++++++++++++++++++++++++++ cull/Views/ImportView.swift | 104 +---------------------------------- 3 files changed, 208 insertions(+), 102 deletions(-) diff --git a/cull/CullApp.swift b/cull/CullApp.swift index bec8848..504a6b8 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -12,5 +12,108 @@ struct CullApp: App { .environment(thumbnailCache) } .windowStyle(.automatic) + .commands { + // Replace default File menu items + CommandGroup(replacing: .newItem) { + Button("Open Folder...") { + openFolder() + } + .keyboardShortcut("o") + + Divider() + + Button("Export...") { + NotificationCenter.default.post(name: .showExport, object: nil) + } + .keyboardShortcut("e") + .disabled(session.groups.isEmpty) + + Divider() + + Button("Close Folder") { + session.sourceFolder = nil + session.groups = [] + thumbnailCache.clearCache() + } + .keyboardShortcut("w") + .disabled(session.sourceFolder == nil) + } + + // Photo menu + CommandMenu("Photo") { + Button("Pick") { + session.togglePick() + } + .keyboardShortcut("p", modifiers: []) + .disabled(session.selectedPhoto == nil) + + Button("Reject") { + session.toggleReject() + } + .keyboardShortcut("x", modifiers: []) + .disabled(session.selectedPhoto == nil) + + Button("Clear Rating & Flag") { + session.clearRatingAndFlag() + } + .keyboardShortcut("0", modifiers: []) + .disabled(session.selectedPhoto == nil) + + Divider() + + ForEach(1...5, id: \.self) { star in + Button("Rate \(star) Star\(star > 1 ? "s" : "")") { + session.setRating(star) + } + .keyboardShortcut(KeyEquivalent(Character("\(star)")), modifiers: []) + .disabled(session.selectedPhoto == nil) + } + } + + // Navigate menu + CommandMenu("Navigate") { + Button("Next Photo") { + session.moveToNextPhoto() + } + .keyboardShortcut(.downArrow, modifiers: []) + .disabled(session.groups.isEmpty) + + Button("Previous Photo") { + session.moveToPreviousPhoto() + } + .keyboardShortcut(.upArrow, modifiers: []) + .disabled(session.groups.isEmpty) + + Divider() + + Button("Next Group") { + session.moveToNextGroup() + } + .keyboardShortcut(.rightArrow, modifiers: []) + .disabled(session.groups.isEmpty) + + Button("Previous Group") { + session.moveToPreviousGroup() + } + .keyboardShortcut(.leftArrow, modifiers: []) + .disabled(session.groups.isEmpty) + } + } } + + private func openFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.message = "Select a folder containing photos" + + guard panel.runModal() == .OK, let url = panel.url else { return } + NotificationCenter.default.post(name: .openFolder, object: url) + } +} + +extension Notification.Name { + static let openFolder = Notification.Name("openFolder") + static let showExport = Notification.Name("showExport") } diff --git a/cull/Views/ContentView.swift b/cull/Views/ContentView.swift index 9257cee..68f86ff 100644 --- a/cull/Views/ContentView.swift +++ b/cull/Views/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ContentView: View { @Environment(CullSession.self) private var session + @Environment(ThumbnailCache.self) private var cache @State private var showExportSheet = false @FocusState private var isViewerFocused: Bool @@ -37,6 +38,108 @@ struct ContentView: View { } } .frame(minWidth: 1000, minHeight: 600) + .onReceive(NotificationCenter.default.publisher(for: .openFolder)) { notification in + guard let url = notification.object as? URL else { return } + startImport(url) + } + .onReceive(NotificationCenter.default.publisher(for: .showExport)) { _ in + showExportSheet = true + } + } + + @MainActor + private func startImport(_ url: URL) { + session.sourceFolder = url + session.isImporting = true + session.importProgress = 0.02 + cache.clearCache() + + let s = session + let c = cache + + Task { + do { + let result = try await PhotoImporter.importFolder(url) + + var lastReported = 0.0 + let groups = await ShotGrouper.group(photos: result.photos) { p in + let mapped = p * 0.95 + guard mapped - lastReported > 0.02 else { return } + lastReported = mapped + await MainActor.run { + withAnimation(.linear(duration: 0.3)) { + s.importProgress = mapped + } + } + } + + let allPhotos = groups.flatMap(\.photos) + var lastCacheReported = 0.95 + await c.preloadAllThumbnails(photos: allPhotos) { p in + let mapped = 0.95 + p * 0.03 + guard mapped - lastCacheReported > 0.005 else { return } + lastCacheReported = mapped + await MainActor.run { + withAnimation(.linear(duration: 0.2)) { + s.importProgress = mapped + } + } + } + + let ahead = Array(allPhotos.prefix(30)) + let behind = Array(allPhotos.suffix(30)) + let initialPreviews = ahead + behind.reversed() + var lastPreviewReported = 0.98 + await c.preloadAllPreviews(photos: initialPreviews) { p in + let mapped = 0.98 + p * 0.02 + guard mapped - lastPreviewReported > 0.005 else { return } + lastPreviewReported = mapped + await MainActor.run { + withAnimation(.linear(duration: 0.2)) { + s.importProgress = mapped + } + } + } + + await MainActor.run { + s.importProgress = 1.0 + s.groups = groups + s.selectedGroupIndex = 0 + s.selectedPhotoIndex = 0 + s.isImporting = false + } + + let analysisWork: [(UUID, URL)] = allPhotos.map { ($0.id, $0.pairedURL ?? $0.url) } + let photosByID: [UUID: Photo] = Dictionary(uniqueKeysWithValues: allPhotos.map { ($0.id, $0) }) + Task.detached(priority: .background) { + for batchStart in stride(from: 0, to: analysisWork.count, by: 4) { + let batch = Array(analysisWork[batchStart.. 0.02 else { return } - lastReported = mapped - await MainActor.run { - withAnimation(.linear(duration: 0.3)) { - s.importProgress = mapped - } - } - } - - // Phase 2: Load thumbnails into memory (95-98%) - let allPhotos = groups.flatMap(\.photos) - var lastCacheReported = 0.95 - await c.preloadAllThumbnails(photos: allPhotos) { p in - let mapped = 0.95 + p * 0.03 - guard mapped - lastCacheReported > 0.005 else { return } - lastCacheReported = mapped - await MainActor.run { - withAnimation(.linear(duration: 0.2)) { - s.importProgress = mapped - } - } - } - - // Phase 3: Preload initial full-res previews (98-100%) - let ahead = Array(allPhotos.prefix(30)) - let behind = Array(allPhotos.suffix(30)) - let initialPreviews = ahead + behind.reversed() - var lastPreviewReported = 0.98 - await c.preloadAllPreviews(photos: initialPreviews) { p in - let mapped = 0.98 + p * 0.02 - guard mapped - lastPreviewReported > 0.005 else { return } - lastPreviewReported = mapped - await MainActor.run { - withAnimation(.linear(duration: 0.2)) { - s.importProgress = mapped - } - } - } - - await MainActor.run { - s.importProgress = 1.0 - s.groups = groups - s.selectedGroupIndex = 0 - s.selectedPhotoIndex = 0 - s.isImporting = false - } - - // Quality analysis in background — batched to avoid overwhelming GPU - let analysisWork: [(UUID, URL)] = allPhotos.map { ($0.id, $0.pairedURL ?? $0.url) } - let photosByID: [UUID: Photo] = Dictionary(uniqueKeysWithValues: allPhotos.map { ($0.id, $0) }) - Task.detached(priority: .background) { - for batchStart in stride(from: 0, to: analysisWork.count, by: 4) { - let batch = Array(analysisWork[batchStart..