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>
220 lines
8.0 KiB
Swift
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)
|
|
}
|
|
)
|
|
}
|
|
}
|