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>
738 lines
30 KiB
Swift
738 lines
30 KiB
Swift
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) })
|
||
}
|
||
}
|