diff --git a/cull/CullApp.swift b/cull/CullApp.swift index 504a6b8..582f8ad 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -97,6 +97,7 @@ struct CullApp: App { } .keyboardShortcut(.leftArrow, modifiers: []) .disabled(session.groups.isEmpty) + } } } diff --git a/cull/Models/CullSession.swift b/cull/Models/CullSession.swift index 19ba535..079e0ca 100644 --- a/cull/Models/CullSession.swift +++ b/cull/Models/CullSession.swift @@ -12,6 +12,40 @@ final class CullSession { var importProgress: Double = 0 var importStatus: String = "" + // Remember cursor position per group + private var groupCursorPositions: [UUID: Int] = [:] + + // Filters: command-click to toggle hiding photos with these attributes + var hiddenRatings: Set = [] // ratings to hide (1-5) + var hidePicks: Bool = false + var hideRejects: Bool = false + + func toggleRatingFilter(_ rating: Int) { + if hiddenRatings.contains(rating) { + hiddenRatings.remove(rating) + } else { + hiddenRatings.insert(rating) + } + ensureVisibleSelection() + } + + func togglePickFilter() { + hidePicks.toggle() + ensureVisibleSelection() + } + + func toggleRejectFilter() { + hideRejects.toggle() + ensureVisibleSelection() + } + + func isPhotoFiltered(_ photo: Photo) -> Bool { + if hidePicks && photo.flag == .pick { return true } + if hideRejects && photo.flag == .reject { return true } + if photo.rating > 0 && hiddenRatings.contains(photo.rating) { return true } + return false + } + var selectedGroup: PhotoGroup? { guard groups.indices.contains(selectedGroupIndex) else { return nil } return groups[selectedGroupIndex] @@ -31,38 +65,72 @@ final class CullSession { func moveToNextGroup() { guard !groups.isEmpty else { return } - selectedGroupIndex = (selectedGroupIndex + 1) % groups.count - selectedPhotoIndex = 0 + saveCursorPosition() + let start = selectedGroupIndex + for offset in 1...groups.count { + let idx = (start + offset) % groups.count + if groupHasVisiblePhotos(idx) { + selectedGroupIndex = idx + restoreCursorPosition() + return + } + } } func moveToPreviousGroup() { guard !groups.isEmpty else { return } - selectedGroupIndex = (selectedGroupIndex - 1 + groups.count) % groups.count - selectedPhotoIndex = 0 + saveCursorPosition() + let start = selectedGroupIndex + for offset in 1...groups.count { + let idx = (start - offset + groups.count) % groups.count + if groupHasVisiblePhotos(idx) { + selectedGroupIndex = idx + restoreCursorPosition() + return + } + } } func moveToNextPhoto() { guard let group = selectedGroup else { return } - if selectedPhotoIndex < group.photos.count - 1 { - selectedPhotoIndex += 1 - } else { - moveToNextGroup() + // Try to find next visible photo in current group + for i in (selectedPhotoIndex + 1).. 0 { - selectedPhotoIndex -= 1 - } else { - moveToPreviousGroup() - selectedPhotoIndex = max(0, (selectedGroup?.photos.count ?? 1) - 1) + guard let group = selectedGroup else { return } + // Try to find previous visible photo in current group + for i in stride(from: selectedPhotoIndex - 1, through: 0, by: -1) { + if !isPhotoFiltered(group.photos[i]) { + selectedPhotoIndex = i + return + } + } + // Exhausted group, move to previous + moveToPreviousGroup() + // Land on last visible photo in new group + if let newGroup = selectedGroup { + for i in stride(from: newGroup.photos.count - 1, through: 0, by: -1) { + if !isPhotoFiltered(newGroup.photos[i]) { + selectedPhotoIndex = i + return + } + } } } func selectGroup(at index: Int) { guard groups.indices.contains(index) else { return } + saveCursorPosition() selectedGroupIndex = index - selectedPhotoIndex = 0 + restoreCursorPosition() } func selectPhoto(at index: Int) { @@ -70,6 +138,62 @@ final class CullSession { selectedPhotoIndex = index } + private func saveCursorPosition() { + guard let group = selectedGroup else { return } + groupCursorPositions[group.id] = selectedPhotoIndex + } + + private func restoreCursorPosition() { + guard let group = selectedGroup else { return } + let saved = groupCursorPositions[group.id] ?? 0 + let clamped = min(saved, group.photos.count - 1) + selectedPhotoIndex = clamped + // If restored position is filtered, find nearest visible + if isPhotoFiltered(group.photos[clamped]) { + ensureVisibleInGroup() + } + } + + private func groupHasVisiblePhotos(_ groupIndex: Int) -> Bool { + guard groups.indices.contains(groupIndex) else { return false } + return groups[groupIndex].photos.contains { !isPhotoFiltered($0) } + } + + /// If current photo is filtered, find nearest visible: forward first, then backward, then next group + private func ensureVisibleSelection() { + guard let photo = selectedPhoto else { return } + guard isPhotoFiltered(photo) else { return } + ensureVisibleInGroup() + } + + private func ensureVisibleInGroup() { + guard let group = selectedGroup else { return } + // Try forward from current position + for i in selectedPhotoIndex.. Void + let filterAction: () -> Void + let help: String + + init(activeIcon: String, inactiveIcon: String, isActive: Bool, isFiltered: Bool, activeColor: Color, action: @escaping () -> Void, filterAction: @escaping () -> Void, help: String) { + self.activeIcon = activeIcon + self.inactiveIcon = inactiveIcon + self.isActive = isActive + self.isFiltered = isFiltered + self.activeColor = activeColor + self.action = action + self.filterAction = filterAction + self.help = help + } + + var body: some View { + Button { + if NSEvent.modifierFlags.contains(.command) { + filterAction() + } else { + action() + } + } label: { + Image(systemName: isActive ? activeIcon : inactiveIcon) + .foregroundStyle(isFiltered ? Color.gray.opacity(0.3) : (isActive ? activeColor : Color.secondary)) + } + .help(help) + } +} + struct ImportProgressView: View { let status: String let progress: Double diff --git a/cull/Views/GroupDetailView.swift b/cull/Views/GroupDetailView.swift index e1766d6..0289ffb 100644 --- a/cull/Views/GroupDetailView.swift +++ b/cull/Views/GroupDetailView.swift @@ -10,13 +10,15 @@ struct GroupDetailView: View { if let group = session.selectedGroup { LazyVStack(spacing: 2) { ForEach(Array(group.photos.enumerated()), id: \.element.id) { index, photo in - PhotoThumbnail( - photo: photo, - isSelected: index == session.selectedPhotoIndex - ) - .id(photo.id) - .onTapGesture { - session.selectPhoto(at: index) + if !session.isPhotoFiltered(photo) { + PhotoThumbnail( + photo: photo, + isSelected: index == session.selectedPhotoIndex + ) + .id(photo.id) + .onTapGesture { + session.selectPhoto(at: index) + } } } }