Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine eigene Anmeldung, kein Netz, kein Schreiben zurück. Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49 Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang nicht abgedeckt ist. Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung. Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie geöffnet zu haben knipst den Apple-Kalender nicht aus. Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot, und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt die Fläche UTType.item statt public.file-url an: solche Anhänge kommen als Dateiversprechen, und loadFileRepresentation löst beides ein. Die übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort sofort gesichert statt später auf dem MainActor. Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit Zeitschaltuhr. Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer im Hauptbundle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.4 KiB
Swift
204 lines
7.4 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) { 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))
|
|
}
|
|
}
|