Files
onyx/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:51 +02:00

213 lines
7.8 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: Onyx.Space.sm) {
if store.items.isEmpty {
EmptyShelf()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
ShelfRow(item: item, store: store, thumbnails: thumbnails)
}
}
}
.scrollIndicators(.never)
HStack(spacing: Onyx.Space.row) {
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) }
// Die Kachel nimmt nebenbei Dateien an — die Kante erscheint deshalb
// erst, wenn wirklich etwas darüber schwebt.
.onyxDropZone(isTargeted: isTargeted, style: .whenTargeted)
}
}
private struct EmptyShelf: View {
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
Image(systemName: "tray")
.font(.system(size: Onyx.Symbol.large))
.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: Onyx.Space.md) {
thumbnail
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
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: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.padding(.vertical, Onyx.Space.xxs)
.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: Onyx.Radius.bar, style: .continuous))
} else {
RoundedRectangle(cornerRadius: Onyx.Radius.bar, 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: Onyx.Space.md) {
// 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)
OnyxHairline()
ShelfView(store: store)
}
)
}
}