diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index b419b0f..cba211a 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -161,7 +161,7 @@ final class CullSession { for i in stride(from: newGroup.photos.count - 1, through: 0, by: -1) { if !isPhotoFiltered(newGroup.photos[i]) { selectedPhotoIndex = i - return + return } } } @@ -242,9 +242,9 @@ final class CullSession { let visible = allPhotos.filter { !isPhotoFiltered($0) } guard !visible.isEmpty, count > 0 else { return [] } guard let current = selectedPhoto, - let visibleIndex = visible.firstIndex(where: { $0.id == current.id }) else { return [] } + let vi = visible.firstIndex(where: { $0.id == current.id }) else { return [] } return (1...min(count, visible.count - 1)).map { i in - visible[(visibleIndex + i) % visible.count] + visible[(vi + i) % visible.count] } } @@ -253,9 +253,9 @@ final class CullSession { let visible = allPhotos.filter { !isPhotoFiltered($0) } guard !visible.isEmpty, count > 0 else { return [] } guard let current = selectedPhoto, - let visibleIndex = visible.firstIndex(where: { $0.id == current.id }) else { return [] } + let vi = visible.firstIndex(where: { $0.id == current.id }) else { return [] } return (1...min(count, visible.count - 1)).map { i in - visible[(visibleIndex - i + visible.count) % visible.count] + visible[(vi - i + visible.count) % visible.count] } } @@ -265,12 +265,10 @@ final class CullSession { guard !groups.isEmpty else { return [] } var result: [Photo] = [] for offset in 1...groupCount { - // Next group (closest first due to iteration order) let nextIdx = (selectedGroupIndex + offset) % groups.count if let first = groups[nextIdx].photos.first(where: { !isPhotoFiltered($0) }) { result.append(first) } - // Previous group let prevIdx = (selectedGroupIndex - offset + groups.count) % groups.count if prevIdx != nextIdx, let first = groups[prevIdx].photos.first(where: { !isPhotoFiltered($0) }) { @@ -479,6 +477,7 @@ final class CullSession { selectedPhotoIndex = min(selectedPhotoIndex, group.photos.count - 1) } + return WorkspaceResult(newPhotos: newPhotos) } } diff --git a/cull/Services/ThumbnailCache.swift b/cull/Services/ThumbnailCache.swift index af5d164..5818040 100644 --- a/cull/Services/ThumbnailCache.swift +++ b/cull/Services/ThumbnailCache.swift @@ -11,6 +11,8 @@ final class ThumbnailCache { private var preloadTask: Task? /// Bumped whenever cache state changes, to trigger SwiftUI re-renders for debug overlay private(set) var cacheGeneration = 0 + /// Recent debug log entries (newest first), shown in debug overlay + private(set) var debugLog: [String] = [] private let diskCacheURL: URL private let maxPixelSize: Int @@ -27,6 +29,13 @@ final class ThumbnailCache { try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) } + private func log(_ message: String) { + let entry = "\(message) [\(previewKeys.count)/\(previewCache.countLimit)]" + debugLog.insert(entry, at: 0) + if debugLog.count > 20 { debugLog = Array(debugLog.prefix(20)) } + cacheGeneration += 1 + } + // MARK: - Synchronous lookups (instant, memory only) func cachedThumbnail(for photo: Photo) -> NSImage? { @@ -240,6 +249,8 @@ final class ThumbnailCache { } guard !work.isEmpty else { return } + log("preload \(work.count)/\(photos.count) new previews") + let pc = previewCache preloadTask = Task.detached(priority: .utility) { @@ -277,9 +288,14 @@ final class ThumbnailCache { /// Remove previews that are outside the current window func evictPreviews(keeping photos: [Photo]) { let keepKeys = Set(photos.map { $0.url.absoluteString }) + var evicted = 0 for key in previewKeys where !keepKeys.contains(key) { previewCache.removeObject(forKey: key as NSString) previewKeys.remove(key) + evicted += 1 + } + if evicted > 0 { + log("evict \(evicted) previews") } } @@ -288,17 +304,24 @@ final class ThumbnailCache { let keepKeys = Set(photos.map { $0.url.absoluteString }) // Evict previews + var evictedPreviews = 0 for key in previewKeys where !keepKeys.contains(key) { previewCache.removeObject(forKey: key as NSString) previewKeys.remove(key) + evictedPreviews += 1 } // Evict thumbnails + var evictedThumbs = 0 for key in thumbnailKeys where !keepKeys.contains(key) { memoryCache.removeObject(forKey: key as NSString) thumbnailKeys.remove(key) + evictedThumbs += 1 } + if evictedPreviews > 0 || evictedThumbs > 0 { + log("filter evict \(evictedPreviews)p \(evictedThumbs)t") + } cacheGeneration += 1 } diff --git a/cull/Views/PhotoViewer.swift b/cull/Views/PhotoViewer.swift index 6498543..4daa8b3 100644 --- a/cull/Views/PhotoViewer.swift +++ b/cull/Views/PhotoViewer.swift @@ -82,6 +82,17 @@ struct PhotoViewer: View { .fontWeight(.semibold) Text("Groups: \(session.groups.count)") Text("Photos: \(allPhotos.count)") + + Divider().overlay(Color.white.opacity(0.3)) + + Text("Log") + .fontWeight(.semibold) + ForEach(Array(cache.debugLog.prefix(8).enumerated()), id: \.offset) { _, entry in + Text(entry) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + } } .font(.caption2) .foregroundStyle(.white) @@ -277,8 +288,8 @@ struct PhotoViewer: View { displayQuality = "preview" } - // Wait for user to stop navigating before doing any loading - try? await Task.sleep(for: .milliseconds(100)) + // Wait for user to pause before computing preload window + try? await Task.sleep(for: .milliseconds(150)) guard !Task.isCancelled, displayedPhotoID == photoID else { return } // Load current photo's full-res preview @@ -290,22 +301,19 @@ struct PhotoViewer: View { } } - // Preload window: current → interleaved group reps + linear neighbors (closest first) + // Preload window: group reps prioritized over linear neighbors guard !Task.isCancelled, displayedPhotoID == photoID else { return } let groupReps = session.adjacentGroupRepresentatives(groupCount: 15) let ahead = session.photosAhead(lookaheadCount) let behind = session.photosBehind(lookbehindCount) - // Interleave: 2 group reps per linear neighbor so group scanning gets priority var fanOut: [Photo] = [] let maxLinear = max(ahead.count, behind.count) let maxGroups = groupReps.count var gi = 0, li = 0 while gi < maxGroups || li < maxLinear { - // Two group reps if gi < maxGroups { fanOut.append(groupReps[gi]); gi += 1 } if gi < maxGroups { fanOut.append(groupReps[gi]); gi += 1 } - // One linear neighbor each direction if li < ahead.count { fanOut.append(ahead[li]) } if li < behind.count { fanOut.append(behind[li]) } li += 1