Files
onyx/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
Scarriffle d7125e28f6 Ablage und AirDrop teilen sich eine Kachel
Zwei eigene Kacheln waren zu viel des Guten: beide zeigen im Ruhezustand
nichts als eine leere Fläche mit einem Satz darin, und zusammen nahmen sie
ein Drittel des Panels.

Jetzt übereinander in einer Kachel — oben die Ablegefläche fürs Senden,
darunter die Ablage mit ihrer Liste. AirDrop steht dabei nebeneinander statt
übereinander: in 52 Punkten Höhe ist für Symbol über Text kein Platz.

Das eigenständige AirDrop-Widget entfällt. Gespeicherte Layouts, die es noch
nennen, verlieren den Eintrag beim Laden — dafür ist der Filter da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:42:39 +02:00

220 lines
8.0 KiB
Swift

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<String> = []
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) { @Sendable 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
/// Ablage und AirDrop in **einer** Kachel.
///
/// Zwei eigene Kacheln waren zu viel des Guten: beide zeigen im Ruhezustand
/// nichts als eine leere Fläche mit einem Satz darin, und zusammen nahmen sie
/// ein Drittel des Panels. Übereinander in einer Kachel bleibt beides
/// erreichbar und kostet halb so viel Platz.
public struct ShelfWidget: OnyxWidget {
public let id = "shelf"
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
public let symbolName = "tray.full"
private let store: ShelfStore
public init(store: ShelfStore) { self.store = store }
public func makeView() -> AnyView {
AnyView(
VStack(spacing: 8) {
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
AirDropView(compact: true)
.frame(height: 52)
Divider().overlay(Onyx.Color.hairline)
ShelfView(store: store)
}
)
}
}