its whats on the tin; culls raw photos
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add filtering and adjust cursor movement

+220 -35
+1
cull/CullApp.swift
··· 97 97 } 98 98 .keyboardShortcut(.leftArrow, modifiers: []) 99 99 .disabled(session.groups.isEmpty) 100 + 100 101 } 101 102 } 102 103 }
+142 -15
cull/Models/CullSession.swift
··· 12 12 var importProgress: Double = 0 13 13 var importStatus: String = "" 14 14 15 + // Remember cursor position per group 16 + private var groupCursorPositions: [UUID: Int] = [:] 17 + 18 + // Filters: command-click to toggle hiding photos with these attributes 19 + var hiddenRatings: Set<Int> = [] // ratings to hide (1-5) 20 + var hidePicks: Bool = false 21 + var hideRejects: Bool = false 22 + 23 + func toggleRatingFilter(_ rating: Int) { 24 + if hiddenRatings.contains(rating) { 25 + hiddenRatings.remove(rating) 26 + } else { 27 + hiddenRatings.insert(rating) 28 + } 29 + ensureVisibleSelection() 30 + } 31 + 32 + func togglePickFilter() { 33 + hidePicks.toggle() 34 + ensureVisibleSelection() 35 + } 36 + 37 + func toggleRejectFilter() { 38 + hideRejects.toggle() 39 + ensureVisibleSelection() 40 + } 41 + 42 + func isPhotoFiltered(_ photo: Photo) -> Bool { 43 + if hidePicks && photo.flag == .pick { return true } 44 + if hideRejects && photo.flag == .reject { return true } 45 + if photo.rating > 0 && hiddenRatings.contains(photo.rating) { return true } 46 + return false 47 + } 48 + 15 49 var selectedGroup: PhotoGroup? { 16 50 guard groups.indices.contains(selectedGroupIndex) else { return nil } 17 51 return groups[selectedGroupIndex] ··· 31 65 32 66 func moveToNextGroup() { 33 67 guard !groups.isEmpty else { return } 34 - selectedGroupIndex = (selectedGroupIndex + 1) % groups.count 35 - selectedPhotoIndex = 0 68 + saveCursorPosition() 69 + let start = selectedGroupIndex 70 + for offset in 1...groups.count { 71 + let idx = (start + offset) % groups.count 72 + if groupHasVisiblePhotos(idx) { 73 + selectedGroupIndex = idx 74 + restoreCursorPosition() 75 + return 76 + } 77 + } 36 78 } 37 79 38 80 func moveToPreviousGroup() { 39 81 guard !groups.isEmpty else { return } 40 - selectedGroupIndex = (selectedGroupIndex - 1 + groups.count) % groups.count 41 - selectedPhotoIndex = 0 82 + saveCursorPosition() 83 + let start = selectedGroupIndex 84 + for offset in 1...groups.count { 85 + let idx = (start - offset + groups.count) % groups.count 86 + if groupHasVisiblePhotos(idx) { 87 + selectedGroupIndex = idx 88 + restoreCursorPosition() 89 + return 90 + } 91 + } 42 92 } 43 93 44 94 func moveToNextPhoto() { 45 95 guard let group = selectedGroup else { return } 46 - if selectedPhotoIndex < group.photos.count - 1 { 47 - selectedPhotoIndex += 1 48 - } else { 49 - moveToNextGroup() 96 + // Try to find next visible photo in current group 97 + for i in (selectedPhotoIndex + 1)..<group.photos.count { 98 + if !isPhotoFiltered(group.photos[i]) { 99 + selectedPhotoIndex = i 100 + return 101 + } 50 102 } 103 + // Exhausted group, move to next 104 + moveToNextGroup() 51 105 } 52 106 53 107 func moveToPreviousPhoto() { 54 - if selectedPhotoIndex > 0 { 55 - selectedPhotoIndex -= 1 56 - } else { 57 - moveToPreviousGroup() 58 - selectedPhotoIndex = max(0, (selectedGroup?.photos.count ?? 1) - 1) 108 + guard let group = selectedGroup else { return } 109 + // Try to find previous visible photo in current group 110 + for i in stride(from: selectedPhotoIndex - 1, through: 0, by: -1) { 111 + if !isPhotoFiltered(group.photos[i]) { 112 + selectedPhotoIndex = i 113 + return 114 + } 115 + } 116 + // Exhausted group, move to previous 117 + moveToPreviousGroup() 118 + // Land on last visible photo in new group 119 + if let newGroup = selectedGroup { 120 + for i in stride(from: newGroup.photos.count - 1, through: 0, by: -1) { 121 + if !isPhotoFiltered(newGroup.photos[i]) { 122 + selectedPhotoIndex = i 123 + return 124 + } 125 + } 59 126 } 60 127 } 61 128 62 129 func selectGroup(at index: Int) { 63 130 guard groups.indices.contains(index) else { return } 131 + saveCursorPosition() 64 132 selectedGroupIndex = index 65 - selectedPhotoIndex = 0 133 + restoreCursorPosition() 66 134 } 67 135 68 136 func selectPhoto(at index: Int) { ··· 70 138 selectedPhotoIndex = index 71 139 } 72 140 141 + private func saveCursorPosition() { 142 + guard let group = selectedGroup else { return } 143 + groupCursorPositions[group.id] = selectedPhotoIndex 144 + } 145 + 146 + private func restoreCursorPosition() { 147 + guard let group = selectedGroup else { return } 148 + let saved = groupCursorPositions[group.id] ?? 0 149 + let clamped = min(saved, group.photos.count - 1) 150 + selectedPhotoIndex = clamped 151 + // If restored position is filtered, find nearest visible 152 + if isPhotoFiltered(group.photos[clamped]) { 153 + ensureVisibleInGroup() 154 + } 155 + } 156 + 157 + private func groupHasVisiblePhotos(_ groupIndex: Int) -> Bool { 158 + guard groups.indices.contains(groupIndex) else { return false } 159 + return groups[groupIndex].photos.contains { !isPhotoFiltered($0) } 160 + } 161 + 162 + /// If current photo is filtered, find nearest visible: forward first, then backward, then next group 163 + private func ensureVisibleSelection() { 164 + guard let photo = selectedPhoto else { return } 165 + guard isPhotoFiltered(photo) else { return } 166 + ensureVisibleInGroup() 167 + } 168 + 169 + private func ensureVisibleInGroup() { 170 + guard let group = selectedGroup else { return } 171 + // Try forward from current position 172 + for i in selectedPhotoIndex..<group.photos.count { 173 + if !isPhotoFiltered(group.photos[i]) { 174 + selectedPhotoIndex = i 175 + return 176 + } 177 + } 178 + // Try backward 179 + for i in stride(from: selectedPhotoIndex - 1, through: 0, by: -1) { 180 + if !isPhotoFiltered(group.photos[i]) { 181 + selectedPhotoIndex = i 182 + return 183 + } 184 + } 185 + // Entire group filtered, move to next group with visible photos 186 + let start = selectedGroupIndex 187 + for offset in 1...groups.count { 188 + let idx = (start + offset) % groups.count 189 + if groupHasVisiblePhotos(idx) { 190 + selectedGroupIndex = idx 191 + selectedPhotoIndex = groups[idx].photos.firstIndex { !isPhotoFiltered($0) } ?? 0 192 + return 193 + } 194 + } 195 + } 196 + 73 197 // MARK: - Lookahead 74 198 75 199 /// Returns the next N photos from the current position, wrapping around to start ··· 98 222 99 223 /// Current photo's index in the flat allPhotos array 100 224 private var flatIndex: Int? { 101 - guard let photo = selectedPhoto else { return nil } 225 + guard selectedPhoto != nil else { return nil } 102 226 var idx = 0 103 227 for gi in 0..<groups.count { 104 228 for pi in 0..<groups[gi].photos.count { ··· 116 240 func setRating(_ rating: Int) { 117 241 guard (1...5).contains(rating) else { return } 118 242 selectedPhoto?.rating = rating 243 + ensureVisibleSelection() 119 244 } 120 245 121 246 func togglePick() { 122 247 guard let photo = selectedPhoto else { return } 123 248 photo.flag = photo.flag == .pick ? .none : .pick 249 + ensureVisibleSelection() 124 250 } 125 251 126 252 func toggleReject() { 127 253 guard let photo = selectedPhoto else { return } 128 254 photo.flag = photo.flag == .reject ? .none : .reject 255 + ensureVisibleSelection() 129 256 } 130 257 131 258 func clearRatingAndFlag() {
+68 -13
cull/Views/ContentView.swift
··· 179 179 .toolbar { 180 180 ToolbarItem(placement: .automatic) { 181 181 HStack(spacing: 4) { 182 - Button { session.togglePick() } label: { 183 - Image(systemName: "checkmark.circle") 184 - } 185 - .help("Pick (P)") 182 + ToolbarFilterButton( 183 + activeIcon: "checkmark.circle.fill", 184 + inactiveIcon: "checkmark.circle", 185 + isActive: session.selectedPhoto?.flag == .pick, 186 + isFiltered: session.hidePicks, 187 + activeColor: .green, 188 + action: { session.togglePick() }, 189 + filterAction: { session.togglePickFilter() }, 190 + help: "Pick (P) · ⌘Click to filter" 191 + ) 186 192 187 - Button { session.toggleReject() } label: { 188 - Image(systemName: "xmark.circle") 189 - } 190 - .help("Reject (X)") 193 + ToolbarFilterButton( 194 + activeIcon: "xmark.circle.fill", 195 + inactiveIcon: "xmark.circle", 196 + isActive: session.selectedPhoto?.flag == .reject, 197 + isFiltered: session.hideRejects, 198 + activeColor: .red, 199 + action: { session.toggleReject() }, 200 + filterAction: { session.toggleRejectFilter() }, 201 + help: "Reject (X) · ⌘Click to filter" 202 + ) 191 203 } 192 204 } 193 205 194 206 ToolbarItem(placement: .automatic) { 195 207 HStack(spacing: 2) { 196 208 ForEach(1...5, id: \.self) { star in 197 - Button { session.setRating(star) } label: { 198 - Image(systemName: star <= (session.selectedPhoto?.rating ?? 0) ? "star.fill" : "star") 199 - .foregroundStyle(star <= (session.selectedPhoto?.rating ?? 0) ? .yellow : .secondary) 200 - } 201 - .help("Rate \(star)") 209 + let isActive = star <= (session.selectedPhoto?.rating ?? 0) 210 + let isFiltered = session.hiddenRatings.contains(star) 211 + ToolbarFilterButton( 212 + activeIcon: "star.fill", 213 + inactiveIcon: "star", 214 + isActive: isActive, 215 + isFiltered: isFiltered, 216 + activeColor: .yellow, 217 + action: { session.setRating(star) }, 218 + filterAction: { session.toggleRatingFilter(star) }, 219 + help: "Rate \(star) · ⌘Click to filter" 220 + ) 202 221 } 203 222 } 204 223 } ··· 221 240 .help("Open Folder") 222 241 } 223 242 } 243 + } 244 + } 245 + 246 + struct ToolbarFilterButton: View { 247 + let activeIcon: String 248 + let inactiveIcon: String 249 + let isActive: Bool 250 + let isFiltered: Bool 251 + let activeColor: Color 252 + let action: () -> Void 253 + let filterAction: () -> Void 254 + let help: String 255 + 256 + init(activeIcon: String, inactiveIcon: String, isActive: Bool, isFiltered: Bool, activeColor: Color, action: @escaping () -> Void, filterAction: @escaping () -> Void, help: String) { 257 + self.activeIcon = activeIcon 258 + self.inactiveIcon = inactiveIcon 259 + self.isActive = isActive 260 + self.isFiltered = isFiltered 261 + self.activeColor = activeColor 262 + self.action = action 263 + self.filterAction = filterAction 264 + self.help = help 265 + } 266 + 267 + var body: some View { 268 + Button { 269 + if NSEvent.modifierFlags.contains(.command) { 270 + filterAction() 271 + } else { 272 + action() 273 + } 274 + } label: { 275 + Image(systemName: isActive ? activeIcon : inactiveIcon) 276 + .foregroundStyle(isFiltered ? Color.gray.opacity(0.3) : (isActive ? activeColor : Color.secondary)) 277 + } 278 + .help(help) 224 279 } 225 280 } 226 281
+9 -7
cull/Views/GroupDetailView.swift
··· 10 10 if let group = session.selectedGroup { 11 11 LazyVStack(spacing: 2) { 12 12 ForEach(Array(group.photos.enumerated()), id: \.element.id) { index, photo in 13 - PhotoThumbnail( 14 - photo: photo, 15 - isSelected: index == session.selectedPhotoIndex 16 - ) 17 - .id(photo.id) 18 - .onTapGesture { 19 - session.selectPhoto(at: index) 13 + if !session.isPhotoFiltered(photo) { 14 + PhotoThumbnail( 15 + photo: photo, 16 + isSelected: index == session.selectedPhotoIndex 17 + ) 18 + .id(photo.id) 19 + .onTapGesture { 20 + session.selectPhoto(at: index) 21 + } 20 22 } 21 23 } 22 24 }