Files
onyx/Onyx/SettingsView.swift
T
ScarriffleandClaude Opus 5 e3b215a1dd Ein eigener Sperrbildschirm — und ohne gespeichertes Passwort
Kameraaufnahme, Anlernen über drei Posen, Fenster je Bildschirm auf
Bildschirmschoner-Ebene, Passwortrückfall.

**Das Anmeldepasswort wird nicht gespeichert.** Ein Spike hat den Weg
freigeräumt: `ODRecord.verifyPassword` ist öffentliche API, die Eingabe geht
direkt hinein, das Ergebnis ist ein `Bool`, danach ist die Zeichenkette wieder
weg. Damit fällt der Grund weg, aus dem vergleichbare Programme sich das
Passwort geben lassen und in die Keychain legen.

Angelernt wird über drei Posen mit je fünf Aufnahmen. Der Merkmalsvektor ist ein
allgemeiner Bildmerkmalsvektor und kein Gesichtsmerkmal — er reagiert auf
Kopfhaltung und Beleuchtung mit. Eine einzige Aufnahme beschreibt genau eine
Haltung bei genau einem Licht.

Die Vektoren liegen in der Keychain, nicht in einer Datei: aus ihnen lässt sich
kein Bild zurückgewinnen, aber wer sie hat, kann prüfen, ob eine bestimmte
Person vor der Kamera stand.

Die Einschränkung steht auf dem Sperrbildschirm selbst und nicht im
Kleingedruckten: Bequemlichkeit, keine Sicherheitsgrenze; der Vergleich läuft
über die Webcam und lässt sich mit einem Foto täuschen. Wer sich darauf
verlässt, tut es dort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:22:32 +02:00

