Files
onyx/Onyx/SettingsView.swift
T
ScarriffleandClaude Opus 5 fd29df51cf Im Band ist Platz für mehr als einen Wert
Die Begründung für „ein Wert je Seite" war falsch gerechnet: der Arm ist bei
fünf Kacheln je 376 Punkte breit, und ein Wert braucht davon knapp sechzig. Die
Zahl im Kopf war die der schmalen Insel, nicht die des ausgefahrenen Arms.

Jetzt bis zu vier Elemente je Seite, in wählbarer Reihenfolge — Uhr, Wetter,
Akku, CPU, Speicher, Temperatur. Kein Ziehen zum Umsortieren: bei höchstens vier
Einträgen ist Herausnehmen und wieder Hinzufügen schneller als eine Ziehgeste,
die man erst finden muss.

„Nichts" ist keine Wahl mehr, sondern die leere Liste — zwei Wege, dasselbe zu
sagen, waren einer zu viel.

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

778 lines
31 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")) {
BandEditor(items: $model.bandLeading)
}
OnyxFormRow(String(localized: "settings.band.trailing")) {
BandEditor(items: $model.bandTrailing)
}
}
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) })
}
}
/// Die Elemente einer Bandseite: was drin ist, in welcher Reihenfolge, und was
/// sich hinzufügen lässt.
///
/// Kein Ziehen zum Umsortieren. Die Liste hat höchstens vier Einträge, und für
/// vier Einträge ist Herausnehmen und wieder Hinzufügen schneller als jede
/// Ziehgeste — die man außerdem erst finden muss.
private struct BandEditor: View {
@Binding var items: [BandAccessory]
private var available: [BandAccessory] {
BandAccessory.allCases.filter { !items.contains($0) }
}
var body: some View {
HStack(spacing: Onyx.Space.sm) {
ForEach(items, id: \.self) { item in
HStack(spacing: Onyx.Space.xs) {
Text(item.localizedName)
.font(Onyx.Font.footnote)
Button {
items.removeAll { $0 == item }
} label: {
Image(systemName: "xmark")
.font(.system(size: Onyx.Symbol.caption))
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, Onyx.Space.sm)
.padding(.vertical, Onyx.Space.xs)
.background(.quaternary, in: .capsule)
}
if items.count < AppModel.bandLimit, !available.isEmpty {
Menu {
ForEach(available, id: \.self) { candidate in
Button { items.append(candidate) } label: {
Text(candidate.localizedName)
}
}
} label: {
Image(systemName: "plus")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
}
}
}
}