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>
268 lines
11 KiB
Swift
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?
|
|
}
|