Kacheln und Fenster sprechen dieselbe Sprache
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b600de9a9
commit
edb327cabe
+404
-252
@@ -9,10 +9,6 @@ import MetricsProvider
|
||||
import OnyxHelperProtocol
|
||||
import WeatherProvider
|
||||
|
||||
enum SettingsTab: Hashable {
|
||||
case widgets, display, colors, sources, menubar, fans, permissions
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var model: AppModel
|
||||
let calendarModel: CalendarModel
|
||||
@@ -22,87 +18,215 @@ struct SettingsView: View {
|
||||
let fanControl: FanControl
|
||||
let launchAtLogin: LaunchAtLogin
|
||||
let showOnboarding: () -> Void
|
||||
/// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes
|
||||
/// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
|
||||
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
|
||||
@Binding var selectedTab: SettingsTab
|
||||
|
||||
/// Die Reiter in der Reihenfolge, in der sie oben stehen.
|
||||
private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [
|
||||
(.widgets, "settings.tab.widgets"),
|
||||
(.display, "settings.tab.display"),
|
||||
(.colors, "settings.tab.colors"),
|
||||
(.sources, "settings.tab.options"),
|
||||
(.menubar, "settings.tab.menubar"),
|
||||
(.fans, "settings.tab.fans"),
|
||||
(.permissions, "settings.tab.permissions"),
|
||||
]
|
||||
@Binding var section: SettingsSection
|
||||
|
||||
var body: some View {
|
||||
// Eigene Reiterleiste statt `TabView`.
|
||||
// **Zweiteilung von Hand, kein `NavigationSplitView`.**
|
||||
//
|
||||
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
|
||||
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf.
|
||||
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
|
||||
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
|
||||
VStack(spacing: 0) {
|
||||
Picker("", selection: $selectedTab) {
|
||||
ForEach(Self.tabs, id: \.tab) { entry in
|
||||
Text(entry.label).tag(entry.tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund
|
||||
// 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch
|
||||
// dann, wenn das Fenster schmaler war — sie ragte dann links und
|
||||
// rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt
|
||||
// dafür, dass hier trotzdem nichts gestaucht wird.
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 14)
|
||||
// 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)
|
||||
}
|
||||
// Eine Fläche für alles.
|
||||
//
|
||||
// Vorher hatte jeder Bereich seinen eigenen Untergrund: die
|
||||
// Formulare bringen einen mit, der Rest des Fensters hat den des
|
||||
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
|
||||
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
|
||||
//
|
||||
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
|
||||
// View — SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
|
||||
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
|
||||
// galt, während der Farbwähler daneben schon die neue zeigte.
|
||||
.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: 740, minHeight: 460)
|
||||
.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 selectedTab {
|
||||
switch section {
|
||||
case .general:
|
||||
GeneralSettings(model: model, launchAtLogin: launchAtLogin,
|
||||
showOnboarding: showOnboarding)
|
||||
case .widgets:
|
||||
WidgetSettings(model: model, shelfStore: shelfStore)
|
||||
case .display:
|
||||
DisplaySettings(model: model)
|
||||
case .colors:
|
||||
ColorSettingsView(theme: OnyxTheme.shared)
|
||||
case .sources:
|
||||
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,
|
||||
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,8 +242,6 @@ struct SettingsView: View {
|
||||
private struct PermissionSettings: View {
|
||||
let calendarModel: CalendarModel
|
||||
let weatherModel: WeatherModel
|
||||
let launchAtLogin: LaunchAtLogin
|
||||
let showOnboarding: () -> Void
|
||||
@State private var calendarGranted = false
|
||||
|
||||
private var locationState: PermissionRow.State {
|
||||
@@ -131,67 +253,29 @@ private struct PermissionSettings: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("settings.general") {
|
||||
Toggle(isOn: Binding(
|
||||
get: { launchAtLogin.isEnabled },
|
||||
set: { _ = launchAtLogin.set($0) })) {
|
||||
Text("settings.launchAtLogin")
|
||||
}
|
||||
.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 {
|
||||
HStack {
|
||||
Text("settings.launchAtLogin.blocked")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
Button("settings.permissions.openSystem") {
|
||||
launchAtLogin.openSystemSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("settings.onboarding.show", action: showOnboarding)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
SettingsPage {
|
||||
OnyxFormCard(String(localized: "settings.permissions.group")) {
|
||||
PermissionRow(
|
||||
title: "settings.permissions.calendar",
|
||||
title: String(localized: "settings.permissions.calendar"),
|
||||
symbol: "calendar",
|
||||
state: calendarGranted ? .granted
|
||||
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
||||
explanation: "settings.permissions.calendar.why",
|
||||
explanation: String(localized: "settings.permissions.calendar.why"),
|
||||
pane: .calendar,
|
||||
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
||||
}
|
||||
|
||||
Section {
|
||||
PermissionRow(
|
||||
title: "settings.permissions.location",
|
||||
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: "settings.permissions.location.why",
|
||||
explanation: String(localized: "settings.permissions.location.why"),
|
||||
pane: .location,
|
||||
request: { weatherModel.requestLocationAuthorization() })
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding()
|
||||
.task {
|
||||
launchAtLogin.refresh()
|
||||
calendarGranted = calendarModel.hasAccess
|
||||
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
|
||||
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
|
||||
@@ -226,17 +310,15 @@ private struct PermissionRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
let title: LocalizedStringKey
|
||||
let title: String
|
||||
let symbol: String
|
||||
let state: State
|
||||
let explanation: LocalizedStringKey
|
||||
let explanation: String
|
||||
let pane: Pane
|
||||
let request: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Label(title, systemImage: symbol)
|
||||
Spacer()
|
||||
OnyxFormRow(title, hint: explanation, symbol: symbol) {
|
||||
switch state {
|
||||
case .granted:
|
||||
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
||||
@@ -260,10 +342,6 @@ private struct PermissionRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(explanation)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +356,7 @@ private struct MenuBarSettings: View {
|
||||
@Bindable var model: AppModel
|
||||
let metricsModel: MetricsModel?
|
||||
|
||||
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
|
||||
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"),
|
||||
@@ -290,22 +368,20 @@ private struct MenuBarSettings: View {
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsPage {
|
||||
Text("settings.menubar.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
List {
|
||||
OnyxFormCard {
|
||||
ForEach(Self.modules, id: \.id) { module in
|
||||
ModuleRow(model: model, id: module.id,
|
||||
label: module.label, symbol: module.symbol,
|
||||
metricsModel: metricsModel)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.padding()
|
||||
// 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)
|
||||
@@ -315,7 +391,7 @@ private struct MenuBarSettings: View {
|
||||
private struct ModuleRow: View {
|
||||
@Bindable var model: AppModel
|
||||
let id: String
|
||||
let label: LocalizedStringKey
|
||||
let label: String.LocalizationValue
|
||||
let symbol: String
|
||||
let metricsModel: MetricsModel?
|
||||
|
||||
@@ -344,69 +420,68 @@ private struct ModuleRow: View {
|
||||
var body: some View {
|
||||
let settings = model.menuBarSettings(for: id)
|
||||
|
||||
HStack {
|
||||
Toggle(isOn: Binding(
|
||||
get: { settings.isEnabled },
|
||||
set: { enabled in
|
||||
var updated = settings
|
||||
updated.isEnabled = enabled
|
||||
model.setMenuBarSettings(updated, for: id)
|
||||
})) {
|
||||
Label(label, systemImage: symbol)
|
||||
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)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 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)
|
||||
// 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: 150)
|
||||
.disabled(!settings.isEnabled)
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 110)
|
||||
.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)
|
||||
}
|
||||
|
||||
// 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
|
||||
Toggle("", isOn: Binding(
|
||||
get: { settings.isEnabled },
|
||||
set: { enabled in
|
||||
var updated = settings
|
||||
updated.presentation = presentation
|
||||
updated.isEnabled = enabled
|
||||
model.setMenuBarSettings(updated, for: id)
|
||||
})) {
|
||||
ForEach(Self.presentations(for: id)) { presentation in
|
||||
Text(presentation.localizedName).tag(presentation)
|
||||
}
|
||||
}
|
||||
}))
|
||||
.labelsHidden()
|
||||
.frame(width: 150)
|
||||
.disabled(!settings.isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,22 +505,30 @@ extension MenuBarPresentation {
|
||||
|
||||
private struct WidgetSettings: View {
|
||||
@Bindable var model: AppModel
|
||||
let shelfStore: ShelfStore?
|
||||
|
||||
/// 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 {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("settings.widgets.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
List {
|
||||
Section("settings.widgets.inPanel") {
|
||||
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) }
|
||||
@@ -454,6 +537,13 @@ private struct WidgetSettings: View {
|
||||
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) }
|
||||
@@ -466,47 +556,69 @@ private struct WidgetSettings: View {
|
||||
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
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
/// Einstellungen der Ablage.
|
||||
/// Die gewählte Kachel, so wie sie in der Notch aussieht.
|
||||
///
|
||||
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
|
||||
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
|
||||
/// Papierkorb mit Zeitschaltuhr.
|
||||
private struct ShelfSettingsRows: View {
|
||||
@Bindable var store: ShelfStore
|
||||
|
||||
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
||||
/// 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 {
|
||||
Picker(selection: $store.autoCleanupDays) {
|
||||
ForEach(Self.choices, id: \.self) { days in
|
||||
if let days {
|
||||
Text("settings.shelf.days \(days)").tag(Optional(days))
|
||||
} else {
|
||||
Text("settings.shelf.never").tag(Int?.none)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("settings.shelf.cleanup")
|
||||
}
|
||||
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)
|
||||
|
||||
HStack {
|
||||
Text("settings.shelf.count \(store.items.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("settings.shelf.reveal") { store.revealInFinder() }
|
||||
.buttonStyle(.borderless)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,50 +644,90 @@ private struct PlacementRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Anzeige
|
||||
// MARK: - Seite
|
||||
|
||||
private struct DisplaySettings: View {
|
||||
/// 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 {
|
||||
Form {
|
||||
Picker("settings.display.policy", selection: $model.displayPolicy) {
|
||||
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
|
||||
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
|
||||
OnyxFormCard(String(localized: "settings.notch.width"),
|
||||
hint: String(localized: "settings.notch.width.hint")) {
|
||||
ForEach(NSScreen.screens, id: \.displayID) { screen in
|
||||
row(for: screen)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
Text("settings.display.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
|
||||
// niemandem etwas, ein nachgebildeter Bildschirm schon.
|
||||
TriggerAreaSettings(trigger: $model.triggerArea)
|
||||
|
||||
Divider()
|
||||
|
||||
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
|
||||
Text("settings.display.menuBarIcon.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
|
||||
Text("settings.display.dockIcon.hint")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
||||
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding()
|
||||
}
|
||||
|
||||
@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) })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user