Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Scarriffle a2be84f4d5 Absturz beim Laden des Hintergrundbilds — und derselbe Fehler in der Ablage
Was in einer View steht, ist an den Hauptthread gebunden: die Konformität zu
`View` vererbt das, auch an statische Methoden, die damit nichts zu tun haben.
Das Laden lief absichtlich nebenher — und lief damit in die Isolationsprüfung,
die das Programm anhält. Der Compiler hat genau das gemeldet:

  warning: call to main actor-isolated static method 'systemWallpaper()' in a
  synchronous nonisolated context
  note: main actor isolation inferred from conformance to protocol 'View'

Eine Warnung, keine Fehlermeldung. Ich hatte den Bau nur nach `error:`
durchsucht und sie deshalb nicht gesehen.

Das Laden liegt jetzt in einem eigenen Typ neben der View, wo es die Bindung
nicht gibt. Dieselbe Suche über den ganzen Bau hat einen zweiten Fall
gefunden: `FileDropView.stage` wird aus dem Abschlussblock von
`loadFileRepresentation` gerufen, also von irgendeinem Thread, gehört aber zu
einem `ViewModifier`. Das hätte jeden Dateiabwurf auf die Ablage angehalten.

Dazu die Verbesserung, die beim letzten Mal wegen eines falschen
Arbeitsverzeichnisses nicht angekommen ist: gesucht wird zuerst unter den
Originalen und erst danach in `.thumbnails`. Die kleinen Bilder messen 214
Punkte in der Breite und sind auf einem Retina-Schirm sichtbar unscharf.

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

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