import SwiftUI struct LocalView: View { @ObservedObject var appState: AppState @ObservedObject var localManager: LocalWallpapersManager @State private var selectedWallpaper: LocalWallpaper? @State private var showingDeleteConfirmation = false @State private var wallpaperToDelete: LocalWallpaper? init(appState: AppState, localManager: LocalWallpapersManager? = nil) { self.appState = appState self.localManager = localManager ?? .shared } var body: some View { VStack(spacing: 0) { // Header HStack { Text("Local Wallpapers") .font(.headline) Spacer() if !localManager.wallpapers.isEmpty { Text("\(localManager.wallpapers.count) items") .font(.caption) .foregroundColor(.secondary) } Button(action: scan) { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) .disabled(localManager.isLoading) } .padding() Divider() // Content if localManager.isLoading && localManager.wallpapers.isEmpty { LoadingState() } else if !appState.saveToFolderEnabled { EmptyState( icon: "folder.badge.questionmark", text: "Enable 'Save wallpapers locally' in Settings to view local wallpapers" ) } else if localManager.wallpapers.isEmpty { EmptyState( icon: "photo.on.rectangle.angled", text: "No wallpapers found in your save folder" ) } else { LocalWallpaperGrid( wallpapers: localManager.wallpapers, onSetWallpaper: { wallpaper in Task { await set(wallpaper) } }, onDeleteWallpaper: requestDelete ) } } .alert( "Delete Wallpaper?", isPresented: $showingDeleteConfirmation, presenting: wallpaperToDelete, actions: deleteActions, message: deleteMessage ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { if appState.saveToFolderEnabled { localManager.scanFolder(path: appState.saveFolderPath) } } } func scan() { localManager.scanFolder(path: appState.saveFolderPath) } func set(_ wallpaper: LocalWallpaper) async { await appState.setLocalWallpaper(wallpaper) } func requestDelete(_ wallpaper: LocalWallpaper) { wallpaperToDelete = wallpaper showingDeleteConfirmation = true } func delete(_ wallpaper: LocalWallpaper) { localManager.deleteWallpaper(wallpaper) } func cancelDelete() { wallpaperToDelete = nil } func deleteMessage(_ wallpaper: LocalWallpaper) -> Text { Text("This will permanently delete '\(wallpaper.filename)' from your save folder.") } @ViewBuilder func deleteActions(_ wallpaper: LocalWallpaper) -> some View { Button("Cancel", role: .cancel, action: cancelDelete) Button("Delete", role: .destructive) { delete(wallpaper) } } } // MARK: - Local Wallpaper Grid struct LocalWallpaperGrid: View { let wallpapers: [LocalWallpaper] let onSetWallpaper: (LocalWallpaper) -> Void let onDeleteWallpaper: (LocalWallpaper) -> Void var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) { ForEach(wallpapers) { wallpaper in LocalWallpaperItem( wallpaper: wallpaper, onSet: { onSetWallpaper(wallpaper) }, onDelete: { onDeleteWallpaper(wallpaper) } ) } } .padding(12) } } } // MARK: - Local Wallpaper Item struct LocalWallpaperItem: View { let wallpaper: LocalWallpaper let onSet: () -> Void let onDelete: () -> Void @State private var isHovering = false @State private var image: NSImage? @State private var isLoadingImage = true init( wallpaper: LocalWallpaper, onSet: @escaping () -> Void, onDelete: @escaping () -> Void, initialImage: NSImage? = nil, isLoadingImage: Bool = true ) { self.wallpaper = wallpaper self.onSet = onSet self.onDelete = onDelete _image = State(initialValue: initialImage) _isLoadingImage = State(initialValue: isLoadingImage) } var body: some View { Button(action: onSet) { ZStack { if let image = image { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fill) .clipped() } else if isLoadingImage { Rectangle() .fill(Color.gray.opacity(0.2)) .overlay(ProgressView()) } else { Rectangle() .fill(Color.gray.opacity(0.1)) .overlay( VStack { Image(systemName: "photo") .foregroundColor(.gray) Text(wallpaper.filename) .font(.caption2) .foregroundColor(.secondary) .lineLimit(1) } ) } } .frame(maxWidth: .infinity, minHeight: 100, maxHeight: 100) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2) ) .shadow(radius: isHovering ? 4 : 0) .overlay( // Action Buttons HStack { // Delete button Button(action: onDelete) { Image(systemName: "trash") .foregroundColor(.white) .shadow(radius: 2) .font(.system(size: 14, weight: .bold)) .padding(6) .background(Color.red.opacity(0.7)) .clipShape(Circle()) } .buttonStyle(.plain) .padding(6) Spacer() // Resolution badge Text(wallpaper.resolution) .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.black.opacity(0.6)) .foregroundColor(.white) .cornerRadius(4) .padding(6) } .opacity(isHovering ? 1 : 0) .animation(.easeInOut(duration: 0.15), value: isHovering), alignment: .top ) .scaleEffect(isHovering ? 1.02 : 1.0) .animation(.spring(response: 0.3), value: isHovering) } .buttonStyle(.plain) .onHover { isHovering = $0 } .help("\(wallpaper.resolution) • \(formatFileSize(wallpaper.fileSize))") .onAppear { loadImage() } } /// Maximum thumbnail pixel dimension. ImageIO uses this as the larger side /// of the downsampled output — the full-resolution JPEG/PNG is decoded /// through `CGImageSourceCreateThumbnailAtIndex` which is dramatically /// cheaper than `NSImage(contentsOf:)` for multi-megapixel wallpapers. private static let thumbnailMaxPixel: Int = 320 private func loadImage() { isLoadingImage = true let url = self.wallpaper.url let maxPixel = Self.thumbnailMaxPixel DispatchQueue.global(qos: .userInitiated).async { let loadedImage = LocalWallpaperItem.makeThumbnail(url: url, maxPixel: maxPixel) DispatchQueue.main.async { self.image = loadedImage self.isLoadingImage = false } } } /// Builds an `NSImage` thumbnail via ImageIO. Public/static so it can be /// exercised by tests without spinning up a SwiftUI view, and so the /// decoding path never falls back to the full-resolution `NSImage` /// initializer used previously. static func makeThumbnail(url: URL, maxPixel: Int) -> NSImage? { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: maxPixel, ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } let size = NSSize(width: cgImage.width, height: cgImage.height) return NSImage(cgImage: cgImage, size: size) } private func formatFileSize(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useKB, .useMB, .useGB] formatter.countStyle = .file return formatter.string(fromByteCount: bytes) } } // MARK: - Loading State struct LoadingState: View { var body: some View { VStack(spacing: 16) { ProgressView() Text("Loading wallpapers...") .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } }