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:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
+85
View File
@@ -20,6 +20,9 @@ final class AppModel {
var onLayoutChanged: (([WidgetPlacement]) -> Void)?
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
var onShowsIdleIslandChanged: ((Bool) -> Void)?
var onNotchStyleChanged: ((NotchStyle) -> Void)?
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
var layout: [WidgetPlacement] {
didSet {
@@ -37,6 +40,68 @@ final class AppModel {
}
}
/// Abweichende Inselbreiten, je Bildschirm — leer heißt „Standardbreite".
///
/// Als Wörterbuch über die `CGDirectDisplayID` und nicht als Liste über den
/// Index in `NSScreen.screens`: der Index ändert sich beim Umstecken, die
/// Kennung nicht. Sonst gälte die Einstellung des einen Monitors plötzlich
/// für den anderen.
private(set) var notchWidths: [CGDirectDisplayID: CGFloat] = [:]
/// Die eingestellte Breite dieses Bildschirms, falls es eine gibt.
func notchWidth(for displayID: CGDirectDisplayID) -> CGFloat? {
notchWidths[displayID]
}
/// Setzt eine abweichende Breite — oder nimmt sie mit `nil` zurück.
func setNotchWidth(_ width: CGFloat?, for displayID: CGDirectDisplayID) {
if let width { notchWidths[displayID] = width } else { notchWidths[displayID] = nil }
// Als Zeichenkette gespeichert, weil `UserDefaults` keine Wörterbücher
// mit Zahlenschlüsseln kann.
let raw = notchWidths.reduce(into: [String: Double]()) { $0[String($1.key)] = Double($1.value) }
defaults.set(raw, forKey: Keys.notchWidths)
onNotchWidthsChanged?(notchWidths)
}
/// Was links neben der Notch steht.
var bandLeading: BandAccessory {
didSet {
guard bandLeading != oldValue else { return }
defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading)
}
}
/// Was rechts daneben steht.
var bandTrailing: BandAccessory {
didSet {
guard bandTrailing != oldValue else { return }
defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing)
}
}
/// Notch oder Insel — die Form, die Onyx oben annimmt.
var notchStyle: NotchStyle {
didSet {
guard notchStyle != oldValue else { return }
defaults.set(notchStyle.rawValue, forKey: Keys.notchStyle)
onNotchStyleChanged?(notchStyle)
}
}
/// Ob die eingeklappte Insel sichtbar stehen bleibt.
///
/// Standardmäßig an: ohne sie wächst das Panel auf externen Bildschirmen
/// aus dem Nichts. Abschaltbar, weil ein schwarzer Balken auf vier
/// Monitoren eine Geschmacksfrage ist und keine Voreinstellung sein darf,
/// von der man nicht wegkommt.
var showsIdleIsland: Bool {
didSet {
guard showsIdleIsland != oldValue else { return }
defaults.set(showsIdleIsland, forKey: Keys.showsIdleIsland)
onShowsIdleIslandChanged?(showsIdleIsland)
}
}
var showsMenuBarIcon: Bool {
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
}
@@ -86,6 +151,11 @@ final class AppModel {
private enum Keys {
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy"
static let showsIdleIsland = "onyx.showsIdleIsland"
static let notchStyle = "onyx.notchStyle"
static let notchWidths = "onyx.notchWidths"
static let bandLeading = "onyx.band.leading"
static let bandTrailing = "onyx.band.trailing"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon"
static let triggerArea = "onyx.triggerArea"
@@ -101,6 +171,21 @@ final class AppModel {
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
.flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
self.notchStyle = defaults.string(forKey: Keys.notchStyle)
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic
// Die bisherige feste Verdrahtung als Voreinstellung: wer nichts
// einstellt, sieht, was vorher auch dastand.
self.bandLeading = defaults.string(forKey: Keys.bandLeading)
.flatMap(BandAccessory.init(rawValue:)) ?? .weather
self.bandTrailing = defaults.string(forKey: Keys.bandTrailing)
.flatMap(BandAccessory.init(rawValue:)) ?? .battery
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
guard let id = CGDirectDisplayID(entry.key) else { return }
result[id] = CGFloat(entry.value)
}
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
// zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
+198
View File
@@ -0,0 +1,198 @@
import SwiftUI
import OnyxDesign
/// Was am Aussehen einstellbar ist: die Farben und die Bewegung.
///
/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet,
/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst
/// nicht zur Wahl — Gelb heißt Warnung, Rot heißt kritisch, und wer das
/// umstellen kann, kann die Bedeutung zerstören.
///
/// Die Bewegung kommt als **Profil**, nicht als Federkonstanten. Ausfahren und
/// Einfahren müssen zueinander passen; diese Beziehung wählt man, nicht vier
/// Zahlen.
struct AppearanceSettingsView: View {
@Bindable var theme: OnyxTheme
/// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss.
///
/// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung
/// darauf sticht, statt zu leiten.
private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [
("color.suggestion.onyx", "#7FA8FF"),
("color.suggestion.mint", "#6FCF97"),
("color.suggestion.amber", "#E8B44A"),
("color.suggestion.coral", "#E5675C"),
("color.suggestion.lilac", "#B79CFF"),
("color.suggestion.slate", "#9AA6B2"),
]
var body: some View {
SettingsPage {
accent
network
material
motion
fixed
}
}
private var accent: some View {
OnyxFormCard(String(localized: "color.section.accent")) {
OnyxFormRow(String(localized: "color.accent"),
hint: String(localized: "color.accent.hint")) {
ColorPicker("", selection: $theme.accent, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.suggestions")) {
HStack(spacing: Onyx.Space.row) {
ForEach(Self.suggestions, id: \.hex) { suggestion in
Swatch(hex: suggestion.hex,
name: suggestion.name,
isSelected: theme.accent.onyxHex == suggestion.hex) {
theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent
}
}
}
}
// Mit dem Farbfleck daneben: welche Farbe das System gerade führt,
// weiß man nicht auswendig — und dann wirkt das Ergebnis wie ein
// Fehler statt wie eine Übernahme.
OnyxFormRow(String(localized: "color.adoptSystem")) {
Button { theme.adoptSystemAccent() } label: {
HStack(spacing: Onyx.Space.sm) {
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
Text("settings.action.adopt")
}
}
}
}
}
private var network: some View {
OnyxFormCard(String(localized: "color.section.network")) {
OnyxFormRow(String(localized: "color.download"),
hint: String(localized: "color.network.hint")) {
ColorPicker("", selection: $theme.download, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.upload")) {
ColorPicker("", selection: $theme.upload, supportsOpacity: false)
.labelsHidden()
}
OnyxFormRow(String(localized: "color.swap")) {
Button("settings.action.swap") {
let previous = theme.download
theme.download = theme.upload
theme.upload = previous
}
}
}
}
private var material: some View {
OnyxFormCard(String(localized: "settings.appearance.material"),
hint: String(localized: "settings.appearance.material.hint")) {
OnyxFormRow(String(localized: "settings.appearance.surface")) {
Picker("", selection: $theme.material) {
ForEach(NotchMaterial.allCases, id: \.self) { material in
Text(material.localizedName).tag(material)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 240)
}
}
}
private var motion: some View {
OnyxFormCard(String(localized: "settings.appearance.motion"),
hint: String(localized: "settings.appearance.motion.hint")) {
OnyxFormRow(String(localized: "settings.appearance.profile")) {
Picker("", selection: $theme.motion) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.localizedName).tag(profile)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 240)
}
}
}
private var fixed: some View {
OnyxFormCard(String(localized: "color.section.fixed"),
hint: String(localized: "color.fixed.hint")) {
// Sichtbar, aber nicht bedienbar: sonst sucht man die Warnfarbe
// hier und findet sie nirgends.
OnyxFormRow(String(localized: "color.fixed.positive")) {
Circle().fill(Onyx.Color.positive).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.fixed.warning")) {
Circle().fill(Onyx.Color.warning).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.fixed.critical")) {
Circle().fill(Onyx.Color.critical).frame(width: 14, height: 14)
}
OnyxFormRow(String(localized: "color.reset")) {
Button("settings.action.reset") { theme.reset() }
.disabled(theme.isDefault)
}
}
}
}
extension NotchMaterial {
var localizedName: LocalizedStringKey {
switch self {
case .stone: "settings.material.stone"
case .blur: "settings.material.blur"
case .glass: "settings.material.glass"
}
}
}
extension MotionProfile {
var localizedName: LocalizedStringKey {
switch self {
case .snappy: "settings.motion.snappy"
case .bouncy: "settings.motion.bouncy"
case .calm: "settings.motion.calm"
}
}
}
/// Ein Farbfeld zum Anklicken.
private struct Swatch: View {
let hex: String
let name: LocalizedStringKey
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(Color(onyxHex: hex) ?? .gray)
.frame(width: 20, height: 20)
.overlay {
// Der Ring statt eines Hakens: ein Haken auf einer dunklen
// Farbe ist nicht zu sehen, auf einer hellen nicht auf
// einer dunklen Fläche.
Circle()
.strokeBorder(.primary, lineWidth: isSelected ? 2 : 0)
.padding(-3)
}
}
.buttonStyle(.plain)
.help(Text(name))
}
}
+39 -45
View File
@@ -12,7 +12,7 @@ struct FanSettingsView: View {
@Bindable var control: FanControl
var body: some View {
Form {
SettingsPage {
switch control.installState {
case .notInstalled, .failed:
installSection
@@ -23,16 +23,13 @@ struct FanSettingsView: View {
// Das Ladelimit hängt am selben Helfer wie die Lüfter — beides
// ist Hardwaresteuerung über den SMC, und beides ist erst
// erreichbar, wenn der Helfer läuft.
Section("charge.section") {
OnyxFormCard(String(localized: "charge.section")) {
ChargeLimitControl(control: control, roomy: true)
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.bottom, OnyxForm.rowPadding)
}
}
}
.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()
.onAppear {
control.refreshInstallState()
control.start()
@@ -43,37 +40,33 @@ struct FanSettingsView: View {
// MARK: - Einrichtung
private var installSection: some View {
Section {
Text("fans.intro")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
OnyxFormCard(String(localized: "fans.group.setup"),
hint: String(localized: "fans.intro")) {
// Das Risiko benennen, bevor jemand zustimmt — nicht danach.
Label("fans.warning", systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
Text("fans.safety")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
OnyxFormRow(String(localized: "fans.warning"),
hint: String(localized: "fans.safety"),
symbol: "exclamationmark.triangle.fill",
tone: .warning)
if case .failed(let message) = control.installState {
Text(message).font(.callout).foregroundStyle(Onyx.Color.critical)
OnyxFormRow(message, symbol: "xmark.octagon", tone: .critical)
}
Button("fans.install") { control.install() }
OnyxFormRow(String(localized: "fans.install")) {
Button("fans.install") { control.install() }
}
}
}
private var approvalSection: some View {
Section {
Label("fans.approval.needed", systemImage: "hand.raised")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
Button("fans.approval.open") { control.openApprovalSettings() }
Button("fans.approval.recheck") { control.refreshInstallState() }
OnyxFormCard(String(localized: "fans.group.setup")) {
OnyxFormRow(String(localized: "fans.approval.needed"),
symbol: "hand.raised") {
Button("fans.approval.open") { control.openApprovalSettings() }
}
OnyxFormRow(String(localized: "fans.action.recheck")) {
Button("fans.action.recheck") { control.refreshInstallState() }
}
}
}
@@ -82,14 +75,13 @@ struct FanSettingsView: View {
@ViewBuilder
private var controlSection: some View {
if let report = control.report {
Section {
OnyxFormCard(String(localized: "fans.group.control")) {
if let hottest = report.hottestCelsius {
HStack {
Label("fans.hottest", systemImage: "thermometer")
Spacer()
OnyxFormRow(String(localized: "fans.hottest"), symbol: "thermometer") {
Text("\(Int(hottest))°").monospacedDigit()
.foregroundStyle(hottest >= FanSafety.criticalCelsius
? Onyx.Color.critical : .secondary)
? AnyShapeStyle(Onyx.Color.critical)
: AnyShapeStyle(.secondary))
}
}
ForEach(report.fans, id: \.index) { fan in
@@ -97,22 +89,21 @@ struct FanSettingsView: View {
}
}
Section {
Text("fans.safety.active")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("fans.uninstall", role: .destructive) {
Task { await control.uninstall() }
OnyxFormCard(String(localized: "fans.group.danger"),
hint: String(localized: "fans.safety.active")) {
OnyxFormRow(String(localized: "fans.uninstall")) {
Button("settings.action.remove", role: .destructive) {
Task { await control.uninstall() }
}
}
}
} else {
Section {
HStack {
OnyxFormCard(String(localized: "fans.group.control")) {
OnyxFormRow(String(localized: "fans.connecting")) {
ProgressView().controlSize(.small)
Text("fans.connecting").foregroundStyle(.secondary)
}
if let error = control.lastError {
Text(error).font(.callout).foregroundStyle(Onyx.Color.critical)
OnyxFormRow(error, symbol: "xmark.octagon", tone: .critical)
}
}
}
@@ -127,7 +118,7 @@ private struct FanRow: View {
@State private var editing = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
HStack {
Label(name, systemImage: "fan")
Spacer()
@@ -173,6 +164,9 @@ private struct FanRow: View {
.fixedSize(horizontal: false, vertical: true)
}
}
// Die Karte polstert nur `OnyxFormRow`; dieser Block ist ein eigener.
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.vertical, Onyx.Space.row)
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
}
+26 -52
View File
@@ -16,7 +16,7 @@ struct FanView: View {
let roomy: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: Onyx.Space.row) {
switch control.installState {
case .installed:
if let report = control.report, !report.fans.isEmpty {
@@ -24,10 +24,10 @@ struct FanView: View {
FanRow(control: control, index: index, fan: fan, roomy: roomy)
}
if let hottest = report.hottestCelsius {
Divider().overlay(Onyx.Color.hairline)
HStack(spacing: 5) {
OnyxHairline()
HStack(spacing: Onyx.Space.sm) {
Image(systemName: "thermometer.medium")
.font(.system(size: 10))
.font(.system(size: Onyx.Symbol.small))
Text("\(Int(hottest.rounded())) °C")
.font(Onyx.Font.metricSmall).monospacedDigit()
Spacer(minLength: 0)
@@ -35,14 +35,24 @@ struct FanView: View {
.foregroundStyle(Onyx.Color.textTertiary)
}
} else {
notice("fans.noData", symbol: "fan")
OnyxNotice(symbol: "fan",
text: String(localized: "fans.noData"),
placement: .leading)
}
case .requiresApproval:
notice("fans.approval.needed", symbol: "hand.raised") {
control.openApprovalSettings()
OnyxNotice(symbol: "hand.raised",
text: String(localized: "fans.approval.needed"),
placement: .leading) {
OnyxPillButton(String(localized: "fans.install")) {
control.openApprovalSettings()
}
}
case .notInstalled:
notice("fans.notInstalled", symbol: "fan") { control.install() }
OnyxNotice(symbol: "fan",
text: String(localized: "fans.notInstalled"),
placement: .leading) {
OnyxPillButton(String(localized: "fans.install")) { control.install() }
}
case .failed(let reason):
Text(reason)
.font(Onyx.Font.caption)
@@ -54,29 +64,6 @@ struct FanView: View {
.onAppear { control.refreshInstallState() }
}
@ViewBuilder
private func notice(_ key: LocalizedStringKey, symbol: String,
action: (() -> Void)? = nil) -> some View {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 15, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary)
Text(key)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if let action {
Button(action: action) {
Text("fans.install")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
}
}
}
}
private struct FanRow: View {
@@ -86,8 +73,8 @@ private struct FanRow: View {
let roomy: Bool
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
HStack(spacing: Onyx.Space.sm) {
Text("fans.fan \(fan.index + 1)")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
@@ -98,29 +85,16 @@ private struct FanRow: View {
.foregroundStyle(Onyx.Color.textPrimary)
}
HStack(spacing: 8) {
HStack(spacing: Onyx.Space.md) {
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
// aus wie ein Link und nicht wie ein Zustand.
Button { control.setAutomatic(index: index) } label: {
Text("fans.auto")
.font(Onyx.Font.metricSmall)
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
: Onyx.Color.surface)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background {
Capsule()
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
.overlay {
Capsule().strokeBorder(
fan.isManual ? Onyx.Color.hairline : .clear,
lineWidth: 1)
}
}
OnyxPillButton(String(localized: "fans.auto"),
isFilled: !fan.isManual,
size: .compact) {
control.setAutomatic(index: index)
}
.buttonStyle(.plain)
.disabled(!fan.isManual)
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
@@ -184,7 +158,7 @@ final class FanMenuBarModule: MenuBarModule {
func makePopoverView() -> AnyView {
AnyView(FanView(control: control, roomy: true)
.padding(14)
.padding(Onyx.Space.xl)
.frame(width: 260))
}
+568
View File
@@ -0,0 +1,568 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die
/// Notch anzufahren.
///
/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt
/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene
/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf
/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese
/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also
/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher
/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle
/// statt erfundener.
///
/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug,
/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten
/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu
/// übersetzen.
struct GalleryView: View {
let model: AppModel
/// Bleibt stehen, wo man zuletzt war.
///
/// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt
/// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den
/// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne
/// Zwischenklick.
@AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles
var body: some View {
VStack(spacing: 0) {
Picker("", selection: $section) {
ForEach(GallerySection.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, Onyx.Space.window)
.padding(.top, Onyx.Space.row)
.padding(.bottom, Onyx.Space.xl)
Divider()
ScrollView {
switch section {
case .tiles: TileGallery()
case .panel: PanelGallery(model: model)
case .components: ComponentGallery()
case .tokens: TokenGallery()
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
.frame(minWidth: 760, minHeight: 520)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
}
}
enum GallerySection: String, CaseIterable, Identifiable {
case tiles, panel, components, tokens
var id: Self { self }
var title: String {
switch self {
case .tiles: "Kacheln"
case .panel: "Panel"
case .components: "Bauteile"
case .tokens: "Token"
}
}
}
// MARK: - Kacheln
/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche.
///
/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in
/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200
/// auf schwarzem Stein ist der Ort, an dem sie später steht.
private struct TileGallery: View {
private struct Entry: Identifiable {
let id: String
let widget: any OnyxWidget
}
var body: some View {
let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) }
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
if entries.isEmpty {
Text("Keine Kacheln angemeldet.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width,
maximum: LayoutEngine.tileSize.width),
spacing: Onyx.Metric.tileSpacing)],
alignment: .leading,
spacing: Onyx.Space.xxl
) {
ForEach(entries) { entry in
VStack(spacing: Onyx.Space.sm) {
// Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel,
// dann Rahmen, dann beschneiden. Wer erst
// beschneidet, sieht Überlauf nicht, den das Panel
// später zeigt.
OnyxTile { entry.widget.makeView() }
.frame(width: LayoutEngine.tileSize.width,
height: LayoutEngine.tileSize.height)
.clipped()
Text(entry.id)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Panel
/// Das ganze Panel, so breit, wie man es einstellt.
///
/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine`
/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen,
/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen,
/// ohne dafür einen Bildschirm anzustecken.
private struct PanelGallery: View {
let model: AppModel
@State private var availableWidth: Double = 1680
var body: some View {
let width = CGFloat(availableWidth)
let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width)
VStack(alignment: .leading, spacing: Onyx.Space.xl) {
HStack(spacing: Onyx.Space.lg) {
Text("Verfügbare Breite")
.font(.callout)
Slider(value: $availableWidth, in: 400...3000)
.frame(maxWidth: 320)
Text("\(Int(availableWidth)) pt")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · "
+ "\(LayoutEngine.columns(availableWidth: width)) Spalten · "
+ "\(model.layout.count) Kacheln")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
WidgetGrid(placements: model.layout, availableWidth: width)
.background(OnyxSurface())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Bauteile
/// Jedes Bauteil in jedem Zustand.
///
/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle
/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von
/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier
/// kann man ihn also einfach hinschreiben.
private struct ComponentGallery: View {
@State private var volume = 0.62
@State private var overdrive = 1.8
@State private var targeted = true
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
group("OnyxCard mit OnyxHairline und OnyxSectionTitle") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
OnyxSectionTitle("System")
OnyxCard {
VStack(spacing: 0) {
row("MacBook Pro-Lautsprecher", "41 %")
OnyxHairline()
row("MacBook Pro-Mikrofon", "27 %")
}
}
}
.frame(width: 260)
}
group("OnyxNotice") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") }
tile {
OnyxNotice(symbol: "hand.raised",
text: "Der Helfer wartet auf deine Freigabe.") {
OnyxPillButton("Einstellungen") {}
}
}
tile {
OnyxNotice(symbol: "fan",
text: "Noch keine Lüfterdaten.",
placement: .leading)
}
tile {
OnyxNotice(symbol: "fan",
text: "Der Helfer ist nicht installiert.",
placement: .leading) {
OnyxPillButton("Installieren") {}
}
}
}
}
group("OnyxPillButton") {
HStack(spacing: Onyx.Space.lg) {
OnyxPillButton("Einschalten") {}
OnyxPillButton("Automatik", isFilled: false) {}
OnyxPillButton("Auto", size: .compact) {}
OnyxPillButton("Auto", isFilled: false, size: .compact) {}
}
}
group("OnyxSlider") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
sliderRow("normal") {
OnyxSlider(value: volume) { volume = $0 }
}
sliderRow("mit Pegel") {
OnyxSlider(value: volume, level: 0.35) { volume = $0 }
}
sliderRow("stumm") {
OnyxSlider(value: volume, isActive: false) { volume = $0 }
}
sliderRow("Overdrive") {
OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 }
}
}
.frame(width: 320)
}
group("OnyxDropZone") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Toggle("Zeiger schwebt darüber", isOn: $targeted)
.font(Onyx.Font.body)
.foregroundStyle(Onyx.Color.textSecondary)
HStack(spacing: Onyx.Space.lg) {
tile {
OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen")
.onyxDropZone(isTargeted: targeted, style: .always)
}
tile {
OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an")
.onyxDropZone(isTargeted: targeted, style: .whenTargeted)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private func row(_ name: String, _ value: String) -> some View {
HStack {
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
Spacer(minLength: Onyx.Space.md)
Text(value)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.md)
}
/// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein
/// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun
/// kann.
private func tile(@ViewBuilder content: () -> some View) -> some View {
OnyxTile { content() }
.frame(width: LayoutEngine.tileSize.width, height: 130)
}
private func sliderRow(_ name: String,
@ViewBuilder content: () -> some View) -> some View {
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 72, alignment: .leading)
content()
}
}
private func group(_ title: String,
@ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
// MARK: - Token
/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind.
///
/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline`
/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da.
private struct TokenGallery: View {
@State private var moved = false
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
colors
spaces
radii
fonts
metrics
motion
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private var colors: some View {
let swatches: [(String, Color)] = [
("surface", Onyx.Color.surface),
("elevated", Onyx.Color.elevated),
("hairline", Onyx.Color.hairline),
("textPrimary", Onyx.Color.textPrimary),
("textSecondary", Onyx.Color.textSecondary),
("textTertiary", Onyx.Color.textTertiary),
("accent", Onyx.Color.accent),
("download", Onyx.Color.download),
("upload", Onyx.Color.upload),
("positive", Onyx.Color.positive),
("warning", Onyx.Color.warning),
("critical", Onyx.Color.critical),
]
return section("Farben") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)],
alignment: .leading, spacing: Onyx.Space.row) {
ForEach(swatches, id: \.0) { name, color in
HStack(spacing: Onyx.Space.md) {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(color)
.overlay {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 28, height: 28)
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var spaces: some View {
let steps: [(String, CGFloat)] = [
("xxs", Onyx.Space.xxs),
("xs", Onyx.Space.xs),
("sm", Onyx.Space.sm),
("md", Onyx.Space.md),
("lg", Onyx.Space.lg),
("xl", Onyx.Space.xl),
("xxl", Onyx.Space.xxl),
]
return section("Abstände") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(steps, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 40, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: value, height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var radii: some View {
let steps: [(String, CGFloat)] = [
("chip", Onyx.Radius.chip),
("control", Onyx.Radius.control),
("card", Onyx.Radius.card),
("tile", Onyx.Radius.tile),
("notch", Onyx.Radius.notch),
("panel", Onyx.Radius.panel),
]
return section("Rundungen") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
ForEach(steps, id: \.0) { name, value in
VStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: value, style: .continuous)
.fill(Onyx.Color.elevated)
.overlay {
RoundedRectangle(cornerRadius: value, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 64, height: 64)
Text("\(name) \(Int(value))")
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
}
}
private var fonts: some View {
let specimens: [(String, Font)] = [
("micro", Onyx.Font.micro),
("caption", Onyx.Font.caption),
("footnote", Onyx.Font.footnote),
("body", Onyx.Font.body),
("title", Onyx.Font.title),
("headline", Onyx.Font.headline),
("display", Onyx.Font.display),
("displayLarge", Onyx.Font.displayLarge),
("metricMicro", Onyx.Font.metricMicro),
("metricSmall", Onyx.Font.metricSmall),
("metric", Onyx.Font.metric),
("metricLarge", Onyx.Font.metricLarge),
("symbolHero", Onyx.Font.symbolHero),
]
return section("Schrift") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(specimens, id: \.0) { name, font in
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Text("Onyx 0123456789")
.font(font)
.foregroundStyle(Onyx.Color.textPrimary)
}
}
}
}
}
private var metrics: some View {
let values: [(String, CGFloat)] = [
("panelPadding", Onyx.Metric.panelPadding),
("tileSpacing", Onyx.Metric.tileSpacing),
("hairlineWidth", Onyx.Metric.hairlineWidth),
]
return section("Panelgeometrie") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(values, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: max(value, 1), height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
/// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf
/// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten.
private var motion: some View {
let curves: [(String, Animation)] = [
("expand", Onyx.Motion.expand),
("collapse", Onyx.Motion.collapse),
("value", Onyx.Motion.value),
]
return section("Bewegung") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Picker("Profil", selection: Binding(
get: { OnyxTheme.shared.motion },
set: { OnyxTheme.shared.motion = $0 })) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.rawValue).tag(profile)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 280)
ForEach(curves, id: \.0) { name, animation in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.hairline)
.frame(height: 4)
Circle()
.fill(Onyx.Color.accent)
.frame(width: 14, height: 14)
.offset(x: moved ? 246 : 0)
.animation(animation, value: moved)
}
.frame(width: 260, height: 14)
}
}
Button(moved ? "Zurück" : "Los") { moved.toggle() }
}
}
}
@ViewBuilder
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import AppKit
import SwiftUI
/// Führt das Schaufenster.
///
/// Aufgebaut wie `SettingsWindowController` — aus demselben Grund: SwiftUIs
/// eigene Fensterszenen kommen bei einer App ohne Menüleiste unzuverlässig an.
@MainActor
final class GalleryWindowController: NSObject, NSWindowDelegate {
private var window: NSWindow?
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
/// zurückgeschaltet wird.
private var keepsDockIcon = false
/// Ob das Schaufenster überhaupt erreichbar ist.
///
/// Im Debug immer, im Release nur auf ausdrücklichen Wunsch. Es ist
/// Werkzeug, kein Programmteil — und ein Menüeintrag, den niemand braucht,
/// ist selbst schon Unordnung.
///
/// ```
/// defaults write com.scarriffleservices.onyx onyx.gallery -bool YES
/// ```
static var isAvailable: Bool {
#if DEBUG
true
#else
UserDefaults.standard.bool(forKey: "onyx.gallery")
#endif
}
/// Ob es beim Start gleich aufgehen soll.
///
/// Der Werkstatt-Kreislauf ist bauen, starten, hinsehen. Ohne das ist der
/// dritte Schritt ein Griff zur Maus und zwei Klicks ins Statusmenü — bei
/// jedem Durchlauf. `UserDefaults` liest Startargumente von selbst mit:
///
/// ```
/// open Onyx.app --args -onyx.gallery.open YES
/// ```
static var opensAtLaunch: Bool {
isAvailable && UserDefaults.standard.bool(forKey: "onyx.gallery.open")
}
func show(model: AppModel) {
keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock — sonst ist es
// weder über den Dock noch über ⌘-Tab zurückzuholen.
NSApp.setActivationPolicy(.regular)
if window == nil {
let hosting = NSHostingController(rootView: GalleryView(model: model))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx — Schaufenster"
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 1040, height: 720))
window.contentMinSize = NSSize(width: 780, height: 540)
window.isReleasedWhenClosed = false
window.delegate = self
OnyxWindowPlacement.center(window)
window.setFrameAutosaveName("onyx.gallery")
self.window = window
}
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}
/// Der rote Knopf blendet aus, statt zu schließen — wie bei den
/// Einstellungen, und aus demselben Grund: Onyx hat kein Hauptfenster, und
/// manche Hilfsprogramme beenden eine App, sobald ihr letztes Fenster
/// verschwindet.
func windowShouldClose(_ sender: NSWindow) -> Bool {
hide()
return false
}
private func hide() {
window?.orderOut(nil)
// Nur zurück in den Hintergrund, wenn wirklich nichts mehr offen ist.
// Das Einstellungsfenster kann noch stehen — es hier mit auszublenden
// sähe aus wie ein Absturz.
let otherVisible = NSApp.windows.contains {
$0 !== window && $0.isVisible && $0.styleMask.contains(.titled)
}
guard !otherVisible else { return }
if !keepsDockIcon {
NSApp.setActivationPolicy(.accessory)
}
NSApp.hide(nil)
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -46,7 +46,7 @@ final class MixerMenuBarModule: MenuBarModule {
AnyView(
ScrollView {
MixerView(mixer: mixer, system: system)
.padding(16)
.padding(Onyx.Space.xl)
}
.scrollIndicators(.automatic)
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
+18 -27
View File
@@ -33,7 +33,7 @@ struct OnboardingView: View {
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
OnyxFormCard {
OnboardingRow(
symbol: "calendar",
title: "onboarding.calendar",
@@ -74,9 +74,10 @@ struct OnboardingView: View {
fanControl.openApprovalSettings()
}
}
.padding(.horizontal, 22)
.padding(.vertical, 6)
.padding(.horizontal, Onyx.Space.xxl)
.padding(.vertical, Onyx.Space.lg)
}
.scrollContentBackground(.hidden)
Divider()
footer
@@ -95,10 +96,10 @@ struct OnboardingView: View {
// MARK: - Kopf und Fuß
private var header: some View {
HStack(spacing: 14) {
HStack(spacing: Onyx.Space.xl) {
Image(nsImage: NSApp.applicationIconImage)
.resizable().frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
Text("onboarding.title").font(.title2).fontWeight(.semibold)
Text("onboarding.subtitle")
.font(.callout).foregroundStyle(.secondary)
@@ -106,7 +107,7 @@ struct OnboardingView: View {
}
Spacer(minLength: 0)
}
.padding(22)
.padding(Onyx.Space.xxl)
}
private var footer: some View {
@@ -128,7 +129,7 @@ struct OnboardingView: View {
Button("onboarding.done", action: onFinish)
.keyboardShortcut(.defaultAction)
}
.padding(22)
.padding(Onyx.Space.xxl)
}
// MARK: - Zustände
@@ -169,33 +170,25 @@ private struct OnboardingRow: View {
enum State { case granted, denied, undetermined }
let symbol: String
let title: LocalizedStringKey
let detail: LocalizedStringKey
let title: String.LocalizationValue
let detail: String.LocalizationValue
let state: State
let request: () async -> Void
let openSettings: () -> Void
/// Dieselbe Zeile wie in den Einstellungen. Vorher war es eine eigene, die
/// zufällig gleich aussah — bis auf die Trennlinie, die sie sich selbst
/// unterlegte, und die deshalb auch unter der letzten stand.
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: symbol)
.font(.system(size: 15))
.foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary)
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title).fontWeight(.medium)
Text(detail)
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
OnyxFormRow(String(localized: title),
hint: String(localized: detail),
symbol: symbol,
tone: state == .granted ? .normal : .normal) {
switch state {
case .granted:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
.font(.system(size: 16))
.font(.system(size: Onyx.Symbol.large))
case .undetermined:
Button("onboarding.allow") { Task { await request() } }
case .denied:
@@ -204,7 +197,5 @@ private struct OnboardingRow: View {
Button("onboarding.openSettings", action: openSettings)
}
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Divider() }
}
}
+4 -1
View File
@@ -46,7 +46,10 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
onFinish: { [weak self] in self?.finish() }))
let window = NSWindow(contentViewController: hosting)
// Derselbe Grund wie im Einstellungsfenster — siehe
// `OnyxBackdropController`.
let window = NSWindow(contentViewController:
OnyxBackdropController(content: hosting))
window.title = "Onyx"
window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false
+264 -7
View File
@@ -12,6 +12,9 @@ import MetricsProvider
import NetworkProvider
import AudioProvider
import ShelfProvider
import ClipboardProvider
import NotesProvider
import ActivityProvider
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
@@ -42,18 +45,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var weatherModel: WeatherModel?
private var mediaModel: MediaModel?
private var metricsModel: MetricsModel?
/// Woran erkannt wird, dass sich etwas **geändert** hat. Ohne dieses
/// Gedächtnis meldete jede Messung dasselbe noch einmal.
/// Entscheidet, ob ein Messwert überhaupt ein Ereignis ist. Geprüft in
/// `PowerWatcherTests`.
private var powerWatcher = PowerWatcher()
/// Woran ein Titelwechsel erkannt wird — Titel und Interpret, nicht der
/// ganze Zustand: die Abspielposition wandert im Sekundentakt weiter, und
/// das ist kein Ereignis.
private var lastTrack: String?
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
/// Helligkeit bleibt sie leer und meldet nie etwas.
private let brightness = DisplayBrightness()
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
private var lastBrightness: Double?
private var networkModel: NetworkModel?
private var shelfStore: ShelfStore?
private var clipboardStore: ClipboardStore?
private var notesStore: NotesStore?
private let settingsWindow = SettingsWindowController()
private let fanControl = FanControl()
private let audioMixer = AudioMixer()
private let systemAudio = SystemAudio()
private let launchAtLogin = LaunchAtLogin()
private let onboarding = OnboardingWindowController()
private let galleryWindow = GalleryWindowController()
private let activities = ActivityPresenter()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Werkstattschalter: die Erscheinung **nur dieser App** umstellen, um
// Fenster hell und dunkel nebeneinander prüfen zu können. Die
// Systemeinstellung dafür zu drehen wäre ein Eingriff in die Umgebung
// des Nutzers — für einen Blick auf ein Fenster zu viel.
//
// open Onyx.app --args -onyx.appearance light
//
// **Nur im Debug.** Der Schalter liegt im flüchtigen Argument-Bereich
// und überlebt keinen Neustart — aber eine ausgelieferte App, die sich
// über die Erscheinungswahl des Systems hinwegsetzen *kann*, ist eine
// Falle, die irgendwann jemand auslöst.
#if DEBUG
switch UserDefaults.standard.string(forKey: "onyx.appearance") {
case "light": NSApp.appearance = NSAppearance(named: .aqua)
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
default: break
}
#endif
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
// Notch — das sieht aus wie ein Ruckler und ist keiner.
@@ -74,14 +117,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let coordinator = NotchCoordinator(policy: model.displayPolicy,
trigger: model.triggerArea) { displayID, presentation in
NotchHostingView(
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model, displayID: displayID)
},
rootView: NotchContainer(
presentation: presentation,
leading: { [weak self] in
NotchBandSlot(presentation: presentation) {
BandSlotView(model: model, edge: .leading,
weather: self?.weatherModel,
metrics: self?.metricsModel)
}
},
trailing: { [weak self] in
NotchBandSlot(presentation: presentation) {
BandSlotView(model: model, edge: .trailing,
weather: self?.weatherModel,
metrics: self?.metricsModel)
}
},
ambient: { [weak self] in
if let self { NotchAmbientSlot(presenter: activities) }
},
content: { NotchPanelContent(model: model, displayID: displayID) }),
presentation: presentation)
}
coordinator.showsIdleIsland = model.showsIdleIsland
coordinator.style = model.notchStyle
coordinator.virtualWidthOverrides = model.notchWidths
coordinator.start()
self.coordinator = coordinator
// Die Live Activities: der Presenter entscheidet, die Controller
// führen aus. Beides je Bildschirm, weil jede Notch ihr eigenes
// Fenster hat.
activities.onChange = { [weak self] activity in
guard let self, let coordinator = self.coordinator else { return }
for controller in coordinator.activeControllers {
controller.setAmbient(activity == nil ? nil : self.activities.contentSize)
}
}
observeVolume()
observePower()
observeMedia()
observeBrightness()
NotificationCenter.default.addObserver(
self, selector: #selector(screensChanged),
name: NSApplication.didChangeScreenParametersNotification, object: nil)
@@ -97,6 +174,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.coordinator?.policy = policy
self?.applyContentSize()
}
model.onShowsIdleIslandChanged = { [weak self] shows in
self?.coordinator?.showsIdleIsland = shows
}
model.onNotchStyleChanged = { [weak self] style in
self?.coordinator?.style = style
}
model.onNotchWidthsChanged = { [weak self] widths in
self?.coordinator?.virtualWidthOverrides = widths
}
let menuBar = MenuBarController()
if let metricsModel {
@@ -133,6 +219,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
installOnyxStatusItem()
observeMenuBarPreference()
requestPermissions()
if GalleryWindowController.opensAtLaunch {
galleryWindow.show(model: model)
}
// Derselbe Werkstattgriff wie beim Schaufenster: bauen, starten,
// hinsehen — ohne den Umweg übers Statusmenü.
//
// open Onyx.app --args -onyx.settings.open YES \
// -onyx.settings.section appearance
if UserDefaults.standard.bool(forKey: "onyx.settings.open") {
let name = UserDefaults.standard.string(forKey: "onyx.settings.section") ?? ""
showSettings(section: SettingsSection(rawValue: name) ?? .general)
}
}
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
@@ -178,7 +278,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
// genügt der Berechtigungsbereich der Einstellungen.
showSettings(tab: .permissions)
showSettings(section: .permissions)
}
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
@@ -246,6 +346,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
// eine leere Ablegefläche waren ein Drittel des Panels.
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
let clipboardStore = ClipboardStore()
self.clipboardStore = clipboardStore
WidgetRegistry.shared.register(ClipboardWidget(store: clipboardStore))
let notesStore = NotesStore()
self.notesStore = notesStore
WidgetRegistry.shared.register(NotesWidget(store: notesStore))
}
/// Je Bildschirm eine eigene Größe.
@@ -292,6 +400,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
action: #selector(openOnboarding), keyEquivalent: "").target = self
// Werkzeug, kein Programmteil: unlokalisiert, und im Release nur, wenn
// jemand den Schlüssel ausdrücklich setzt. Siehe
// `GalleryWindowController.isAvailable`.
if GalleryWindowController.isAvailable {
menu.addItem(withTitle: "Schaufenster",
action: #selector(openGallery), keyEquivalent: "").target = self
}
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
@@ -302,6 +417,148 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openOnboarding() { showOnboarding() }
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
///
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
/// `SystemAudio` hängt bereits mit CoreAudio-Listenern an Lautstärke und
/// Stummschaltung. Ein zweiter Weg dorthin wäre eine zweite Wahrheit.
///
/// Die Meldung kommt **vor** dem Schreiben; der neue Wert steht erst
/// danach. Deshalb wird er in einer Aufgabe gelesen, nicht im Rückruf.
private func observeVolume() {
withObservationTracking {
_ = systemAudio.outputVolume
_ = systemAudio.outputMuted
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
activities.raise(VolumeActivity(level: systemAudio.outputVolume,
isMuted: systemAudio.outputMuted))
observeVolume()
}
}
}
/// Meldet jeden **Wechsel** am Strom als Live Activity an.
///
/// Nicht jeden Messwert: der Ladestand ändert sich ständig, und eine Notch,
/// die das begleitet, wäre eine Leuchtreklame. Angemeldet wird nur, wenn
/// sich der Zustand ändert — und der Zustand ist das, was ``ChargeActivity``
/// aus Laden, Netz, Ladestand und Limit ableitet.
///
/// Der erste gelesene Zustand meldet **nichts**. Sonst begrüßte Onyx jeden
/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen
/// ist.
private func observePower() {
guard let metricsModel else { return }
withObservationTracking {
_ = metricsModel.snapshot.battery
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observePower() }
guard let battery = self.metricsModel?.snapshot.battery else { return }
guard let event = self.powerWatcher.observe(
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
charge: battery.charge,
limit: self.metricsModel?.chargeControl?.chargeLimit)
else { return }
let kind: PowerActivity.Kind = switch event {
case .charging: .charging
case .plugged: .plugged
case .unplugged: .unplugged
case .atLimit(let limit): .atLimit(limit)
case .low: .low
}
self.activities.raise(PowerActivity(kind: kind,
charge: battery.charge,
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn))
}
}
}
/// Meldet jede Helligkeitsänderung als Live Activity an.
///
/// Nur, wenn dieser Rechner überhaupt eine lesbare Helligkeit hat — auf
/// einem Mini am Monitor gibt es keine, und dann soll auch nichts
/// erscheinen. Siehe ``DisplayBrightness``.
private func observeBrightness() {
guard brightness.isAvailable else { return }
withObservationTracking {
_ = brightness.level
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observeBrightness() }
guard let level = self.brightness.level else { return }
guard let previous = self.lastBrightness else {
self.lastBrightness = level
return
}
self.lastBrightness = level
// Ein Hauch Unterschied ist kein Ereignis: die Meldung kommt
// auch bei automatischer Anpassung an das Umgebungslicht, und
// die kriecht in winzigen Schritten.
guard abs(level - previous) >= Self.brightnessStep else { return }
self.activities.raise(BrightnessActivity(level: level))
}
}
}
/// Ab welchem Sprung eine Helligkeitsänderung als Handgriff gilt.
///
/// Ein Tastendruck ändert sie um rund ein Sechzehntel; das Umgebungslicht
/// kriecht in viel kleineren Schritten.
private static let brightnessStep = 0.02
/// Meldet jeden **Titelwechsel** als Live Activity an.
///
/// Nicht jede Änderung an `nowPlaying`: dort wandert die Abspielposition
/// im Sekundentakt weiter, und das ist kein Ereignis. Verglichen wird
/// deshalb Titel und Interpret, nicht der ganze Zustand.
///
/// Auch hier meldet der erste gelesene Titel nichts — beim Start läuft
/// vielleicht seit einer Stunde dasselbe Stück.
private func observeMedia() {
guard let mediaModel else { return }
withObservationTracking {
_ = mediaModel.nowPlaying
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observeMedia() }
guard let playing = self.mediaModel?.nowPlaying, !playing.title.isEmpty else {
self.lastTrack = nil
return
}
let track = "\(playing.title)\u{1}\(playing.artist)"
guard let previous = self.lastTrack else {
self.lastTrack = track
return
}
guard track != previous else { return }
self.lastTrack = track
// Ein pausierter Titel wechselt nicht von selbst — was hier
// ankommt, während nichts läuft, ist ein Nachtrag der Quelle
// und kein Ereignis.
guard playing.isPlaying else { return }
self.activities.raise(MediaActivity(title: playing.title,
artist: playing.artist,
artwork: playing.artwork))
}
}
}
@objc private func openGallery() {
guard let model else { return }
galleryWindow.show(model: model)
}
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
///
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
@@ -362,16 +619,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
@objc private func openSettings() { showSettings(tab: .widgets) }
@objc private func openSettings() { showSettings(section: .general) }
private func showSettings(tab: SettingsTab) {
private func showSettings(section: SettingsSection) {
guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, metricsModel: metricsModel,
shelfStore: shelfStore, fanControl: fanControl,
launchAtLogin: launchAtLogin,
showOnboarding: { [weak self] in self?.showOnboarding() },
tab: tab)
section: section)
}
@objc private func quit() { NSApp.terminate(nil) }
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
/// Die Bereiche des Einstellungsfensters.
///
/// **Seitenleiste statt Reiterband.** Sieben deutsche Reiterbeschriftungen
/// brauchten rund 880 Punkte nebeneinander — das Fenster hatte deshalb eine
/// Mindestbreite, die nichts mit seinem Inhalt zu tun hatte, sondern nur damit,
/// dass sonst der erste und der letzte Reiter über die Kante hinausragten. Eine
/// Seitenleiste wächst nach unten, und dort ist Platz.
///
/// **Keine leeren Einträge auf Vorrat.** Sperrbildschirm, Zwischenablage und
/// Notizen kommen mit ihren Phasen. Ein Eintrag, der sich öffnen lässt und
/// nichts enthält, ist ein Versprechen, das die App nicht hält.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case general
case widgets
case widgetOptions
case notch
case appearance
case menubar
case fans
case permissions
var id: Self { self }
var title: String {
switch self {
case .general: String(localized: "settings.general")
case .widgets: String(localized: "settings.tab.widgets")
case .widgetOptions: String(localized: "settings.tab.options")
case .notch: String(localized: "settings.section.notch")
case .appearance: String(localized: "settings.section.appearance")
case .menubar: String(localized: "settings.tab.menubar")
case .fans: String(localized: "settings.tab.fans")
case .permissions: String(localized: "settings.tab.permissions")
}
}
var symbol: String {
switch self {
case .general: "gearshape.fill"
case .widgets: "square.grid.2x2.fill"
case .widgetOptions: "wrench.adjustable.fill"
case .notch: "macbook"
case .appearance: "paintpalette.fill"
case .menubar: "menubar.rectangle"
case .fans: "fan.fill"
case .permissions: "lock.fill"
}
}
/// Die Farbe des Plättchens.
///
/// Fest und **nicht** die Akzentfarbe: acht Einträge in derselben Farbe
/// wären wieder acht gleich aussehende Zeilen. Die Farbe unterscheidet
/// hier, sie schmückt nicht.
var tint: Color {
switch self {
case .general: Color(red: 0.44, green: 0.47, blue: 0.51)
case .widgets: Color(red: 0.31, green: 0.53, blue: 0.93)
case .widgetOptions: Color(red: 0.29, green: 0.68, blue: 0.66)
case .notch: Color(red: 0.55, green: 0.44, blue: 0.85)
case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28)
case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66)
case .fans: Color(red: 0.33, green: 0.68, blue: 0.85)
case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35)
}
}
}
+404 -252
View File
@@ -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) })
}
}
+149 -47
View File
@@ -27,8 +27,9 @@ import ShelfProvider
final class SettingsWindowController: NSObject, NSWindowDelegate {
@ObservationIgnored private var window: NSWindow?
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
private var selectedTab: SettingsTab = .widgets
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Bereich
/// setzen kann.
private var section: SettingsSection = .general
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
/// zurückgeschaltet wird.
@@ -37,8 +38,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
tab: SettingsTab = .widgets) {
selectedTab = tab
section: SettingsSection = .general) {
self.section = section
keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
@@ -59,39 +60,48 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
fanControl: fanControl,
launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding,
selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets },
set: { [weak self] in self?.selectedTab = $0 })))
section: Binding(
get: { [weak self] in self?.section ?? .general },
set: { [weak self] in self?.section = $0 })))
let window = NSWindow(contentViewController: hosting)
let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting))
window.title = "Onyx"
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
// verschwindet.
// **Kein durchsichtiger Titelbalken mehr.**
//
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
// Titel auf eine Trennlinie.
// Er war für die selbstgebaute Reiterleiste da: macOS zieht unter
// dem Titelbalken einen Trenner quer durchs Fenster, und die
// Leiste saß mittig darauf. Die Leiste gibt es nicht mehr — und
// durchsichtig ist er jetzt schädlich: Seitenleiste und Inhalt
// zeichnen darunter weiter, statt darunter zu beginnen. Sichtbar
// wurde das im Widgets-Bereich, wo die Liste unter den
// Fensterknöpfen hindurchlief.
window.titlebarSeparatorStyle = .automatic
// **Kein `isOpaque = false`, kein durchsichtiger Hintergrund.**
//
// Beides klingt nach dem Weg zur Durchlässigkeit und ist ihr
// Gegenteil, sobald die Vibrancy-Ebene selbst der Fensterinhalt
// ist: erklärt sich das Fenster für durchsichtig, stellt der
// Fensterserver ihm keinen Hintergrund mehr zum Abtasten bereit,
// und `behindWindow` hat nichts, woraus es Unschärfe machen könnte.
// Der Titelbalken teilt sich die Fläche mit dem Inhalt. Ohne das
// zeichnet macOS dort seine **eigene**, deutlich durchlässigere
// Vibrancy — sichtbar als heller Streifen quer über den oberen
// Rand, durch den man den Hintergrund lesen kann.
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
// Veränderbar, und das ist keine Kür: der Bereich „Displays" mit
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
// Bildschirm frei ist, ist eine Zumutung.
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 920, height: 680))
// Breit genug für die Reiterleiste.
//
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880
// Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner
// dazu. Ist das Fenster schmaler, ragt die Leiste über beide
// Kanten hinaus, und der erste wie der letzte Reiter sind nicht
// mehr erreichbar.
window.contentMinSize = NSSize(width: 900, height: 460)
window.setContentSize(NSSize(width: 940, height: 700))
// Seit die Bereiche in einer Seitenleiste stehen, hängt die
// Mindestbreite am Inhalt statt an der Beschriftung: das
// Reiterband brauchte 900 Punkte, nur damit der erste und der
// letzte Reiter nicht über die Kante hinausragten.
window.contentMinSize = NSSize(width: 780, height: 480)
window.isReleasedWhenClosed = false
window.delegate = self
Self.place(window)
OnyxWindowPlacement.center(window)
// Über die Position freuen sich Nutzer mit mehreren Displays:
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
window.setFrameAutosaveName("onyx.settings")
@@ -104,27 +114,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window?.makeKeyAndOrderFront(nil)
}
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
/// klebt.
///
/// `center()` setzt etwas oberhalb der Mitte — bei einem 452 Punkt hohen
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
/// plötzlich das Panel vor sich.
private static func place(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
// Wirklich mittig, nicht „etwas unterhalb der Notch".
//
// `center()` setzt oberhalb der Bildschirmmitte, und mein erster
// Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag
// weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die
// Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete.
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
/// Der rote Knopf blendet aus, statt zu schließen.
///
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
@@ -147,3 +136,116 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
NSApp.hide(nil)
}
}
/// Legt eine Vibrancy-Ebene **unter das ganze Fenster**, Titelbalken
/// eingeschlossen.
///
/// In SwiftUI ginge das nicht: ein `.background` dort endet an der sicheren
/// Fläche, und über dem oberen Rand blieb ein Streifen, durch den man den
/// Hintergrund lesen konnte. Hier liegt die Ebene im Fensterinhalt und wächst
/// mit ihm.
///
/// Zwei Schichten, nicht eine: die Vibrancy allein ist zu durchlässig — man
/// liest den Schreibtisch mit, statt die Einstellung. Der getönte Grund darüber
/// lässt die Unschärfe stehen und nimmt ihr die Lesbarkeit.
@MainActor
final class OnyxBackdropController: NSViewController {
/// Wie viel vom Fensterhintergrund über der Vibrancy stehen bleibt.
///
/// 1 wäre undurchlässig, 0 ließe das nackte Material stehen — dann ist der
/// Hintergrund stellenweise mitzulesen. **Das ist die Schraube**, wenn es
/// zu durchlässig oder zu deckend wirkt.
static let tintOpacity: CGFloat = 0
private let content: NSViewController
init(content: NSViewController) {
self.content = content
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") }
override func loadView() {
// Die Vibrancy-Ebene **ist** der Fensterinhalt, sie liegt nicht darin.
//
// Als Unteransicht in einem gewöhnlichen `NSView` blieb sie wirkungslos
// — sichtbar wurde nur der Titelbalken, den macOS selbst zeichnet. Als
// `contentView` greift `behindWindow` zuverlässig, und zusammen mit
// `.fullSizeContentView` am Fenster reicht sie auch unter den
// Titelbalken.
let container = NSVisualEffectView()
// `.sidebar` und nicht `.underWindowBackground`: letzteres ist so
// deckend, dass man von der Unschärfe nichts sieht, sobald hinter dem
// Fenster etwas Dunkles liegt. `.sidebar` ist das Material, das man von
// Finder und Systemeinstellungen kennt.
container.material = .sidebar
container.blendingMode = .behindWindow
// `.active` statt `.followsWindowActiveState`: sonst fällt die Ebene
// zurück, sobald das Fenster nicht vorn ist — dann sieht dasselbe
// Fenster je nach Aufmerksamkeit verschieden aus, und das liest sich
// als Fehler, nicht als Zustand.
container.state = .active
let tint = TintView()
tint.autoresizingMask = [.width, .height]
container.addSubview(tint)
addChild(content)
content.view.autoresizingMask = [.width, .height]
// Ohne das bleibt die Vibrancy unsichtbar: `NSHostingView` legt einen
// deckenden Grund unter den SwiftUI-Inhalt, und der steht genau
// dazwischen. Nur der Titelbalken kam vorbei — er wird von macOS
// gezeichnet, nicht von SwiftUI.
content.view.wantsLayer = true
content.view.layer?.backgroundColor = NSColor.clear.cgColor
container.addSubview(content.view)
self.view = container
}
override func viewDidLayout() {
super.viewDidLayout()
for subview in view.subviews { subview.frame = view.bounds }
}
/// Der Titelbalken bekommt keinen eigenen Grund — er liegt auf derselben
/// Fläche wie alles andere.
override func viewDidAppear() {
super.viewDidAppear()
view.window?.styleMask.insert(.fullSizeContentView)
}
}
/// Der getönte Grund über der Vibrancy.
///
/// Eine eigene Klasse, weil die Farbe erscheinungsabhängig ist: eine einmal
/// gelesene `CGColor` bliebe beim Wechsel auf Hell dunkel stehen.
/// `viewDidChangeEffectiveAppearance` gibt es nur an der View, nicht am
/// Controller — daran ist der erste Versuch gescheitert.
private final class TintView: NSView {
override var wantsUpdateLayer: Bool { true }
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
}
required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") }
override func updateLayer() {
effectiveAppearance.performAsCurrentDrawingAppearance {
layer?.backgroundColor = NSColor.windowBackgroundColor
.withAlphaComponent(OnyxBackdropController.tintOpacity).cgColor
}
}
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}
}
+115 -118
View File
@@ -17,26 +17,13 @@ struct WidgetOptionsView: View {
let shelfStore: ShelfStore?
var body: some View {
Form {
Section("options.calendar") {
CalendarOptions(model: calendarModel)
}
Section("options.weather") {
WeatherOptions(model: weatherModel)
}
SettingsPage {
CalendarOptions(model: calendarModel)
WeatherOptions(model: weatherModel)
if let shelfStore {
Section("options.shelf") {
ShelfOptions(store: shelfStore)
}
ShelfOptions(store: shelfStore)
}
}
.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()
}
}
@@ -44,82 +31,93 @@ struct WidgetOptionsView: View {
private struct CalendarOptions: View {
@Bindable var model: CalendarModel
@State private var calendarrState: CalendarSourceState?
var body: some View {
Picker(selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
} label: {
Text("options.calendar.source")
}
.pickerStyle(.menu)
Picker(selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true)
} label: {
Text("options.calendar.display")
}
.pickerStyle(.menu)
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**:
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn
// suchen muss, ohne zu wissen, dass es ihn gibt.
if model.availableCalendars.isEmpty {
Text("options.calendar.noneFound")
.font(.callout).foregroundStyle(.secondary)
} else {
ForEach(model.availableCalendars) { calendar in
Toggle(isOn: Binding(
get: { !model.hiddenCalendarIDs.contains(calendar.id) },
set: { shown in
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
else { model.hiddenCalendarIDs.insert(calendar.id) }
})) {
Label {
Text(calendar.title)
} icon: {
Circle()
.fill(Color(hex: calendar.colorHex) ?? .secondary)
.frame(width: 9, height: 9)
}
OnyxFormCard(String(localized: "options.calendar")) {
OnyxFormRow(String(localized: "options.calendar.source")) {
Picker("", selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
}
.labelsHidden()
.frame(width: 180)
}
OnyxFormRow(String(localized: "options.calendar.display")) {
Picker("", selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true)
}
.labelsHidden()
.frame(width: 180)
}
}
OnyxFormCard(String(localized: "options.calendar.calendars"),
hint: String(localized: "options.calendar.calendars.hint")) {
if model.availableCalendars.isEmpty {
OnyxFormRow(String(localized: "options.calendar.noneFound"))
} else {
ForEach(model.availableCalendars) { calendar in
OnyxFormRow(calendar.title) {
Circle()
.fill(Color(hex: calendar.colorHex) ?? .secondary)
.frame(width: 9, height: 9)
Toggle("", isOn: Binding(
get: { !model.hiddenCalendarIDs.contains(calendar.id) },
set: { shown in
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
else { model.hiddenCalendarIDs.insert(calendar.id) }
}))
.labelsHidden()
}
}
}
}
if model.kind != .appleCalendar {
CalendarrServerSettings(model: model)
CalendarrStatus(state: calendarrState,
isLoading: model.isLoading,
onRefresh: { model.refresh() })
.task(id: model.kind) {
guard model.kind != .appleCalendar else { return }
let now = Date()
calendarrState = await CalendarrSource().load(
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
end: now.addingTimeInterval(60 * 86_400)))
}
OnyxFormCard(String(localized: "options.calendar.serverGroup")) {
CalendarrServerSettings(model: model)
// Der Zustand kommt aus dem Modell, nicht aus einem zweiten
// eigenen Aufruf. Der las immer den Schnappschuss — auch wenn
// längst der Server gefragt wurde — und der Knopf daneben
// aktualisierte ihn nie.
CalendarrStatus(state: model.calendarrState,
usesServer: model.usesCalendarrServer,
isLoading: model.isLoading,
onRefresh: { model.refresh() })
}
}
}
}
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
///
/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den
/// Mit einem Knopf zum Nachsehen. Ohne Zugangsdaten liest Onyx den
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen.
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. Mit
/// Zugangsdaten fragt Onyx den Server selbst — dann sagt diese Zeile, wann das
/// zuletzt gelang.
private struct CalendarrStatus: View {
let state: CalendarSourceState?
/// Ob gerade direkt vom Server gelesen wird oder aus dem Schnappschuss.
///
/// Ohne diese Auskunft ist der Zeitstempel daneben nicht zu deuten: ein
/// altes Datum heißt beim Schnappschuss „Calendarr lief lange nicht" und
/// beim Server „etwas stimmt nicht".
let usesServer: Bool
let isLoading: Bool
let onRefresh: () -> Void
var body: some View {
HStack(spacing: 8) {
OnyxFormRow(String(localized: usesServer ? "options.calendar.viaServer"
: "options.calendar.viaSnapshot"),
hint: usesServer ? nil
: String(localized: "options.calendar.viaSnapshot.hint")) {
message
Spacer(minLength: 8)
if case .ready(let window) = state {
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–")
.foregroundStyle(.secondary)
@@ -180,44 +178,40 @@ private struct WeatherOptions: View {
private var usesCurrentLocation: Bool { model.place.isCurrent }
var body: some View {
Toggle(isOn: Binding(
get: { usesCurrentLocation },
set: { model.place = $0 ? .current : model.place })) {
Text("options.weather.automatic")
OnyxFormCard(String(localized: "options.weather")) {
OnyxFormRow(String(localized: "options.weather.automatic"),
// Ohne Ortungsberechtigung nützt die Automatik nichts —
// und das steht hier, statt dass das Widget stumm leer
// bleibt.
hint: usesCurrentLocation && model.locationIsDenied
? String(localized: "options.weather.needsLocation") : nil) {
Toggle("", isOn: Binding(
get: { usesCurrentLocation },
set: { model.place = $0 ? .current : model.place }))
.labelsHidden()
.disabled(usesCurrentLocation)
}
.disabled(usesCurrentLocation)
if usesCurrentLocation {
// Ohne Ortungsberechtigung nützt die Automatik nichts — und das
// steht hier, statt dass das Widget stumm leer bleibt.
if model.locationIsDenied {
Text("options.weather.needsLocation")
.font(.callout).foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
if case .fixed(let name, _, _) = model.place {
OnyxFormRow(String(localized: "options.weather.place")) {
Text(name).foregroundStyle(.secondary)
}
}
} else if case .fixed(let name, _, _) = model.place {
LabeledContent {
Text(name).foregroundStyle(.secondary)
} label: {
Text("options.weather.place")
}
}
HStack {
TextField("options.weather.search", text: $query)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await search() } }
if searching {
ProgressView().controlSize(.small)
} else {
Button("options.weather.set") { Task { await search() } }
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
OnyxFormRow(String(localized: "options.weather.search"),
hint: notFound ? String(localized: "options.weather.notFound") : nil) {
TextField("", text: $query)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.onSubmit { Task { await search() } }
if searching {
ProgressView().controlSize(.small)
} else {
Button("options.weather.set") { Task { await search() } }
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
if notFound {
Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning)
}
}
private func search() async {
@@ -241,23 +235,26 @@ private struct ShelfOptions: View {
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
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)
OnyxFormCard(String(localized: "options.shelf")) {
OnyxFormRow(String(localized: "settings.shelf.cleanup")) {
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)
}
}
}
.labelsHidden()
.frame(width: 180)
}
} label: {
Text("settings.shelf.cleanup")
}
HStack {
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
OnyxFormRow(String(localized: "options.shelf")) {
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Button("settings.shelf.reveal") { store.revealInFinder() }
}
}
}
}
@@ -298,7 +295,7 @@ private struct CalendarrServerSettings: View {
var body: some View {
if let account {
LabeledContent {
HStack(spacing: 10) {
HStack(spacing: Onyx.Space.row) {
Text(account.username).foregroundStyle(.secondary)
Button {
credentials.clear()
+26
View File
@@ -0,0 +1,26 @@
import AppKit
/// Wohin ein Fenster von Onyx gehört.
///
/// `NSWindow.center()` setzt etwas **oberhalb** der Bildschirmmitte. Bei einem
/// nicht sehr hohen Fenster landet der Schließknopf damit knapp unter der
/// Menüleiste — wer ihn treffen will, fährt durch die Auslösefläche der Notch
/// und hat plötzlich das Panel vor sich.
///
/// Das gilt für jedes Fenster, das Onyx aufmacht, nicht nur für die
/// Einstellungen. Deshalb steht es hier und nicht in einem der Controller.
enum OnyxWindowPlacement {
/// Wirklich mittig, nicht „etwas unterhalb der Notch".
///
/// Ein erster Versuch mit 60 Punkten Abstand zur Oberkante war zu zaghaft:
/// der Titelbalken lag weiter so hoch, dass man auf dem Weg zu seinen
/// Knöpfen durch die Auslösefläche fuhr.
static func center(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
}