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) { 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) { // Auf Maß gebracht, bevor es in den Stapel kommt: ein // skalierbares Bild meldet seine **eigene** Größe als // Wunschgröße, und die tausend Bildpunkte der Vorlage haben den // Stapel auf tausend Punkte aufgeblasen. Notch, Menüleiste und // Auslösefläche saßen dann am Rand dieser unsichtbaren Fläche // und lagen außerhalb des Ausschnitts — das Bild war da, alles // andere weg. background .frame(width: width, height: height) .clipped() // 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: WallpaperLoader.load(own: own)) }.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 Pfad des eigenen Schreibtischhintergrunds. /// /// Liegt er in Schreibtisch, Dokumente oder Downloads, fragt macOS beim /// ersten Lesen einmal nach — deshalb wird hier nicht vorab geprüft, /// sondern gleich gelesen. Eine Prüfung mit `isReadableFile` beantwortet /// macOS in diesen Ordnern mit „nein", ohne zu fragen; die Vorschau blieb /// damit dauerhaft beim Ersatzbild, obwohl eine Freigabe möglich gewesen /// wäre. /// /// Wird die Freigabe verweigert, kommt ein Standardhintergrund von macOS. @MainActor private static func ownWallpaperURL() -> URL? { guard let screen = NSScreen.main else { return nil } return NSWorkspace.shared.desktopImageURL(for: screen) } } /// Lädt das Hintergrundbild der Vorschau. /// /// **Ein eigener Typ, kein Teil der View.** Was in einer `View` steht, ist an /// den Hauptthread gebunden — die Konformität vererbt das, auch an statische /// Methoden, die damit gar nichts zu tun haben. Aufgerufen wurde es trotzdem /// nebenher; der Compiler hat das nur angemahnt, und zur Laufzeit hat die /// Prüfung die App angehalten. Hier draußen gibt es die Bindung nicht. enum WallpaperLoader { static func load(own: URL?) -> NSImage? { own.flatMap(thumbnail(of:)) ?? systemWallpaper() } /// Ein Standardhintergrund von macOS. /// /// 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. static func systemWallpaper() -> NSImage? { let directory = "/System/Library/Desktop Pictures" let preferred = ["Tahoe", "Sequoia", "Sonoma", "Ventura", "Monterey", "Big Sur Mountains", "Catalina Coast"] // Zuerst die Originale, dann die kleinen Bilder aus `.thumbnails`. // // Die Originale sind zwanzig Megabyte groß, werden aber nie ganz // gelesen — `CGImageSource` dekodiert gleich in der gebrauchten Größe. // Die Vorschaubilder messen 214 Punkte in der Breite und wären auf // einem Retina-Schirm sichtbar unscharf; sie sind die Reserve für die // Motive, die macOS erst bei Bedarf nachlädt. for folder in [directory, directory + "/.thumbnails"] { let available = (try? FileManager.default.contentsOfDirectory(atPath: folder)) ?? [] 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: folder + "/" + match)) { return image } } } // Nichts Bekanntes dabei: dann das erste Bild, das sich lesen lässt. for folder in [directory, directory + "/.thumbnails"] { let available = ((try? FileManager.default.contentsOfDirectory(atPath: folder)) ?? []) .filter { $0.hasSuffix(".heic") }.sorted() for name in available { if let image = thumbnail(of: URL(fileURLWithPath: folder + "/" + 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. 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? }