import SwiftUI import AppKit import QuickLookThumbnailing import OnyxDesign import OnyxWidgetKit /// Vorschaubilder, einmal erzeugt und behalten. /// /// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei /// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei /// jedem Tastendruck ein neuer Auftrag laufen. @MainActor @Observable final class ThumbnailCache { private var images: [String: NSImage] = [:] private var pending: Set = [] func image(for item: ShelfItem, size: CGFloat) -> NSImage? { if let cached = images[item.id] { return cached } guard !pending.contains(item.id) else { return nil } pending.insert(item.id) let request = QLThumbnailGenerator.Request( fileAt: item.url, size: CGSize(width: size, height: size), scale: NSScreen.main?.backingScaleFactor ?? 2, representationTypes: .all) QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, _ in guard let rep else { return } let image = NSImage(cgImage: rep.cgImage, size: CGSize(width: size, height: size)) Task { @MainActor [weak self] in self?.images[item.id] = image self?.pending.remove(item.id) } } return nil } } /// Die Ablage. public struct ShelfView: View { private let store: ShelfStore private let compact: Bool @State private var isTargeted = false @State private var thumbnails = ThumbnailCache() public init(store: ShelfStore, compact: Bool = false) { self.store = store self.compact = compact } public var body: some View { VStack(alignment: .leading, spacing: 6) { if store.items.isEmpty { EmptyShelf() } else { ScrollView { LazyVStack(alignment: .leading, spacing: 4) { ForEach(store.items.prefix(compact ? 4 : 20)) { item in ShelfRow(item: item, store: store, thumbnails: thumbnails) } } } .scrollIndicators(.never) HStack(spacing: 10) { Button { store.removeAll() } label: { Text("shelf.clear", bundle: .module).font(Onyx.Font.caption) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textSecondary) Button { store.revealInFinder() } label: { Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textSecondary) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin. .contentShape(.rect) .onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) } .overlay { if isTargeted { RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])) .background(Onyx.Color.accent.opacity(0.08), in: .rect(cornerRadius: 10, style: .continuous)) .allowsHitTesting(false) } } .animation(Onyx.Motion.value, value: isTargeted) } } private struct EmptyShelf: View { var body: some View { VStack(alignment: .leading, spacing: 4) { Image(systemName: "tray") .font(.system(size: 16)) .foregroundStyle(Onyx.Color.textTertiary) Text("shelf.empty", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } private struct ShelfRow: View { let item: ShelfItem let store: ShelfStore let thumbnails: ThumbnailCache @State private var isHovered = false var body: some View { HStack(spacing: 8) { thumbnail VStack(alignment: .leading, spacing: 1) { Text(item.name) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textPrimary) .lineLimit(1) .truncationMode(.middle) Text(item.size.formatted(.byteCount(style: .file))) .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textTertiary) } Spacer(minLength: 0) if isHovered { Button { AirDrop.send([item.url]) } label: { Image(systemName: "shareplay").font(.system(size: 10)) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textTertiary) Button { store.remove(item) } label: { Image(systemName: "xmark.circle.fill").font(.system(size: 11)) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textTertiary) } } .padding(.vertical, 2) .contentShape(.rect) .onHover { isHovered = $0 } // `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres // reicht die URL als Text weiter, und im Finder landet eine // Internetverknüpfung statt der Datei. .onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() } .onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) } .contextMenu { Button { NSWorkspace.shared.open(item.url) } label: { Text("shelf.open", bundle: .module) } Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: { Text("shelf.reveal", bundle: .module) } Button { AirDrop.send([item.url]) } label: { Text("shelf.airdrop", bundle: .module) } Divider() Button(role: .destructive) { store.remove(item) } label: { Text("shelf.delete", bundle: .module) } } } @ViewBuilder private var thumbnail: some View { if let image = thumbnails.image(for: item, size: 22) { Image(nsImage: image) .resizable().aspectRatio(contentMode: .fit) .frame(width: 22, height: 22) .clipShape(.rect(cornerRadius: 3, style: .continuous)) } else { RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(Onyx.Color.elevated) .frame(width: 22, height: 22) } } } // MARK: - Widget public struct ShelfWidget: OnyxWidget { public let id = "shelf" public var displayName: String { String(localized: "shelf.name", bundle: .module) } public let symbolName = "tray.full" public let supportedSizes: [WidgetSize] = [.medium, .large] private let store: ShelfStore public init(store: ShelfStore) { self.store = store } public func makeView(size: WidgetSize) -> AnyView { AnyView(ShelfView(store: store, compact: size == .medium)) } }