Auslösefläche: die Vorschau zeigt wieder einen Bildschirm
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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ImageIO
|
||||
import OnyxDesign
|
||||
|
||||
/// Einstellung der Auslösefläche — mit Vorschau.
|
||||
@@ -60,13 +61,15 @@ public struct TriggerAreaSettings: View {
|
||||
|
||||
/// Der nachgebildete Bildschirm.
|
||||
///
|
||||
/// Mit dem echten Schreibtischhintergrund: Apples Bilder lassen sich nicht
|
||||
/// mitliefern — Lizenz, und ein Tahoe-Hintergrund wiegt zweistellige Megabyte.
|
||||
/// Der eigene ist ohnehin der bessere Anker, weil man genau ihn wiedererkennt.
|
||||
/// Fehlt er, bleibt ein Verlauf in Onyx-Farben.
|
||||
/// 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
|
||||
@@ -84,7 +87,7 @@ private struct TriggerPreview: View {
|
||||
let areaHeight = min(notchHeight + trigger.extraHeight * scale, height)
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
wallpaper
|
||||
background
|
||||
|
||||
// Die Menüleiste als heller Streifen: sie erklärt, warum die
|
||||
// Fläche nicht beliebig breit werden sollte.
|
||||
@@ -117,11 +120,30 @@ private struct TriggerPreview: View {
|
||||
.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 wallpaper: some View {
|
||||
if let image = Self.desktopImage {
|
||||
private var background: some View {
|
||||
if let image = wallpaper {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
@@ -131,14 +153,84 @@ private struct TriggerPreview: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Schreibtischhintergrund des Hauptbildschirms.
|
||||
/// Der eigene Schreibtischhintergrund — wenn er sich denn lesen lässt.
|
||||
///
|
||||
/// Einmal geladen und behalten: es ist eine Datei von mehreren Megabyte,
|
||||
/// und die Vorschau zeichnet sich bei jedem Zug am Regler neu.
|
||||
private static let desktopImage: NSImage? = {
|
||||
/// 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)
|
||||
let url = NSWorkspace.shared.desktopImageURL(for: screen),
|
||||
FileManager.default.isReadableFile(atPath: url.path)
|
||||
else { return nil }
|
||||
return NSImage(contentsOf: url)
|
||||
}()
|
||||
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?
|
||||
}
|
||||
|
||||
@@ -1,155 +1,169 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"weather.error.timeout": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zeitüberschreitung beim Abruf"
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"↑ %@" : {
|
||||
"comment" : "A label that shows the high temperature. The argument is the high temperature.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "↑ %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Request timed out"
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "↑ %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"weather.feelsLike %@": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "gefühlt %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "feels like %@"
|
||||
"↑ %@ ↓ %@" : {
|
||||
"comment" : "Temperaturbereich.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "↑ %1$@ ↓ %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.weather.asOf": {
|
||||
"comment": "%@ ist die Uhrzeit des Messwerts",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Stand %@"
|
||||
"↓ %@" : {
|
||||
"comment" : "A label that reads \"low\" in the weather app.",
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "↓ %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "As of %@"
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "↓ %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.weather.locationDenied": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Kein Standortzugriff.\nOrt in den Einstellungen wählen."
|
||||
"weather.error.timeout" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zeitüberschreitung beim Abruf"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No location access.\nPick a place in settings."
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Request timed out"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.weather.locationUndetermined": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Standort noch nicht erlaubt.\nIn den Einstellungen freigeben."
|
||||
"weather.feelsLike %@" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "gefühlt %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Location not allowed yet.\nGrant it in settings."
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "feels like %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.weather.name": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wetter"
|
||||
"widget.weather.asOf" : {
|
||||
"comment" : "%@ ist die Uhrzeit des Messwerts",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Stand %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Weather"
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "As of %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.weather.unavailable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wetter nicht abrufbar"
|
||||
"widget.weather.locationDenied" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kein Standortzugriff.\nOrt in den Einstellungen wählen."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Weather unavailable"
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No location access.\nPick a place in settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"↑ %@": {
|
||||
"comment": "A label that shows the high temperature. The argument is the high temperature.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "↑ %@"
|
||||
"widget.weather.locationUndetermined" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Standort noch nicht erlaubt.\nIn den Einstellungen freigeben."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "↑ %@"
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Location not allowed yet.\nGrant it in settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"↓ %@": {
|
||||
"comment": "A label that reads \"low\" in the weather app.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "↓ %@"
|
||||
"widget.weather.name" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Wetter"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "↓ %@"
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Weather"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.weather.unavailable" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Wetter nicht abrufbar"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Weather unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.1"
|
||||
"version" : "1.1"
|
||||
}
|
||||
Reference in New Issue
Block a user