738 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import OnyxDesign
import OnyxNotch
import LockProvider
import OnyxWidgetKit
import CalendarProvider
import ShelfProvider
import OnyxMenuBar
import MetricsProvider
import OnyxHelperProtocol
import WeatherProvider
struct SettingsView: View {
@Bindable var model: AppModel
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let metricsModel: MetricsModel?
let shelfStore: ShelfStore?
let fanControl: FanControl
let lockController: LockController
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
/// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
@Binding var section: SettingsSection
var body: some View {
// **Zweiteilung von Hand, kein `NavigationSplitView`.**
//
// Der malt Seitenleiste und Inhaltsfläche selbst deckend zu. Die
// Vibrancy-Ebene des Fensters kam deshalb nur unter dem Titelbalken
// durch — oben durchsichtig, darunter eine Platte.
// `.scrollContentBackground(.hidden)` erreicht sie nicht: es gilt für
// Listen und Scrollflächen, nicht für den Rahmen darum.
//
// Gebraucht wird hier ohnehin keine Navigation, sondern eine Auswahl
// und zwei Spalten.
HStack(spacing: 0) {
sidebar
.frame(width: 200)
Divider()
content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
// Außerhalb eines `Form` zeichnet macOS jeden `Toggle` als
// Ankreuzfeld. In einer Karte mit Schaltern sieht das aus wie ein
// Formular von 2009 — und schlimmer: die beiden Formen bedeuten
// dasselbe und sehen verschieden aus.
.toggleStyle(.switch)
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
// wachsen, der Inhalt aber nicht mit.
.frame(minWidth: 760, minHeight: 480)
}
private var sidebar: some View {
ScrollView {
VStack(spacing: Onyx.Space.xxs) {
ForEach(SettingsSection.allCases) { entry in
Button { section = entry } label: {
OnyxSidebarLabel(entry.title, symbol: entry.symbol, tint: entry.tint)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.sm)
.background {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(section == entry ? AnyShapeStyle(Onyx.Color.accent)
: AnyShapeStyle(.clear))
}
// Auf der Akzentfarbe steht Weiß, nicht die
// Textfarbe des Systems — die wäre im Hellmodus
// schwarz auf kräftigem Blau.
.foregroundStyle(section == entry ? AnyShapeStyle(.white)
: AnyShapeStyle(.primary))
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.row)
}
.scrollContentBackground(.hidden)
}
@ViewBuilder
private var content: some View {
switch section {
case .general:
GeneralSettings(model: model, launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding)
case .widgets:
WidgetSettings(model: model)
case .widgetOptions:
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
shelfStore: shelfStore)
case .notch:
NotchSettings(model: model)
case .appearance:
AppearanceSettingsView(theme: OnyxTheme.shared)
case .menubar:
MenuBarSettings(model: model, metricsModel: metricsModel)
case .fans:
FanSettingsView(control: fanControl)
case .lockScreen:
LockSettingsView(model: model, controller: lockController)
case .permissions:
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
}
}
}
// MARK: - Allgemein
private struct GeneralSettings: View {
@Bindable var model: AppModel
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
var body: some View {
SettingsPage {
OnyxFormCard(String(localized: "settings.general.behaviour")) {
OnyxFormRow(String(localized: "settings.launchAtLogin")) {
Toggle("", isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { _ = launchAtLogin.set($0) }))
.labelsHidden()
.disabled(launchAtLogin.needsSystemSettings)
}
// Der Zustand, der sonst in eine Schleife führt: der Schalter
// steht auf aus, Drücken bewirkt nichts, weil das System den
// Eintrag blockiert. Also steht hier, wo es weitergeht.
if launchAtLogin.needsSystemSettings {
OnyxFormRow(String(localized: "settings.launchAtLogin.blocked")) {
Button("settings.action.open") {
launchAtLogin.openSystemSettings()
}
}
}
OnyxFormRow(String(localized: "settings.onboarding.title"),
hint: String(localized: "settings.onboarding.hint")) {
Button("settings.action.show", action: showOnboarding)
}
}
OnyxFormCard(String(localized: "settings.general.appearanceGroup")) {
OnyxFormRow(String(localized: "settings.display.menuBarIcon"),
hint: String(localized: "settings.display.menuBarIcon.hint")) {
Toggle("", isOn: $model.showsMenuBarIcon).labelsHidden()
}
OnyxFormRow(String(localized: "settings.display.dockIcon"),
hint: String(localized: "settings.display.dockIcon.hint")) {
Toggle("", isOn: $model.showsDockIcon).labelsHidden()
}
}
}
.task { launchAtLogin.refresh() }
}
}
// MARK: - Notch
private struct NotchSettings: View {
@Bindable var model: AppModel
var body: some View {
SettingsPage {
OnyxFormCard(String(localized: "settings.notch.displays")) {
OnyxFormRow(String(localized: "settings.display.policy"),
hint: String(localized: "settings.display.hint")) {
Picker("", selection: $model.displayPolicy) {
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
Text("settings.display.mainDisplay").tag(DisplayPolicy.mainDisplay)
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
}
.labelsHidden()
.frame(width: 200)
}
OnyxFormRow(String(localized: "settings.notch.style"),
hint: String(localized: "settings.notch.style.hint")) {
Picker("", selection: $model.notchStyle) {
Text("settings.notch.style.automatic").tag(NotchStyle.automatic)
Text("settings.notch.style.notch").tag(NotchStyle.notch)
Text("settings.notch.style.island").tag(NotchStyle.island)
}
.labelsHidden()
.frame(width: 200)
}
OnyxFormRow(String(localized: "settings.island.show"),
hint: String(localized: "settings.island.hint")) {
Toggle("", isOn: $model.showsIdleIsland).labelsHidden()
}
}
OnyxFormCard(String(localized: "settings.band"),
hint: String(localized: "settings.band.hint")) {
OnyxFormRow(String(localized: "settings.band.leading")) {
Picker("", selection: $model.bandLeading) {
ForEach(BandAccessory.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
}
.labelsHidden()
.frame(width: 200)
}
OnyxFormRow(String(localized: "settings.band.trailing")) {
Picker("", selection: $model.bandTrailing) {
ForEach(BandAccessory.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
}
.labelsHidden()
.frame(width: 200)
}
}
NotchWidthSettings(model: model)
OnyxFormCard(String(localized: "settings.notch.triggerArea")) {
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
// niemandem etwas, ein nachgebildeter Bildschirm schon.
TriggerAreaSettings(trigger: $model.triggerArea)
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.bottom, OnyxForm.rowPadding)
}
}
}
}
// MARK: - Berechtigungen
/// Zeigt den Zustand, statt ihn nur spürbar zu machen.
///
/// Eine verweigerte Berechtigung äußert sich sonst als leeres Widget, und der
/// Weg zurück ist nicht offensichtlich: hat macOS die Anfrage einmal
/// abgelehnt bekommen, fragt es nie wieder — es bleibt nur der Gang in die
/// Systemeinstellungen.
private struct PermissionSettings: View {
let calendarModel: CalendarModel
let weatherModel: WeatherModel
@State private var calendarGranted = false
private var locationState: PermissionRow.State {
switch weatherModel.locationAuthorization {
case .granted: .granted
case .denied: .denied
case .undetermined: .undetermined
}
}
var body: some View {
SettingsPage {
OnyxFormCard(String(localized: "settings.permissions.group")) {
PermissionRow(
title: String(localized: "settings.permissions.calendar"),
symbol: "calendar",
state: calendarGranted ? .granted
: (calendarModel.authorization == .denied ? .denied : .undetermined),
explanation: String(localized: "settings.permissions.calendar.why"),
pane: .calendar,
request: { calendarGranted = await calendarModel.requestAccessNow() })
PermissionRow(
title: String(localized: "settings.permissions.location"),
symbol: "location",
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
// sich, während dieses Fenster offen steht.
state: locationState,
explanation: String(localized: "settings.permissions.location.why"),
pane: .location,
request: { weatherModel.requestLocationAuthorization() })
}
}
.task {
calendarGranted = calendarModel.hasAccess
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
// Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung
// wirkt asynchron.
try? await Task.sleep(for: .milliseconds(400))
calendarGranted = await calendarModel.requestAccessNow()
}
}
}
/// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt.
///
/// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist
/// die erste Fassung gescheitert: „noch nie gefragt" lässt sich mit einer
/// Anfrage lösen, „abgelehnt" nur über die Systemeinstellungen, und „erteilt"
/// braucht gar nichts.
private struct PermissionRow: View {
enum State { case granted, denied, undetermined }
enum Pane {
case calendar, location
/// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer
/// Knopf, der immer im Kalender landet, ist schlimmer als keiner.
var url: URL? {
switch self {
case .calendar:
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")
case .location:
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices")
}
}
}
let title: String
let symbol: String
let state: State
let explanation: String
let pane: Pane
let request: () async -> Void
var body: some View {
OnyxFormRow(title, hint: explanation, symbol: symbol) {
switch state {
case .granted:
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
// Akzentstil färbt sonst darüber. Grün heißt hier „erteilt"
// und ist keine Geschmacksfrage.
Label {
Text("settings.permissions.granted")
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
}
.foregroundStyle(Onyx.Color.positive)
.labelStyle(.titleAndIcon)
case .undetermined:
Button("settings.permissions.request") { Task { await request() } }
case .denied:
// macOS fragt nach einer Ablehnung nicht mehr — ein
// Anfragen-Knopf wäre hier eine Lüge.
Button("settings.permissions.openSystem") {
if let url = pane.url { NSWorkspace.shared.open(url) }
}
}
}
}
}
// MARK: - Menüleiste
/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
///
/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
/// Zumutung, und die Menüleiste ist ohnehin knapp — auf einem Notch-Display
/// blockiert die Kamera zusätzlich die Mitte.
private struct MenuBarSettings: View {
@Bindable var model: AppModel
let metricsModel: MetricsModel?
private static let modules: [(id: String, label: String.LocalizationValue, symbol: String)] = [
("metric.cpu", "metric.cpu", "cpu"),
("metric.gpu", "metric.gpu", "cpu.fill"),
("metric.memory", "metric.memory", "memorychip"),
("metric.battery", "metric.battery", "battery.100"),
("metric.temperature", "metric.sensors", "thermometer"),
("network", "network.name", "network"),
("audio", "mixer.menubar.name", "slider.horizontal.3"),
("fans", "widget.fans.name", "fan"),
]
var body: some View {
SettingsPage {
Text("settings.menubar.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
OnyxFormCard {
ForEach(Self.modules, id: \.id) { module in
ModuleRow(model: model, id: module.id,
label: module.label, symbol: module.symbol,
metricsModel: metricsModel)
}
}
}
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
.id(model.menuBarRevision)
}
}
private struct ModuleRow: View {
@Bindable var model: AppModel
let id: String
let label: String.LocalizationValue
let symbol: String
let metricsModel: MetricsModel?
/// Welche Darstellungen dieses Modul beherrscht.
static func presentations(for id: String) -> [MenuBarPresentation] {
switch id {
// Ein Mischpult hat keinen einzelnen Messwert.
case "audio": []
// Drehzahl als Zahl oder nur das Symbol — ein Verlauf der Drehzahl
// sagt in 28 Punkten Breite nichts.
case "fans": [.value, .symbol]
// Der Akku kann alles, und gerade dort will man Symbol oder Kürzel:
// eine nackte Prozentzahl neben vier anderen sagt nichts.
case "metric.battery": [.symbolAndValue, .labelAndValue, .value, .symbol,
.graph, .valueAndGraph]
default: MenuBarPresentation.allCases
}
}
/// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk.
private var metricKind: MetricKind? {
guard id.hasPrefix("metric.") else { return nil }
return MetricKind(rawValue: String(id.dropFirst("metric.".count)))
}
var body: some View {
let settings = model.menuBarSettings(for: id)
OnyxFormRow(String(localized: label), symbol: symbol) {
HStack(spacing: Onyx.Space.md) {
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort
// das stille Maximum über alle Sensoren — eine Zahl, von der
// niemand weiß, woher sie kommt.
if id == "metric.temperature", let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.selectedSensorKey ?? "" },
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
Text("settings.menubar.sensor.hottest").tag("")
ForEach(metricsModel.snapshot.sensors) { sensor in
Text(sensor.name).tag(sensor.key)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide
// Werte stammen aus derselben Messung — ein zweites Element in
// der ohnehin knappen Menüleiste wäre Verschwendung.
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.displayMode(for: metric) },
set: { metricsModel.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
}
.labelsHidden()
.frame(width: 110)
.disabled(!settings.isEnabled)
}
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
// keinen einzelnen Messwert, den man als Zahl oder Verlauf
// zeigen könnte — eine Auswahl ohne Wirkung wäre eine Falle.
if !Self.presentations(for: id).isEmpty {
Picker("", selection: Binding(
get: { settings.presentation },
set: { presentation in
var updated = settings
updated.presentation = presentation
model.setMenuBarSettings(updated, for: id)
})) {
ForEach(Self.presentations(for: id)) { presentation in
Text(presentation.localizedName).tag(presentation)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
Toggle("", isOn: Binding(
get: { settings.isEnabled },
set: { enabled in
var updated = settings
updated.isEnabled = enabled
model.setMenuBarSettings(updated, for: id)
}))
.labelsHidden()
}
}
}
}
extension MenuBarPresentation {
var localizedName: LocalizedStringKey {
switch self {
case .value: "settings.menubar.value"
case .graph: "settings.menubar.graph"
case .bars: "settings.menubar.bars"
case .valueAndGraph: "settings.menubar.valueAndGraph"
case .symbol: "settings.menubar.symbol"
case .labelAndValue: "settings.menubar.labelAndValue"
case .symbolAndValue: "settings.menubar.symbolAndValue"
}
}
}
// MARK: - Widgets
private struct WidgetSettings: View {
@Bindable var model: AppModel
/// Welches Widget rechts gezeigt wird.
///
/// Der Name allein sagt nicht, was eine Kachel zeigt — „Sensoren" kann
/// vieles heißen. Und ausprobieren hieß bisher: hinzufügen, Notch
/// anfahren, ansehen, wieder entfernen.
@State private var previewID: String?
var body: some View {
HStack(spacing: 0) {
// Eine Liste, keine Karte: das Ziehen zum Umsortieren kommt von
// `List.onMove`. Und **nur** die Liste, ohne Rahmen darum — eine
// Liste in einer Spalte mit eigenem Rand lief unter den
// Fensterknöpfen hindurch, weil sie ihre eigene Scrollfläche
// mitbringt und die dann zwei Ränder gegeneinander schieben.
List(selection: $previewID) {
Section {
// Die Reihenfolge hier ist die Reihenfolge im Panel: die
// Layout-Engine setzt die Widgets von oben links der Reihe
// nach ein.
ForEach(model.layout) { placement in
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
PlacementRow(model: model, placement: placement, widget: widget)
.tag(placement.widgetID)
}
}
.onMove { model.move(fromOffsets: $0, toOffset: $1) }
if model.layout.isEmpty {
Text("settings.widgets.empty")
.foregroundStyle(.secondary)
}
} header: {
Text("settings.widgets.inPanel")
} footer: {
Text("settings.widgets.hintShort")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) }
if !available.isEmpty {
Section("settings.widgets.available") {
ForEach(available, id: \.id) { widget in
HStack {
Label(widget.displayName, systemImage: widget.symbolName)
Spacer()
Button("settings.widgets.add") { model.add(widget) }
.buttonStyle(.borderless)
}
.tag(widget.id)
}
}
}
}
.listStyle(.inset)
.scrollContentBackground(.hidden)
Divider()
WidgetPreview(widgetID: previewID)
.frame(width: 252)
}
.task {
// Beim Öffnen gleich etwas zeigen: eine leere Vorschau neben einer
// vollen Liste sieht aus, als wäre sie kaputt.
guard previewID == nil else { return }
previewID = model.layout.first?.widgetID ?? WidgetRegistry.shared.all.first?.id
}
}
}
/// Die gewählte Kachel, so wie sie in der Notch aussieht.
///
/// Echte Größe (180 × 200) auf echter Paneloberfläche und mit den echten
/// Modellen — eine verkleinerte oder erfundene Vorschau wäre eine Behauptung.
/// Dieselben Schritte wie in `WidgetGrid`: Kachel, Rahmen, beschneiden.
private struct WidgetPreview: View {
let widgetID: String?
var body: some View {
VStack(spacing: Onyx.Space.row) {
if let widgetID, let widget = WidgetRegistry.shared.widget(id: widgetID) {
OnyxTile { widget.makeView() }
.frame(width: LayoutEngine.tileSize.width,
height: LayoutEngine.tileSize.height)
.clipped()
.padding(Onyx.Metric.panelPadding)
// Die Fläche bleibt schwarzer Stein, auch wenn das Fenster
// hell ist — sie zeigt die Notch, nicht das Fenster.
.background(OnyxSurface(cornerRadius: Onyx.Radius.tile))
.tint(Onyx.Color.accent)
VStack(spacing: Onyx.Space.xxs) {
Text(widget.displayName)
.font(.callout)
Text("settings.widgets.preview.hint")
.font(.caption)
.foregroundStyle(.secondary)
}
.multilineTextAlignment(.center)
} else {
Text("settings.widgets.preview.none")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(OnyxForm.pagePadding)
}
}
private struct PlacementRow: View {
@Bindable var model: AppModel
let placement: WidgetPlacement
let widget: any OnyxWidget
var body: some View {
HStack {
Label(widget.displayName, systemImage: widget.symbolName)
Spacer()
Button {
model.remove(placement.widgetID)
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
.help("settings.widgets.remove")
}
}
}
// MARK: - Seite
/// Der Rahmen eines Bereichs, der aus Karten besteht.
///
/// Die `ScrollView` sitzt hier und nicht im Fenster: Bereiche mit einer `List`
/// oder einem `Form` bringen ihre eigene mit, und zwei ineinander ergeben eine
/// Fläche, die beim Ziehen mal die eine und mal die andere bewegt.
struct SettingsPage<Content: View>: View {
private let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: OnyxForm.cardSpacing) {
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(OnyxForm.pagePadding)
}
.scrollContentBackground(.hidden)
}
}
/// Die Inselbreite, je Bildschirm.
///
/// **Nur Breite, keine Höhe.** Die Höhe ist nicht mehr frei: bei verschmolzener
/// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste, damit
/// die Insel darin liegt statt darunter herauszuhängen. Ein Höhenregler wäre
/// ein Bedienelement, das nichts tut.
///
/// Bildschirme mit echter Notch bekommen keinen Regler, sondern die Auskunft,
/// warum es keinen gibt — sonst sucht man den Fehler bei sich.
private struct NotchWidthSettings: View {
@Bindable var model: AppModel
var body: some View {
OnyxFormCard(String(localized: "settings.notch.width"),
hint: String(localized: "settings.notch.width.hint")) {
ForEach(NSScreen.screens, id: \.displayID) { screen in
row(for: screen)
}
}
}
@ViewBuilder
private func row(for screen: NSScreen) -> some View {
let id = screen.displayID
// Dieselbe Frage, die auch die Geometrie stellt — und dieselbe Antwort.
let hasHardware = NotchGeometry.hardwareRect(screen.onyxMetrics) != nil
let merged = model.notchStyle.merges(withHardware: hasHardware)
OnyxFormRow(screen.localizedName,
hint: merged ? String(localized: "settings.notch.width.hardware") : nil) {
if merged {
Text("settings.notch.width.fixed")
.foregroundStyle(.secondary)
} else {
HStack(spacing: Onyx.Space.lg) {
Slider(value: binding(for: id),
in: NotchCoordinator.widthRange, step: 2)
.frame(width: 180)
Text(verbatim: "\(Int(model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width))")
.font(.body.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .trailing)
Button {
model.setNotchWidth(nil, for: id)
} label: {
Image(systemName: "arrow.counterclockwise")
}
.buttonStyle(.borderless)
.disabled(model.notchWidth(for: id) == nil)
.help(Text("settings.notch.width.reset"))
}
}
}
}
private func binding(for id: CGDirectDisplayID) -> Binding<CGFloat> {
Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width },
set: { model.setNotchWidth($0, for: id) })
}
}