Dein Hintergrundbild liegt in ~/Desktop, und dorthin lässt macOS keine App ohne Freigabe. NSWorkspace gibt den Pfad trotzdem heraus, NSImage gibt danach still nil zurück — und die Vorschau blieb schwarz, ohne jeden Hinweis worauf. Gemessen: isReadableFile meldet false, das Laden schlägt fehl. Um eine Ordnerfreigabe zu bitten, nur um ein Vorschaubildchen zu malen, wäre die falsche Frage. Also wird jetzt vorher geprüft, ob sich die Datei lesen lässt, und wenn nicht, kommt ein Standardhintergrund von macOS — /System/Library/Desktop Pictures, von neu nach alt durchgesehen, hier landet es bei Sonoma. Die Bilder liegen auf jedem Mac und werden nur gelesen. Geladen über CGImageSource statt NSImage: die Vorlagen sind 6016 Punkte im Quadrat und zwanzig Megabyte schwer, gebraucht werden tausend Punkte. Und ein Hintergrundbild kann mehrere Bilder enthalten, hell und dunkel im selben HEIC. Das Dekodieren dauert gut hundert Millisekunden, deshalb läuft es neben dem Zeichnen statt davor — sonst hätte das Fenster beim Öffnen so lange gestanden. Danach liegt das Bild im Cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
9.9 KiB
Swift
237 lines
9.9 KiB
Swift
import SwiftUI
|
|
import ImageIO
|
|
import OnyxDesign
|
|
|
|
/// Einstellung der Auslösefläche — mit Vorschau.
|
|
///
|
|
/// Zwei Zahlen in Punkten sagen niemandem etwas: „24 Punkte unter der Notch"
|
|
/// ist keine Vorstellung, die man im Kopf hat. Ein nachgebildeter Bildschirm
|
|
/// mit der Notch und der eingefärbten Fläche darüber schon — man sieht sofort,
|
|
/// wie weit man greifen muss.
|
|
public struct TriggerAreaSettings: View {
|
|
|
|
@Binding private var trigger: TriggerArea
|
|
|
|
public init(trigger: Binding<TriggerArea>) {
|
|
self._trigger = trigger
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("trigger.hint", bundle: .module)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
TriggerPreview(trigger: trigger)
|
|
.frame(height: 150)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
LabeledContent {
|
|
Slider(value: Binding(
|
|
get: { trigger.extraWidth },
|
|
set: { trigger = TriggerArea(extraWidth: $0,
|
|
extraHeight: trigger.extraHeight) }),
|
|
in: 0...TriggerArea.maximumExtraWidth)
|
|
} label: {
|
|
Text("trigger.width \(Int(trigger.extraWidth))", bundle: .module)
|
|
.monospacedDigit()
|
|
}
|
|
|
|
LabeledContent {
|
|
Slider(value: Binding(
|
|
get: { trigger.extraHeight },
|
|
set: { trigger = TriggerArea(extraWidth: trigger.extraWidth,
|
|
extraHeight: $0) }),
|
|
in: 0...TriggerArea.maximumExtraHeight)
|
|
} label: {
|
|
Text("trigger.height \(Int(trigger.extraHeight))", bundle: .module)
|
|
.monospacedDigit()
|
|
}
|
|
|
|
Button {
|
|
trigger = .standard
|
|
} label: {
|
|
Text("trigger.reset", bundle: .module)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Der nachgebildete Bildschirm.
|
|
///
|
|
/// Mit einem echten Hintergrundbild, damit man auf einen Blick sieht, dass hier
|
|
/// ein Mac-Bildschirm gemeint ist und nicht irgendein Rechteck. Zuerst der
|
|
/// eigene Schreibtischhintergrund — den erkennt man am schnellsten wieder —,
|
|
/// sonst einer von macOS.
|
|
private struct TriggerPreview: View {
|
|
let trigger: TriggerArea
|
|
|
|
@State private var wallpaper: NSImage?
|
|
|
|
/// Maße eines MacBook Pro mit Notch, auf die Vorschau heruntergerechnet.
|
|
private let screenAspect: CGFloat = 1800 / 1169
|
|
private let notchWidthFraction: CGFloat = 220 / 1800
|
|
private let notchHeightFraction: CGFloat = 38 / 1169
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let height = min(geometry.size.height, geometry.size.width / screenAspect)
|
|
let width = height * screenAspect
|
|
let scale = width / 1800
|
|
|
|
let notchWidth = width * notchWidthFraction
|
|
let notchHeight = height * notchHeightFraction
|
|
let areaWidth = min(notchWidth + 2 * trigger.extraWidth * scale, width)
|
|
let areaHeight = min(notchHeight + trigger.extraHeight * scale, height)
|
|
|
|
ZStack(alignment: .top) {
|
|
background
|
|
|
|
// Die Menüleiste als heller Streifen: sie erklärt, warum die
|
|
// Fläche nicht beliebig breit werden sollte.
|
|
Rectangle()
|
|
.fill(.black.opacity(0.35))
|
|
.frame(height: notchHeight)
|
|
.frame(maxWidth: .infinity, alignment: .top)
|
|
|
|
// Die Auslösefläche.
|
|
Rectangle()
|
|
.fill(Onyx.Color.accent.opacity(0.35))
|
|
.overlay { Rectangle().strokeBorder(Onyx.Color.accent, lineWidth: 1) }
|
|
.frame(width: areaWidth, height: areaHeight)
|
|
|
|
// Die Notch selbst — undurchsichtig, wie in echt.
|
|
UnevenRoundedRectangle(bottomLeadingRadius: 5, bottomTrailingRadius: 5,
|
|
style: .continuous)
|
|
.fill(.black)
|
|
.frame(width: notchWidth, height: notchHeight)
|
|
}
|
|
.frame(width: width, height: height)
|
|
// Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und
|
|
// Fläche gemeinsam ab, sonst ragt etwas über die Ecken hinaus.
|
|
.clipShape(.rect(cornerRadius: 8, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.animation(Onyx.Motion.value, value: areaWidth)
|
|
.animation(Onyx.Motion.value, value: areaHeight)
|
|
}
|
|
// Nicht beim Zeichnen laden: das Dekodieren dauert gut hundert
|
|
// Millisekunden, und die hätte das Fenster beim Öffnen gestanden.
|
|
.task { await loadWallpaper() }
|
|
}
|
|
|
|
private func loadWallpaper() async {
|
|
if let cached = Self.cache { wallpaper = cached.image; return }
|
|
// Den Pfad auf dem Hauptthread bestimmen — `NSScreen` und
|
|
// `NSWorkspace` gehören dorthin —, das Bild daneben.
|
|
let own = Self.ownWallpaperURL()
|
|
let loaded = await Task.detached(priority: .userInitiated) {
|
|
Wallpaper(image: own.flatMap { Self.thumbnail(of: $0) } ?? Self.systemWallpaper())
|
|
}.value
|
|
Self.cache = loaded
|
|
wallpaper = loaded.image
|
|
}
|
|
|
|
/// Einmal geladen und behalten: die Vorschau zeichnet sich bei jedem Zug am
|
|
/// Regler neu.
|
|
@MainActor private static var cache: Wallpaper?
|
|
|
|
@ViewBuilder
|
|
private var background: some View {
|
|
if let image = wallpaper {
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
LinearGradient(colors: [Onyx.Color.elevated, Onyx.Color.surface],
|
|
startPoint: .top, endPoint: .bottom)
|
|
}
|
|
}
|
|
|
|
/// Der eigene Schreibtischhintergrund — wenn er sich denn lesen lässt.
|
|
///
|
|
/// Die Prüfung ist der eigentliche Punkt: liegt das Bild in Schreibtisch,
|
|
/// Dokumente oder Downloads, verweigert macOS den Zugriff, bis der Nutzer
|
|
/// den Ordner freigibt. `NSWorkspace` liefert den Pfad trotzdem, und
|
|
/// `NSImage` gibt dann still `nil` zurück — die Vorschau blieb schwarz, und
|
|
/// zwar ohne jeden Hinweis, warum. Genau dieser Fall lag hier vor.
|
|
///
|
|
/// Um eine Ordnerfreigabe zu bitten, nur um ein Vorschaubildchen zu malen,
|
|
/// wäre die falsche Frage. Also: lesbar oder nicht, und sonst weiter.
|
|
@MainActor
|
|
private static func ownWallpaperURL() -> URL? {
|
|
guard let screen = NSScreen.main,
|
|
let url = NSWorkspace.shared.desktopImageURL(for: screen),
|
|
FileManager.default.isReadableFile(atPath: url.path)
|
|
else { return nil }
|
|
return url
|
|
}
|
|
|
|
/// Ein Standardhintergrund von macOS.
|
|
///
|
|
/// Die Vorschaubilder aus `.thumbnails` statt der Originale: dieselben
|
|
/// Motive in ein paar Dutzend Kilobyte statt in zwanzig Megabyte. Sie
|
|
/// liegen auf jedem Mac und werden nur gelesen, nicht mitgeliefert.
|
|
///
|
|
/// Die Reihenfolge geht von neu nach alt und endet bei „irgendein Foto":
|
|
/// welche Bilder installiert sind, hängt an der macOS-Version.
|
|
private static func systemWallpaper() -> NSImage? {
|
|
let directory = "/System/Library/Desktop Pictures"
|
|
let preferred = ["Tahoe", "Sequoia", "Sonoma", "Ventura", "Monterey",
|
|
"Big Sur Mountains", "Catalina Coast"]
|
|
|
|
let thumbnails = directory + "/.thumbnails"
|
|
let available = (try? FileManager.default.contentsOfDirectory(atPath: thumbnails)) ?? []
|
|
|
|
for name in preferred {
|
|
// „Sonoma.heic" vor „Sonoma Dark.heic": das schlichte Motiv ist das
|
|
// bekannte, die Varianten sind Abwandlungen davon.
|
|
let matches = available
|
|
.filter { $0.hasPrefix(name) && $0.hasSuffix(".heic") }
|
|
.sorted { $0.count < $1.count }
|
|
if let match = matches.first,
|
|
let image = thumbnail(of: URL(fileURLWithPath: thumbnails + "/" + match)) {
|
|
return image
|
|
}
|
|
}
|
|
|
|
// Nichts Bekanntes dabei: dann das erste Bild, das sich lesen lässt.
|
|
for name in available.sorted() where name.hasSuffix(".heic") {
|
|
if let image = thumbnail(of: URL(fileURLWithPath: thumbnails + "/" + name)) {
|
|
return image
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Lädt ein verkleinertes Bild.
|
|
///
|
|
/// Über `CGImageSource` statt `NSImage(contentsOf:)`: die Vorschau ist ein
|
|
/// paar hundert Bildpunkte breit, das Original hat sechstausend. Und ein
|
|
/// Hintergrundbild kann mehrere Bilder enthalten — hell und dunkel im
|
|
/// selben HEIC —, von denen hier nur das erste gebraucht wird.
|
|
private static func thumbnail(of url: URL) -> NSImage? {
|
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: 1000,
|
|
]
|
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
|
|
else { return nil }
|
|
return NSImage(cgImage: image, size: .init(width: image.width, height: image.height))
|
|
}
|
|
}
|
|
|
|
/// `NSImage` über eine Aufgabengrenze zu reichen ist nicht von Haus aus
|
|
/// zugesichert. Hier ist es unbedenklich: das Bild wird einmal erzeugt und
|
|
/// danach nur noch gelesen.
|
|
private struct Wallpaper: @unchecked Sendable {
|
|
let image: NSImage?
|
|
}
|