Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Scarriffle 152d36c904 Vorschau: Notch zurück, und der eigene Hintergrund wird wirklich versucht
Die Notch verschwand, sobald zum ersten Mal ein Bild geladen wurde: 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. Vorher fiel es nicht auf, weil nie ein Bild
ankam. Das Bild wird jetzt auf Maß gebracht, bevor es in den Stapel geht.

Und der eigentliche Wunsch: dein Hintergrundbild statt eines Ersatzes. Es
liegt auf dem Schreibtisch, und dorthin fragt macOS beim ersten Lesen einmal
nach. Die Prüfung mit isReadableFile, die ich vorher davorgesetzt hatte,
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. Jetzt wird gelesen statt geprüft, mit den drei nötigen Erklärungen in
der Info.plist. Wird die Freigabe verweigert, bleibt es beim Systembild.

Ein Tahoe-Hintergrund als Ersatz ist nicht drin: unter den Systembildern liegt
davon nur „Tahoe Day.mov", ein Video von 170 Megabyte. Deshalb weiter Sonoma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:54:11 +02:00

268 lines
11 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) {
// 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?
}