Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl. Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz auf schwarz endet. Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient, bis jede Kachel einmal zu sehen war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
734 lines
30 KiB
Swift
734 lines
30 KiB
Swift
import SwiftUI
|
||
import OnyxDesign
|
||
import OnyxNotch
|
||
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 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 .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) })
|
||
}
|
||
}
|