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>
213 lines
7.8 KiB
Swift
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)
|
|
}
|
|
)
|
|
}
|
|
}
|