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) { 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? }