diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 2a1f9ab..dbd2164 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -16,11 +16,12 @@ 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; 5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; }; - 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; 68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; }; 6A972372414D6EFDC81E2661 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B442E8A39C37C8FD92B4CA83 /* main.swift */; }; + 6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89309D9F5722FA1B91CF2905 /* FanWidget.swift */; }; 75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; }; 7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; }; + 8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; }; A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; }; @@ -58,14 +59,15 @@ 508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = ""; }; + 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixerMenuBarModule.swift; sourceTree = ""; }; 63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; }; 87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = ""; }; + 89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = ""; }; 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; }; 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; - CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderWidgets.swift; sourceTree = ""; }; F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = ""; }; @@ -154,15 +156,16 @@ B83C4E3CB821F903F3977E88 /* AppModel.swift */, FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, + 89309D9F5722FA1B91CF2905 /* FanWidget.swift */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */, 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */, 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */, + 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */, 63A5510538846C8427AEB831 /* OnboardingView.swift */, 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */, 87597F09E971C14CF1E475DB /* Onyx.entitlements */, F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, - CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */, 45BD2DF1B775C733C8F75635 /* SettingsView.swift */, 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */, ); @@ -331,11 +334,12 @@ C041F171FB69F4381309D978 /* AppModel.swift in Sources */, CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */, 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */, + 6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */, B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */, + 8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */, C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */, 1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */, 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, - 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */, 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */, 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */, ); diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift index 0320293..f991de5 100644 --- a/Onyx/AppModel.swift +++ b/Onyx/AppModel.swift @@ -108,7 +108,20 @@ final class AppModel { // MARK: - Widgets - var panelContentSize: CGSize { WidgetGrid.panelSize(for: layout) } + var panelContentSize: CGSize { + WidgetGrid.panelSize(for: layout, availableWidth: Self.availableWidth) + } + + /// Wie breit das Panel höchstens werden darf. + /// + /// Der **schmalste** aktive Bildschirm gibt den Ausschlag: dasselbe Panel + /// erscheint auf allen Displays, und eines, das auf dem Notebook über den + /// Rand ragt, wäre auf dem großen Monitor kein Trost. Der Abzug lässt Luft + /// zum Rand — ein Panel, das exakt bis zur Kante geht, wirkt eingeklemmt. + static var availableWidth: CGFloat { + let narrowest = NSScreen.screens.map(\.frame.width).min() ?? 1440 + return narrowest - 120 + } func isInPanel(_ widgetID: String) -> Bool { layout.contains { $0.widgetID == widgetID } @@ -116,19 +129,13 @@ final class AppModel { func add(_ widget: any OnyxWidget) { guard !isInPanel(widget.id) else { return } - let size = widget.supportedSizes.first ?? .small - layout.append(WidgetPlacement(widgetID: widget.id, size: size)) + layout.append(WidgetPlacement(widgetID: widget.id)) } func remove(_ widgetID: String) { layout.removeAll { $0.widgetID == widgetID } } - func setSize(_ size: WidgetSize, for widgetID: String) { - guard let index = layout.firstIndex(where: { $0.widgetID == widgetID }) else { return } - layout[index].size = size - } - func move(fromOffsets source: IndexSet, toOffset destination: Int) { layout.move(fromOffsets: source, toOffset: destination) } diff --git a/Onyx/FanWidget.swift b/Onyx/FanWidget.swift new file mode 100644 index 0000000..6ffcf13 --- /dev/null +++ b/Onyx/FanWidget.swift @@ -0,0 +1,239 @@ +import SwiftUI +import AppKit +import OnyxDesign +import OnyxWidgetKit +import OnyxMenuBar +import OnyxHelperProtocol + +/// Die Lüfter — als Panel-Kachel und als Menüleistenmodul. +/// +/// Beide zeigen dieselbe Ansicht auf derselben Datenquelle. Die Steuerung nur +/// in den Einstellungen zu verstecken war falsch: Lüfter regelt man, wenn es +/// gerade laut ist, und dann will man nicht erst ein Fenster suchen. +struct FanView: View { + let control: FanControl + /// Im Popover ist Platz für die Erklärung, in der Kachel nicht. + let roomy: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + switch control.installState { + case .installed: + if let report = control.report, !report.fans.isEmpty { + ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in + FanRow(control: control, index: index, fan: fan, roomy: roomy) + } + if let hottest = report.hottestCelsius { + Divider().overlay(Onyx.Color.hairline) + HStack(spacing: 5) { + Image(systemName: "thermometer.medium") + .font(.system(size: 10)) + Text("\(Int(hottest.rounded())) °C") + .font(Onyx.Font.metricSmall).monospacedDigit() + Spacer(minLength: 0) + } + .foregroundStyle(Onyx.Color.textTertiary) + } + } else { + notice("fans.noData", symbol: "fan") + } + case .requiresApproval: + notice("fans.approval.needed", symbol: "hand.raised") { + control.openApprovalSettings() + } + case .notInstalled: + notice("fans.notInstalled", symbol: "fan") { control.install() } + case .failed(let reason): + Text(reason) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.critical) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .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 { + let control: FanControl + let index: Int + let fan: FanStatus + let roomy: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("fans.fan \(fan.index + 1)") + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + .lineLimit(1) + Spacer(minLength: 4) + Text("\(Int(fan.currentRPM)) U/min") + .font(Onyx.Font.metricSmall).monospacedDigit() + .foregroundStyle(Onyx.Color.textPrimary) + } + + HStack(spacing: 6) { + // Automatik ist der Ruhezustand und bleibt einen Klick entfernt: + // wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen. + Button { control.setAutomatic(index: index) } label: { + Text("fans.auto") + .font(Onyx.Font.caption) + .foregroundStyle(fan.isManual ? Onyx.Color.textTertiary + : Onyx.Color.accent) + } + .buttonStyle(.plain) + + Slider(value: Binding( + get: { fan.targetRPM }, + set: { control.setTarget(index: index, rpm: $0) }), + in: fan.limits.minimum...fan.limits.maximum) + .controlSize(.mini) + } + } + } +} + +// MARK: - Panel-Kachel + +struct FanWidget: OnyxWidget { + let id = "fans" + var displayName: String { String(localized: "widget.fans.name") } + let symbolName = "fan" + + private let control: FanControl + + init(control: FanControl) { self.control = control } + + func makeView() -> AnyView { + AnyView(ScrollView { FanView(control: control, roomy: false) } + .scrollIndicators(.never)) + } +} + +// MARK: - Menüleistenmodul + +@MainActor +final class FanMenuBarModule: MenuBarModule { + let id = "fans" + var displayName: String { String(localized: "widget.fans.name") } + + private let control: FanControl + private var view: FanStatusView? + private var isActive = false + + init(control: FanControl) { self.control = control } + + func makeStatusView(presentation: MenuBarPresentation) -> NSView { + let view = FanStatusView(presentation: presentation) + view.update(rpm: control.report?.fans.map(\.currentRPM).max()) + self.view = view + return view + } + + func makePopoverView() -> AnyView { + AnyView(FanView(control: control, roomy: true) + .padding(14) + .frame(width: 260)) + } + + func activate() { + isActive = true + control.start() + startObserving() + } + + func deactivate() { + isActive = false + view = nil + } + + /// Keine eigene Messschleife: `FanControl` fragt den Helfer ohnehin ab, hier + /// wird nur zugehört. `withObservationTracking` meldet sich **einmal** und + /// muss danach neu eingerichtet werden. + private func startObserving() { + withObservationTracking { + _ = control.report + } onChange: { + Task { @MainActor [weak self] in + guard let self, isActive else { return } + view?.update(rpm: control.report?.fans.map(\.currentRPM).max()) + startObserving() + } + } + } +} + +/// Die Drehzahl in der Menüleiste. +private final class FanStatusView: NSView, WidthReporting { + private let presentation: MenuBarPresentation + private var rpm: Double? + + init(presentation: MenuBarPresentation) { + self.presentation = presentation + super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: NSSize { + NSSize(width: MenuBarText.width(for: reference, includesSymbol: true), height: 22) + } + + /// Die breiteste Zahl, die vorkommen kann — nicht die aktuelle. Sonst + /// änderte das Element bei jeder Messung seine Breite. + private var reference: String { presentation == .symbol ? "" : "9999" } + + var onWidthChange: ((CGFloat) -> Void)? + + func update(rpm: Double?) { + self.rpm = rpm + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + let color = NSColor.labelColor + guard let image = NSImage(systemSymbolName: "fan", accessibilityDescription: nil) + else { return } + image.isTemplate = true + + if presentation == .symbol { + color.set() + image.draw(in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5, + width: 15, height: 15)) + return + } + + color.set() + image.draw(in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13)) + MenuBarText.draw(rpm.map { String(Int($0.rounded())) } ?? "–", + color: color, + in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height)) + } +} diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 4c64489..25e2673 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -80,6 +80,22 @@ } } }, + "fans.fan %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" + } + } + } + }, "fans.hottest": { "extractionState": "stale", "localizations": { @@ -98,18 +114,17 @@ } }, "fans.install": { - "extractionState": "stale", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Hilfsdienst einrichten" + "value": "Installieren" } }, "en": { "stringUnit": { "state": "translated", - "value": "Install helper" + "value": "Install" } } } @@ -179,6 +194,38 @@ } } }, + "fans.noData": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Lüfterdaten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No fan data yet." + } + } + } + }, + "fans.notInstalled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Lüftersteuerung braucht einen kleinen privilegierten Dienst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control needs a small privileged service." + } + } + } + }, "fans.reason.noTemperature": { "extractionState": "stale", "localizations": { @@ -515,6 +562,22 @@ } } }, + "mixer.menubar.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärkemixer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Volume mixer" + } + } + } + }, "network.name": { "extractionState": "stale", "localizations": { @@ -1515,6 +1578,22 @@ } } }, + "settings.widgets.hint.order": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Kacheln sind gleich groß. Die Reihenfolge hier ist die Reihenfolge im Panel — zum Sortieren ziehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All tiles are the same size. The order here is the order in the panel — drag to sort." + } + } + } + }, "settings.widgets.inPanel": { "extractionState": "stale", "localizations": { @@ -1548,6 +1627,22 @@ } } } + }, + "widget.fans.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" + } + } + } } }, "version": "1.1" diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift new file mode 100644 index 0000000..b4a5fac --- /dev/null +++ b/Onyx/MixerMenuBarModule.swift @@ -0,0 +1,108 @@ +import SwiftUI +import AppKit +import AudioProvider +import OnyxMenuBar +import OnyxDesign + +/// Der Lautstärkemixer als eigenes Element in der Menüleiste. +/// +/// Bewusst getrennt vom Onyx-Element: den Ton einer einzelnen App leiser zu +/// drehen ist etwas, das man mitten in etwas anderem tut. Ein Umweg über ein +/// Menü und dann in ein Panel ist dafür zwei Schritte zu weit. +@MainActor +final class MixerMenuBarModule: MenuBarModule { + let id = "audio" + var displayName: String { String(localized: "mixer.menubar.name") } + + private let mixer: AudioMixer + private var view: MixerStatusView? + private var isActive = false + + init(mixer: AudioMixer) { self.mixer = mixer } + + func makeStatusView(presentation: MenuBarPresentation) -> NSView { + let view = MixerStatusView() + view.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count) + self.view = view + return view + } + + func makePopoverView() -> AnyView { + AnyView( + ScrollView { + MixerView(mixer: mixer) + .padding(14) + } + .scrollIndicators(.automatic) + .frame(width: 280) + .frame(maxHeight: 420) + ) + } + + func activate() { + isActive = true + startObserving() + } + + func deactivate() { + isActive = false + view = nil + } + + /// Der Mixer misst ohnehin; hier wird nur zugehört. `withObservationTracking` + /// meldet sich einmal und muss danach neu eingerichtet werden. + private func startObserving() { + withObservationTracking { + _ = mixer.isEnabled + _ = mixer.managed + } onChange: { + Task { @MainActor [weak self] in + guard let self, isActive else { return } + view?.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count) + startObserving() + } + } + } +} + +/// Ein Glyph, mehr nicht. +/// +/// Eine Zahl wäre hier sinnlos: „welche Lautstärke" hat bei einem Mischpult +/// keine einzelne Antwort. Der Zustand, der zählt, ist ob überhaupt etwas +/// geregelt wird — und den zeigt die Deckkraft. +private final class MixerStatusView: NSView { + private var isEnabled = false + private var managed = 0 + + init() { + super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: NSSize { + NSSize(width: MenuBarText.minimumWidth, height: 22) + } + + func update(isEnabled: Bool, managed: Int) { + self.isEnabled = isEnabled + self.managed = managed + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + guard let image = NSImage(systemSymbolName: "slider.horizontal.3", + accessibilityDescription: nil) else { return } + image.isTemplate = true + // Ausgeschaltet bleibt es sichtbar, aber zurückgenommen: das Element + // verschwinden zu lassen wäre die schlechtere Antwort — dann fände man + // den Weg zum Einschalten nicht mehr. + let color = (isEnabled && managed > 0) + ? NSColor.labelColor + : NSColor.labelColor.withAlphaComponent(0.55) + color.set() + image.draw(in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5, + width: 15, height: 15)) + } +} diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index fcdab9f..5da57a2 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -100,6 +100,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBar.register(NetworkMenuBarModule(model: networkModel), settings: model.menuBarSettings(for: "network")) } + // Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte: + // beides regelt man mitten in etwas anderem, und dafür ist der Weg + // über ein Fenster zu weit. + menuBar.register(MixerMenuBarModule(mixer: audioMixer), + settings: model.menuBarSettings(for: "audio")) + menuBar.register(FanMenuBarModule(control: fanControl), + settings: model.menuBarSettings(for: "fans")) menuBar.start() self.menuBar = menuBar model.menuBar = menuBar @@ -176,7 +183,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { audioMixer.disable() } - /// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt. private func registerWidgets() { let calendarModel = CalendarModel() self.calendarModel = calendarModel @@ -202,12 +208,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer)) + WidgetRegistry.shared.register(FanWidget(control: fanControl)) + let shelfStore = ShelfStore() self.shelfStore = shelfStore WidgetRegistry.shared.register(ShelfWidget(store: shelfStore)) WidgetRegistry.shared.register(AirDropWidget()) - - PlaceholderWidgets.registerAll() } private func applyContentSize() { @@ -320,6 +326,6 @@ private struct NotchPanelContent: View { let model: AppModel var body: some View { - WidgetGrid(placements: model.layout) + WidgetGrid(placements: model.layout, availableWidth: AppModel.availableWidth) } } diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift deleted file mode 100644 index a8be2d5..0000000 --- a/Onyx/PlaceholderWidgets.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI -import OnyxDesign -import OnyxWidgetKit - -/// Vorläufige Widgets für Phase 2. -/// -/// Sie tragen keine echten Daten — ihr Zweck ist zu belegen, dass Registry, -/// Layout-Engine, Persistenz und Panelgröße zusammenspielen, bevor die Provider -/// dazukommen. Jedes wird in seiner Phase durch das echte Widget ersetzt; die -/// Kennungen bleiben dabei gleich, damit gespeicherte Layouts weitergelten. -struct PlaceholderWidget: OnyxWidget { - let id: String - let displayName: String - let symbolName: String - let supportedSizes: [WidgetSize] - /// In welcher Phase dieses Widget echt wird. - let phase: String - - func makeView(size: WidgetSize) -> AnyView { - AnyView( - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: symbolName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Onyx.Color.accent) - Text(displayName) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textSecondary) - } - Spacer(minLength: 0) - Text(phase) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textTertiary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - ) - } -} - -@MainActor -enum PlaceholderWidgets { - /// Registriert nur noch die Karten, deren echte Fassung fehlt. Sobald ein - /// Widget umgesetzt ist, verschwindet es hier — die Kennung bleibt gleich, - /// damit gespeicherte Layouts weitergelten. - static func registerAll() { - let widgets: [PlaceholderWidget] = [ - .init(id: "fans", displayName: "Lüfter", symbolName: "fan", - supportedSizes: [.small, .medium], phase: "Phase 6"), - ] - widgets.forEach { WidgetRegistry.shared.register($0) } - } -} diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index cdd71a0..70e450b 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -217,6 +217,8 @@ private struct MenuBarSettings: View { ("metric.battery", "metric.battery", "battery.100"), ("metric.temperature", "metric.sensors", "thermometer"), ("network", "network.name", "network"), + ("audio", "mixer.menubar.name", "slider.horizontal.3"), + ("fans", "widget.fans.name", "fan"), ] var body: some View { @@ -249,6 +251,18 @@ private struct ModuleRow: View { let symbol: String let metricsModel: MetricsModel? + /// Welche Darstellungen dieses Modul beherrscht. + static func presentations(for id: String) -> [MenuBarPresentation] { + switch id { + // Ein Mischpult hat keinen einzelnen Messwert. + case "audio": [] + // Drehzahl als Zahl oder nur das Symbol — ein Verlauf der Drehzahl + // sagt in 28 Punkten Breite nichts. + case "fans": [.value, .symbol] + default: MenuBarPresentation.allCases + } + } + /// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk. private var metricKind: MetricKind? { guard id.hasPrefix("metric.") else { return nil } @@ -286,20 +300,25 @@ private struct ModuleRow: View { .disabled(!settings.isEnabled) } - Picker("", selection: Binding( - get: { settings.presentation }, - set: { presentation in - var updated = settings - updated.presentation = presentation - model.setMenuBarSettings(updated, for: id) - })) { - ForEach(MenuBarPresentation.allCases) { presentation in - Text(presentation.localizedName).tag(presentation) + // 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) + .labelsHidden() + .frame(width: 150) + .disabled(!settings.isEnabled) + } } } } @@ -416,18 +435,6 @@ private struct PlacementRow: View { Spacer() - // Nur die Größen anbieten, die das Widget auch sinnvoll ausfüllt. - // Ein Mini-Monat in 1×1 wäre unleserlich. - Picker("", selection: Binding( - get: { placement.size }, - set: { model.setSize($0, for: placement.widgetID) })) { - ForEach(widget.supportedSizes) { size in - Text(size.localizedName).tag(size) - } - } - .labelsHidden() - .frame(width: 130) - Button { model.remove(placement.widgetID) } label: { @@ -477,14 +484,3 @@ private struct DisplaySettings: View { .padding() } } - -extension WidgetSize { - var localizedName: LocalizedStringKey { - switch self { - case .small: "settings.size.small" - case .medium: "settings.size.medium" - case .large: "settings.size.large" - case .wide: "settings.size.wide" - } - } -} diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index c014d19..7efcdc7 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -271,16 +271,15 @@ public struct AudioMixerWidget: OnyxWidget { public let id = "audio" public var displayName: String { String(localized: "mixer.name", bundle: .module) } public let symbolName = "slider.horizontal.3" - public let supportedSizes: [WidgetSize] = [.medium, .large] private let mixer: AudioMixer public init(mixer: AudioMixer) { self.mixer = mixer } - public func makeView(size: WidgetSize) -> AnyView { + public func makeView() -> AnyView { AnyView( ScrollView { - MixerView(mixer: mixer, compact: size == .medium) + MixerView(mixer: mixer) } .scrollIndicators(.never) ) diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 8caf517..4359f6a 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -24,7 +24,13 @@ public final class CalendarModel { } } + /// Monatsraster statt Terminliste. + public var showsMonthGrid: Bool { + didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) } + } + private static let kindKey = "calendar.source" + private static let gridKey = "calendar.showsMonthGrid" private let appleSource: CalendarSource private let calendarrSource: CalendarSource @@ -44,6 +50,7 @@ public final class CalendarModel { self.calendar = calendar self.kind = defaults.string(forKey: Self.kindKey) .flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar + self.showsMonthGrid = defaults.bool(forKey: Self.gridKey) observeCalendarrIfNeeded() } @@ -157,7 +164,6 @@ public struct CalendarWidget: OnyxWidget { public let id = "calendar" public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) } public let symbolName = "calendar" - public let supportedSizes: [WidgetSize] = [.medium, .large] private let model: CalendarModel @@ -165,21 +171,23 @@ public struct CalendarWidget: OnyxWidget { self.model = model } - public func makeView(size: WidgetSize) -> AnyView { - AnyView(CalendarWidgetView(model: model, size: size)) + public func makeView() -> AnyView { + AnyView(CalendarWidgetView(model: model)) } } private struct CalendarWidgetView: View { let model: CalendarModel - let size: WidgetSize var body: some View { Group { switch model.state { case .ready(let window): - if size == .large { - LargeCalendar(window: window, upcoming: model.upcoming) + // Monatsraster oder Terminliste — das ist keine Frage der + // Größe, sondern was man sehen will. Beides zugleich passt in + // eine Kachel nicht, ohne dass eines davon unlesbar wird. + if model.showsMonthGrid { + LargeCalendar(window: window, upcoming: []) } else { AgendaList(events: model.upcoming, coverage: window.coverage) } diff --git a/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift b/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift index 4ef826b..df8b607 100644 --- a/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift +++ b/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift @@ -7,25 +7,23 @@ public struct MediaWidget: OnyxWidget { public let id = "media" public var displayName: String { String(localized: "widget.media.name", bundle: .module) } public let symbolName = "play.circle" - public let supportedSizes: [WidgetSize] = [.medium, .wide] private let model: MediaModel public init(model: MediaModel) { self.model = model } - public func makeView(size: WidgetSize) -> AnyView { - AnyView(MediaWidgetView(model: model, size: size)) + public func makeView() -> AnyView { + AnyView(MediaWidgetView(model: model)) } } private struct MediaWidgetView: View { let model: MediaModel - let size: WidgetSize var body: some View { Group { if let state = model.nowPlaying, state.hasMetadata { - Playing(model: model, state: state, size: size) + Playing(model: model, state: state) } else { // Auch ohne Angaben bleibt das Widget bedienbar: die // Medientasten erreichen jedes Programm. Ein Widget, das bei @@ -56,7 +54,6 @@ private struct Idle: View { private struct Playing: View { let model: MediaModel let state: NowPlaying - let size: WidgetSize var body: some View { HStack(spacing: 10) { @@ -97,7 +94,7 @@ private struct Playing: View { .onTapGesture(count: 2) { model.activateSource() } } - private var artworkSize: CGFloat { size == .wide ? 64 : 56 } + private let artworkSize: CGFloat = 56 } private struct Artwork: View { diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index 8a63b8a..264cedd 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -76,7 +76,7 @@ public final class MetricMenuBarModule: MenuBarModule { /// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit /// SwiftUI müsste man diese Breite doppelt führen — einmal für das Layout und /// einmal für `NSStatusItem.length`. -final class MetricStatusView: NSView { +final class MetricStatusView: NSView, WidthReporting { private let metric: MetricKind private let presentation: MenuBarPresentation @@ -87,21 +87,61 @@ final class MetricStatusView: NSView { init(metric: MetricKind, presentation: MenuBarPresentation) { self.metric = metric self.presentation = presentation - super.init(frame: NSRect(x: 0, y: 0, width: presentation.width, height: 22)) + super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22)) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } override var intrinsicContentSize: NSSize { - NSSize(width: presentation.width, height: 22) + NSSize(width: width, height: 22) } + /// Gemessen statt geschätzt — und zwar an der **breitesten Zeichenfolge, + /// die vorkommen kann**, nicht an der gerade angezeigten. Sonst schiebt + /// jedes Prozent alle Symbole rechts davon hin und her. + private var width: CGFloat { + switch presentation { + case .symbol: MenuBarText.minimumWidth + case .graph: 28 + case .bars: barsWidth + case .value: MenuBarText.width(for: reference) + case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28 + } + } + + /// Ein Balken je Kern braucht Platz für jeden Kern. Bei fünfzehn ist eine + /// feste Breite entweder zu eng oder überall sonst zu weit. + private var barsWidth: CGFloat { + let count = (metric == .cpu && mode == .usage) ? max(snapshot.cpu.perCore.count, 1) : 1 + guard count > 1 else { return MenuBarText.minimumWidth } + return min(CGFloat(count) * 2 + CGFloat(count - 1) * 1 + 4, 60) + } + + /// Die breiteste Zeichenfolge dieser Metrik. + private var reference: String { + switch (metric, mode) { + case (.temperature, _), (_, .temperature): "100 °C" + default: "100 %" + } + } + + /// Meldet, wenn sich die nötige Breite geändert hat — der Controller stellt + /// das Element danach neu ein. + var onWidthChange: ((CGFloat) -> Void)? + func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) { + let before = width self.snapshot = snapshot self.history = history self.mode = mode needsDisplay = true + // Die Kernzahl steht erst nach der ersten Messung fest, und der Modus + // wechselt zwischen „100 %" und „100 °C". + if width != before { + invalidateIntrinsicContentSize() + onWidthChange?(width) + } } override func draw(_ dirtyRect: NSRect) { @@ -129,16 +169,7 @@ final class MetricStatusView: NSView { } private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) { - let attributes: [NSAttributedString.Key: Any] = [ - // Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung. - .font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular), - .foregroundColor: color, - ] - let string = NSAttributedString(string: text, attributes: attributes) - let area = rect ?? bounds - let size = string.size() - string.draw(at: NSPoint(x: area.midX - size.width / 2, - y: area.midY - size.height / 2)) + MenuBarText.draw(text, color: color, in: rect ?? bounds) } private func drawSymbol(color: NSColor) { @@ -193,20 +224,6 @@ final class MetricStatusView: NSView { } extension MenuBarPresentation { - /// Feste Breite je Darstellungsart. - /// - /// Ohne sie ändert das Element bei jedem Messwert seine Größe und schiebt - /// alle Symbole rechts davon hin und her — das fällt in der Menüleiste - /// sofort unangenehm auf. - var width: CGFloat { - switch self { - case .value: 42 - case .symbol: 22 - case .graph: 34 - case .bars: 30 - case .valueAndGraph: 74 - } - } } public extension MetricSummary { diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index fa88343..278797d 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -11,7 +11,6 @@ public struct MetricWidget: OnyxWidget { String(localized: .init(metric.localizationKey), bundle: .module) } public var symbolName: String { metric.symbolName } - public let supportedSizes: [WidgetSize] private let model: MetricsModel @@ -25,11 +24,10 @@ public struct MetricWidget: OnyxWidget { case .battery: "battery" case .temperature: "sensors" } - self.supportedSizes = metric == .temperature ? [.medium, .large] : [.small, .medium] } - public func makeView(size: WidgetSize) -> AnyView { - AnyView(MetricWidgetView(model: model, metric: metric, size: size)) + public func makeView() -> AnyView { + AnyView(MetricWidgetView(model: model, metric: metric)) } } @@ -48,17 +46,15 @@ extension MetricKind { private struct MetricWidgetView: View { let model: MetricsModel let metric: MetricKind - let size: WidgetSize @State private var token: UUID? var body: some View { Group { - switch (metric, size) { - case (.temperature, _): - SensorList(snapshot: model.snapshot, detailed: size == .large) - case (_, .small): - CompactMetric(model: model, metric: metric) - default: + // Die Kachel ist hochkant: die Sensorliste hat Platz für Details, + // alles andere zeigt Zahl und Verlauf. + if metric == .temperature { + SensorList(snapshot: model.snapshot, detailed: true) + } else { WideMetric(model: model, metric: metric) } } @@ -119,13 +115,18 @@ private struct WideMetric: View { let metric: MetricKind var body: some View { - HStack(spacing: 10) { + // Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein + // Verlauf über die volle Breite liest sich besser als einer, der sich + // die Zeile mit der Zahl teilt. + VStack(alignment: .leading, spacing: 8) { CompactMetric(model: model, metric: metric) - .frame(width: 84) + Spacer(minLength: 0) Sparkline(values: model.series(metric), tint: MetricSummary.color(metric, model.snapshot, mode: model.displayMode(for: metric))) + .frame(height: 46) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift index ecfd4d8..e3a4977 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift @@ -73,15 +73,28 @@ final class NetworkStatusView: NSView { /// Breiter als die Hardwaremodule: „↓ 2,4 MB/s" braucht schlicht mehr Platz /// als „46 %". + /// Gemessen an der breitesten Zeichenfolge, die vorkommen kann. + /// + /// „↓ 999,9 MB/s" ist der Extremfall — meistens steht dort „↓ 12 KB/s". + /// Vorher waren 76 Punkte fest verdrahtet, und daneben blieb dauerhaft + /// Platz für ein weiteres Symbol frei, in dem nichts stand. static func width(_ presentation: MenuBarPresentation) -> CGFloat { switch presentation { - case .symbol: 22 - case .graph: 34 - case .bars: 30 - case .value, .valueAndGraph: 76 + case .symbol: MenuBarText.minimumWidth + case .graph, .bars: 28 + case .value: ratesWidth + case .valueAndGraph: ratesWidth + 26 } } + /// Die Raten stehen zweizeilig in 9 pt — dafür misst `MenuBarText` mit + /// seiner 11-pt-Schrift zu breit, also hier eigens gemessen. + private static var ratesWidth: CGFloat { + let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular) + let widest = NSAttributedString(string: "↓ 999,9 MB/s", attributes: [.font: font]) + return ceil(widest.size().width) + 2 * MenuBarText.sidePadding + } + override var intrinsicContentSize: NSSize { NSSize(width: Self.width(presentation), height: 22) } diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift index ab8d89c..afeae43 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift @@ -126,20 +126,18 @@ public struct NetworkWidget: OnyxWidget { public let id = "network" public var displayName: String { String(localized: "network.name", bundle: .module) } public let symbolName = "network" - public let supportedSizes: [WidgetSize] = [.small, .medium, .large] private let model: NetworkModel public init(model: NetworkModel) { self.model = model } - public func makeView(size: WidgetSize) -> AnyView { - AnyView(NetworkWidgetView(model: model, size: size)) + public func makeView() -> AnyView { + AnyView(NetworkWidgetView(model: model)) } } private struct NetworkWidgetView: View { let model: NetworkModel - let size: WidgetSize @State private var token: UUID? var body: some View { @@ -154,17 +152,13 @@ private struct NetworkWidgetView: View { .lineLimit(1) } - Rates(snapshot: model.snapshot, compact: size == .small) + Rates(snapshot: model.snapshot, compact: false) - if size != .small { - Sparkline(values: model.series(download: true), tint: Onyx.Color.accent) - .frame(height: 18) - } + Sparkline(values: model.series(download: true), tint: Onyx.Color.accent) + .frame(height: 18) - if size == .large { - Divider().overlay(Onyx.Color.hairline) - Details(snapshot: model.snapshot) - } + Divider().overlay(Onyx.Color.hairline) + Details(snapshot: model.snapshot) Spacer(minLength: 0) } diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift index de73675..cd6ecfc 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift @@ -42,6 +42,7 @@ public final class MenuBarController { } public func stop() { + closeOpenPopover() visibilityTimer?.invalidate() visibilityTimer = nil for id in entries.keys { setEnabled(false, forModuleID: id) } @@ -78,6 +79,12 @@ public final class MenuBarController { apply(entry.settings, toModuleID: id) } + /// Welches Popover gerade offen ist. Höchstens eines. + private var openPopoverID: String? + private var globalMonitor: Any? + private var localMonitor: Any? + private var keyMonitor: Any? + public func settings(forModuleID id: String) -> MenuBarModuleSettings? { entries[id]?.settings } @@ -100,7 +107,14 @@ public final class MenuBarController { button.sendAction(on: [.leftMouseUp, .rightMouseUp]) button.identifier = NSUserInterfaceItemIdentifier(id) } - entry.item.length = view.fittingSize.width > 0 ? view.fittingSize.width : NSStatusItem.variableLength + applyWidth(view.intrinsicContentSize.width, to: entry) + // Die nötige Breite steht nicht von Anfang an fest: die Kernzahl + // kennt das Modul erst nach der ersten Messung, und ein Moduswechsel + // ändert „100 %" in „100 °C". + (view as? WidthReporting)?.onWidthChange = { [weak self] width in + guard let self, let entry = entries[id] else { return } + applyWidth(width, to: entry) + } entry.module.activate() } else { entry.module.deactivate() @@ -108,20 +122,78 @@ public final class MenuBarController { checkVisibility() } + private func applyWidth(_ width: CGFloat, to entry: Entry) { + entry.item.length = width > 0 ? width : NSStatusItem.variableLength + } + // MARK: - Interaktion @objc private func statusItemClicked(_ sender: NSStatusBarButton) { guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return } - if entry.popover.isShown { - entry.popover.performClose(nil) + // Immer nur eines offen. Zwei Popover nebeneinander sehen aus wie ein + // Fehler, und das zweite verdeckt oft das erste. + if openPopoverID == id { + closeOpenPopover() return } + closeOpenPopover() + entry.popover.contentViewController = NSHostingController( rootView: entry.module.makePopoverView()) entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) // Ohne das bleibt das Popover hinter dem aktiven Fenster. entry.popover.contentViewController?.view.window?.makeKey() + openPopoverID = id + startWatchingForOutsideClicks() + } + + // MARK: - Schließen bei Klick daneben + + /// `NSPopover.behavior = .transient` genügt hier **nicht**. + /// + /// Es schließt zuverlässig, solange die eigene App aktiv ist. Onyx läuft + /// aber als `.accessory` und wird durch einen Klick auf ein Statuselement + /// nicht aktiviert — ein Klick in ein fremdes Fenster erreicht das Popover + /// dann gar nicht, und es bleibt offen stehen. Ein globaler Beobachter sieht + /// solche Klicks trotzdem. + private func startWatchingForOutsideClicks() { + guard globalMonitor == nil else { return } + globalMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in + Task { @MainActor in self?.closeOpenPopover() } + } + // Der globale Beobachter sieht keine Klicks in die eigene App — dafür + // der lokale. Er reicht das Ereignis weiter, damit Knöpfe im Popover + // weiterhin funktionieren. + localMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + guard let self, let id = openPopoverID, let entry = entries[id] else { return event } + // Klicks **im** Popover schließen es nicht. + if event.window !== entry.popover.contentViewController?.view.window, + event.window !== entry.item.button?.window { + closeOpenPopover() + } + return event + } + // Auch die Escape-Taste und ein Bildschirmwechsel beenden es. + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == 53 { self?.closeOpenPopover(); return nil } + return event + } + } + + private func closeOpenPopover() { + if let id = openPopoverID, let entry = entries[id] { + entry.popover.performClose(nil) + } + openPopoverID = nil + globalMonitor.map(NSEvent.removeMonitor) + localMonitor.map(NSEvent.removeMonitor) + keyMonitor.map(NSEvent.removeMonitor) + globalMonitor = nil + localMonitor = nil + keyMonitor = nil } private func checkVisibility() { diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift new file mode 100644 index 0000000..2a9c8bd --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift @@ -0,0 +1,67 @@ +import AppKit + +/// Beschriftung und Breite eines Menüleisten-Elements. +/// +/// Die Breite wird **gemessen**, nicht geschätzt. Vorher stand je Darstellungsart +/// eine feste Zahl im Code, großzügig gewählt, damit nichts abgeschnitten wird — +/// mit dem Ergebnis, dass neben jedem Wert Platz für ein weiteres Symbol blieb, +/// in dem nichts stand. In einer Menüleiste ist das der teuerste Platz des +/// ganzen Bildschirms. +/// +/// Gemessen wird die **breiteste Zeichenfolge, die vorkommen kann**, nicht die +/// gerade angezeigte. Sonst ändert das Element bei jeder Messung seine Größe und +/// schiebt alle Symbole rechts davon hin und her. +public enum MenuBarText { + + /// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung. + /// + /// Als berechnete Eigenschaft, nicht als gespeicherte: `NSFont` ist nicht + /// `Sendable`, und die Schrift jedes Mal neu zu erfragen kostet nichts — + /// AppKit gibt dieselbe zwischengespeicherte Instanz zurück. + public static var font: NSFont { + NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + } + + public static let sidePadding: CGFloat = 2 + public static let symbolWidth: CGFloat = 13 + /// Abstand zwischen Symbol und Zahl. + public static let innerGap: CGFloat = 2 + /// Schmaler geht nicht, ohne dass ein Glyph anstößt. + public static let minimumWidth: CGFloat = 20 + + /// Die Breite eines Elements aus gemessener Textbreite. Rein, damit die + /// Rechnung prüfbar bleibt. + public static func itemWidth(textWidth: CGFloat, includesSymbol: Bool) -> CGFloat { + let symbol = includesSymbol ? symbolWidth + innerGap : 0 + let content = textWidth > 0 ? ceil(textWidth) + 2 * sidePadding : 2 * sidePadding + return max(minimumWidth, content + symbol) + } + + public static func width(of text: String) -> CGFloat { + guard !text.isEmpty else { return 0 } + return NSAttributedString(string: text, attributes: [.font: font]).size().width + } + + /// Die Breite für die breiteste vorkommende Zeichenfolge. + public static func width(for reference: String, includesSymbol: Bool = false) -> CGFloat { + itemWidth(textWidth: width(of: reference), includesSymbol: includesSymbol) + } + + public static func draw(_ text: String, color: NSColor, in rect: NSRect) { + let string = NSAttributedString(string: text, + attributes: [.font: font, .foregroundColor: color]) + let size = string.size() + string.draw(at: NSPoint(x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2)) + } +} + +/// Eine Statusansicht, deren nötige Breite sich ändern kann. +/// +/// Der Controller stellt das Statuselement daraufhin neu ein. Ohne diesen Weg +/// bliebe ein Element in der Breite stehen, die beim allerersten Zeichnen +/// gebraucht wurde — vor der ersten Messung also. +@MainActor +public protocol WidthReporting: AnyObject { + var onWidthChange: ((CGFloat) -> Void)? { get set } +} diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift index 2d75be1..21bc9be 100644 --- a/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift @@ -2,58 +2,25 @@ import Foundation import CoreGraphics import OnyxDesign -/// Die Größen, in denen ein Widget im Panel auftreten darf. -public enum WidgetSize: String, Codable, Sendable, CaseIterable, Identifiable { - /// 1 × 1 — ein Messwert, ein Ring. - case small - /// 2 × 1 — Messwert mit Verlauf, kurze Liste. - case medium - /// 2 × 2 — Mini-Monat, Sensorliste. - case large - /// 4 × 1 — Medienzeile über die volle Breite. - case wide - - public var id: String { rawValue } - - public var columnSpan: Int { - switch self { - case .small: 1 - case .medium, .large: 2 - case .wide: 4 - } - } - - public var rowSpan: Int { - switch self { - case .small, .medium, .wide: 1 - case .large: 2 - } - } -} - -/// Ein Widget an seinem Platz im Layout, wie der Nutzer es angeordnet hat. +/// Ein Widget an seinem Platz im Layout, in der Reihenfolge, die der Nutzer +/// festgelegt hat. +/// +/// Ohne Größe: alle Kacheln sind gleich groß. Eine Größenauswahl klingt nach +/// Freiheit und bringt vor allem Löcher — ein 2×1 neben einem 2×2 lässt oben +/// rechts eine Lücke, die niemand füllen kann und die aussieht wie ein Fehler. public struct WidgetPlacement: Codable, Equatable, Identifiable, Sendable { public let id: UUID public var widgetID: String - public var size: WidgetSize - public init(id: UUID = UUID(), widgetID: String, size: WidgetSize) { + public init(id: UUID = UUID(), widgetID: String) { self.id = id self.widgetID = widgetID - self.size = size - } -} - -public struct GridCell: Hashable, Sendable, CustomStringConvertible { - public let row: Int - public let column: Int - - public init(row: Int, column: Int) { - self.row = row - self.column = column } - public var description: String { "(\(row),\(column))" } + /// Ältere Layouts trugen noch eine Größe. Sie wird beim Lesen verworfen, + /// statt die Datei für ungültig zu erklären — sonst stünde nach dem + /// Aktualisieren ein leeres Panel da. + private enum CodingKeys: String, CodingKey { case id, widgetID } } /// Ein Widget mit ausgerechneter Rasterposition. @@ -63,92 +30,67 @@ public struct ResolvedPlacement: Equatable, Identifiable, Sendable { public let column: Int public var id: UUID { placement.id } - public var columnSpan: Int { placement.size.columnSpan } - public var rowSpan: Int { placement.size.rowSpan } } -/// Setzt Widgets in ein Raster mit fester Spaltenzahl. +/// Setzt Widgets nebeneinander. +/// +/// Das Panel wächst in die **Breite**. Es sitzt an der Notch, also am oberen +/// Rand — nach unten zu wachsen bedeckt den Bildschirm, nach rechts und links +/// legt es sich in den ohnehin leeren Streifen neben der Notch. Eine zweite +/// Reihe entsteht erst, wenn der Bildschirm keine weitere Kachel mehr hergibt. public enum LayoutEngine { - public static let columns = 4 - /// Kantenlänge eines 1×1-Feldes. - public static let cellSize: CGFloat = 96 - - /// Ordnet die Widgets in der gespeicherten Reihenfolge ein, jedes an die - /// erste Stelle, an die es passt. + /// Die eine Kachelgröße. /// - /// Der Reihe nach von links oben zu suchen statt stur hintereinander zu - /// setzen ist der ganze Unterschied: neben einem 2×2-Widget bleiben rechts - /// zwei 1×1-Plätze frei, und ein Verfahren, das immer nur hinten anfügt, - /// lässt sie dauerhaft leer. - public static func resolve(_ placements: [WidgetPlacement]) -> [ResolvedPlacement] { - var occupied = Set() - var resolved: [ResolvedPlacement] = [] + /// Hochkant, weil fast jedes Widget eine Liste zeigt: Termine, Sensoren, + /// Programme im Mischpult. Breiter als hoch wäre für ein einziges davon + /// besser und für alle anderen schlechter. + public static let tileSize = CGSize(width: 180, height: 200) + public static var tileSpacing: CGFloat { Onyx.Metric.tileSpacing } + private static var padding: CGFloat { Onyx.Metric.panelPadding } - for placement in placements { - let span = placement.size - // Zu breit für das Raster: einpassen statt verwerfen. Ein Widget - // verschwinden zu lassen wäre für den Nutzer nicht erklärbar. - let columnSpan = min(span.columnSpan, columns) - - guard let cell = firstFreeCell(columnSpan: columnSpan, - rowSpan: span.rowSpan, - occupied: occupied) else { continue } - - for row in cell.row..<(cell.row + span.rowSpan) { - for column in cell.column..<(cell.column + columnSpan) { - occupied.insert(GridCell(row: row, column: column)) - } - } - resolved.append(ResolvedPlacement(placement: placement, - row: cell.row, - column: cell.column)) - } - return resolved + /// Wie viele Kacheln nebeneinander passen. + public static func columns(availableWidth: CGFloat) -> Int { + let usable = availableWidth - 2 * padding + tileSpacing + let fitting = Int(usable / (tileSize.width + tileSpacing)) + // Mindestens eine: lieber ein Panel, das über den Rand ragt, als eine + // Division durch null oder ein Panel ganz ohne Inhalt. + return max(1, fitting) } - private static func firstFreeCell(columnSpan: Int, - rowSpan: Int, - occupied: Set) -> GridCell? { - let maxRow = (occupied.map(\.row).max() ?? -1) + rowSpan + 1 - - for row in 0...maxRow { - for column in 0...(columns - columnSpan) { - let fits = (row..<(row + rowSpan)).allSatisfy { r in - (column..<(column + columnSpan)).allSatisfy { c in - !occupied.contains(GridCell(row: r, column: c)) - } - } - if fits { return GridCell(row: row, column: column) } - } + public static func resolve(_ placements: [WidgetPlacement], + availableWidth: CGFloat) -> [ResolvedPlacement] { + let columnCount = columns(availableWidth: availableWidth) + return placements.enumerated().map { index, placement in + ResolvedPlacement(placement: placement, + row: index / columnCount, + column: index % columnCount) } - return nil } /// Die Größe, die das Panel für dieses Layout braucht. - public static func panelSize(for resolved: [ResolvedPlacement]) -> CGSize { - let spacing = Onyx.Metric.tileSpacing - let padding = Onyx.Metric.panelPadding - - let rows = resolved.map { $0.row + $0.rowSpan }.max() ?? 1 - let usedRows = max(rows, 1) + public static func panelSize(for placements: [WidgetPlacement], + availableWidth: CGFloat) -> CGSize { + let columnCount = columns(availableWidth: availableWidth) + // Ein leeres Layout bekommt trotzdem eine Kachelfläche: ein Panel ohne + // Ausdehnung wäre ein unsichtbares Panel, und der Nutzer hielte das + // für einen Absturz. + let count = max(placements.count, 1) + let used = min(count, columnCount) + let rows = Int(ceil(Double(count) / Double(columnCount))) return CGSize( - width: CGFloat(columns) * cellSize + CGFloat(columns - 1) * spacing + 2 * padding, - height: CGFloat(usedRows) * cellSize + CGFloat(usedRows - 1) * spacing + 2 * padding) + width: CGFloat(used) * tileSize.width + + CGFloat(used - 1) * tileSpacing + 2 * padding, + height: CGFloat(rows) * tileSize.height + + CGFloat(rows - 1) * tileSpacing + 2 * padding) } - /// Der Rahmen eines Widgets innerhalb des Panels, in SwiftUI-Koordinaten - /// (Ursprung oben links). - public static func frame(for item: ResolvedPlacement, in panelSize: CGSize) -> CGRect { - let spacing = Onyx.Metric.tileSpacing - let padding = Onyx.Metric.panelPadding - - let x = padding + CGFloat(item.column) * (cellSize + spacing) - let y = padding + CGFloat(item.row) * (cellSize + spacing) - let width = CGFloat(item.columnSpan) * cellSize + CGFloat(item.columnSpan - 1) * spacing - let height = CGFloat(item.rowSpan) * cellSize + CGFloat(item.rowSpan - 1) * spacing - - return CGRect(x: x, y: y, width: width, height: height) + /// Der Rahmen einer Kachel im Panel, in SwiftUI-Koordinaten (oben links). + public static func frame(for item: ResolvedPlacement) -> CGRect { + CGRect(x: padding + CGFloat(item.column) * (tileSize.width + tileSpacing), + y: padding + CGFloat(item.row) * (tileSize.height + tileSpacing), + width: tileSize.width, + height: tileSize.height) } } diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift index bccfe47..661e117 100644 --- a/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift @@ -62,15 +62,9 @@ public struct LayoutStore: Sendable { /// wenige Widgets, die sofort etwas zeigen, als ein volles Panel, das /// erst konfiguriert werden will. public static func defaultLayout(knownWidgetIDs: Set) -> [WidgetPlacement] { - let preferred: [(String, WidgetSize)] = [ - ("calendar", .large), - ("weather", .medium), - ("cpu", .small), - ("battery", .small), - ("media", .wide), - ] + let preferred = ["calendar", "weather", "media", "cpu", "battery"] return preferred - .filter { knownWidgetIDs.contains($0.0) } - .map { WidgetPlacement(widgetID: $0.0, size: $0.1) } + .filter { knownWidgetIDs.contains($0) } + .map { WidgetPlacement(widgetID: $0) } } } diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift index d6f8a09..e1e5ec7 100644 --- a/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift @@ -13,10 +13,9 @@ public protocol OnyxWidget: Identifiable, Sendable { var displayName: String { get } var symbolName: String { get } - /// Welche Größen dieses Widget sinnvoll ausfüllen kann. - var supportedSizes: [WidgetSize] { get } - - @ViewBuilder func makeView(size: WidgetSize) -> AnyView + /// Der Inhalt der Kachel. Alle Kacheln sind gleich groß — was ein Widget + /// zeigt, entscheidet es selbst, nicht eine Größenauswahl. + @ViewBuilder func makeView() -> AnyView /// Einstellungen dieses Widgets, falls es welche hat. @ViewBuilder func makeSettingsView() -> AnyView? } diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift index 3413503..cd6cb80 100644 --- a/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift @@ -11,18 +11,22 @@ public struct WidgetGrid: View { private let resolved: [ResolvedPlacement] private let panelSize: CGSize - public init(placements: [WidgetPlacement]) { - self.resolved = LayoutEngine.resolve(placements) - self.panelSize = LayoutEngine.panelSize(for: resolved) + public init(placements: [WidgetPlacement], availableWidth: CGFloat) { + self.resolved = LayoutEngine.resolve(placements, availableWidth: availableWidth) + self.panelSize = LayoutEngine.panelSize(for: placements, availableWidth: availableWidth) } public var body: some View { ZStack(alignment: .topLeading) { ForEach(resolved) { item in - let frame = LayoutEngine.frame(for: item, in: panelSize) + let frame = LayoutEngine.frame(for: item) widgetView(for: item) .frame(width: frame.width, height: frame.height) + // Beschneiden ist Pflicht, kein Feinschliff: quillt der + // Inhalt über, zeichnet er über die Nachbarkachel — und + // ganz oben über die Notch. + .clipped() .offset(x: frame.minX, y: frame.minY) } } @@ -32,7 +36,7 @@ public struct WidgetGrid: View { @ViewBuilder private func widgetView(for item: ResolvedPlacement) -> some View { if let widget = WidgetRegistry.shared.widget(id: item.placement.widgetID) { - OnyxTile { widget.makeView(size: item.placement.size) } + OnyxTile { widget.makeView() } } else { // Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch, // ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall. @@ -50,7 +54,8 @@ public struct WidgetGrid: View { /// Die Größe, die das Panel für dieses Layout braucht — der Controller /// stellt das Fenster darauf ein. - public static func panelSize(for placements: [WidgetPlacement]) -> CGSize { - LayoutEngine.panelSize(for: LayoutEngine.resolve(placements)) + public static func panelSize(for placements: [WidgetPlacement], + availableWidth: CGFloat) -> CGSize { + LayoutEngine.panelSize(for: placements, availableWidth: availableWidth) } } diff --git a/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift index 255996f..d7cf18c 100644 --- a/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift +++ b/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift @@ -67,11 +67,10 @@ public struct AirDropWidget: OnyxWidget { public let id = "airdrop" public var displayName: String { String(localized: "airdrop.name", bundle: .module) } public let symbolName = "shareplay" - public let supportedSizes: [WidgetSize] = [.small, .medium] public init() {} - public func makeView(size: WidgetSize) -> AnyView { + public func makeView() -> AnyView { AnyView(AirDropView()) } } diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift index ec8ddff..b5b32de 100644 --- a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift @@ -191,13 +191,12 @@ public struct ShelfWidget: OnyxWidget { public let id = "shelf" public var displayName: String { String(localized: "shelf.name", bundle: .module) } public let symbolName = "tray.full" - public let supportedSizes: [WidgetSize] = [.medium, .large] private let store: ShelfStore public init(store: ShelfStore) { self.store = store } - public func makeView(size: WidgetSize) -> AnyView { - AnyView(ShelfView(store: store, compact: size == .medium)) + public func makeView() -> AnyView { + AnyView(ShelfView(store: store)) } } diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift index 14ca6fe..4805ffa 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -101,20 +101,18 @@ public struct WeatherWidget: OnyxWidget { public let id = "weather" public var displayName: String { String(localized: "widget.weather.name", bundle: .module) } public let symbolName = "cloud.sun" - public let supportedSizes: [WidgetSize] = [.small, .medium] private let model: WeatherModel public init(model: WeatherModel) { self.model = model } - public func makeView(size: WidgetSize) -> AnyView { - AnyView(WeatherWidgetView(model: model, size: size)) + public func makeView() -> AnyView { + AnyView(WeatherWidgetView(model: model)) } } private struct WeatherWidgetView: View { let model: WeatherModel - let size: WidgetSize var body: some View { Group { @@ -143,11 +141,8 @@ private struct WeatherWidgetView: View { @ViewBuilder private func content(_ snapshot: WeatherSnapshot, isStale: Bool) -> some View { - if size == .small { - CompactWeather(snapshot: snapshot, isStale: isStale) - } else { - WideWeather(snapshot: snapshot, isStale: isStale) - } + // Die Kachel ist hochkant und hat Platz für die ausführliche Fassung. + WideWeather(snapshot: snapshot, isStale: isStale) } } @@ -169,39 +164,6 @@ private struct Notice: View { } } -private struct CompactWeather: View { - let snapshot: WeatherSnapshot - let isStale: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) - .font(.system(size: 18)) - .foregroundStyle(Onyx.Color.textPrimary) - .symbolRenderingMode(.hierarchical) - Spacer(minLength: 0) - Text(snapshot.temperature.onyxFormatted) - .font(Onyx.Font.metric) - .foregroundStyle(Onyx.Color.textPrimary) - Text(snapshot.placeName) - .font(.system(size: 9)) - .foregroundStyle(Onyx.Color.textTertiary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .opacity(isStale ? 0.55 : 1) - .help(helpText) - } - - private var helpText: Text { - // Apples Namensnennung ist Bedingung der Nutzung. In der 1×1-Kachel ist - // dafür kein Platz — sie steht hier und zusätzlich in den Einstellungen. - Text(verbatim: [snapshot.placeName, - snapshot.attribution.map { "Wetterdaten: \($0.name)" }] - .compactMap { $0 }.joined(separator: " · ")) - } -} - private struct WideWeather: View { let snapshot: WeatherSnapshot let isStale: Bool diff --git a/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift b/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift new file mode 100644 index 0000000..3edee00 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift @@ -0,0 +1,51 @@ +import Testing +import AppKit +@testable import OnyxMenuBar + +@Suite("Breite in der Menüleiste") +struct MenuBarTextTests { + + @Test("Die Breite folgt dem Text und nicht einer Schätzung") + func widthFollowsText() { + // Genau das war der Fehler: eine feste Zahl je Darstellungsart, so + // großzügig, dass neben jedem Wert Platz für ein weiteres Symbol blieb. + let short = MenuBarText.itemWidth(textWidth: 20, includesSymbol: false) + let long = MenuBarText.itemWidth(textWidth: 60, includesSymbol: false) + #expect(long > short) + #expect(short == 20 + 2 * MenuBarText.sidePadding) + } + + @Test("Ein Symbol daneben braucht seinen Platz") + func symbolAddsWidth() { + let without = MenuBarText.itemWidth(textWidth: 40, includesSymbol: false) + let with = MenuBarText.itemWidth(textWidth: 40, includesSymbol: true) + #expect(with - without == MenuBarText.symbolWidth + MenuBarText.innerGap) + } + + @Test("Ohne Text bleibt die Mindestbreite") + func emptyKeepsMinimum() { + // Ein Element ohne Zahl zeigt ein Glyph. Schrumpfte es auf null, + // verschwände es aus der Leiste. + #expect(MenuBarText.itemWidth(textWidth: 0, includesSymbol: false) + == MenuBarText.minimumWidth) + } + + @Test("Die Breite wird aufgerundet") + func roundsUp() { + // Gebrochene Breiten führen zu unscharfem Text, weil die Zeichenfolge + // dann auf einem halben Bildpunkt sitzt. + let width = MenuBarText.itemWidth(textWidth: 20.3, includesSymbol: false) + #expect(width == width.rounded()) + } + + @Test("Ein längerer Verweistext ergibt ein breiteres Element") + func referenceDrivesWidth() { + #expect(MenuBarText.width(for: "100 %") > MenuBarText.width(for: "9 %")) + } + + @Test("Gleich lange Zahlen sind gleich breit") + func digitsAreMonospaced() { + // Ohne feste Ziffernbreite springt die Leiste bei jedem Messwert. + #expect(MenuBarText.width(of: "111") == MenuBarText.width(of: "888")) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift index c858d81..428d80d 100644 --- a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift +++ b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift @@ -1,194 +1,126 @@ import Testing +import Foundation import CoreGraphics @testable import OnyxWidgetKit -// Das Panel-Raster hat vier Spalten. Widgets werden in der gespeicherten -// Reihenfolge eingesetzt, jedes an die erste Stelle, an die es passt. Bleibt -// links eine Lücke, füllt sie das nächste Widget, das hineinpasst — sonst -// stünden im Panel dauernd Löcher, die niemand füllen kann. +// Das Panel wächst in die **Breite**. Widgets stehen nebeneinander, alle gleich +// groß, in der Reihenfolge, die der Nutzer festgelegt hat. Eine zweite Reihe +// entsteht erst, wenn der Bildschirm keine weitere Kachel mehr hergibt. -@Suite("Layout-Engine") +private func placements(_ count: Int) -> [WidgetPlacement] { + (0.. WidgetPlacement { - WidgetPlacement(widgetID: widget, size: size) - } - - // MARK: - Grundlegendes Setzen - - @Test("Ein kleines Widget landet oben links") - func singleSmall() { - let resolved = LayoutEngine.resolve([placement("cpu", .small)]) - - #expect(resolved.count == 1) - #expect(resolved[0].column == 0) - #expect(resolved[0].row == 0) - } - - @Test("Vier kleine Widgets füllen genau die erste Zeile") - func fourSmallFillFirstRow() { - let resolved = LayoutEngine.resolve((0..<4).map { placement("w\($0)", .small) }) - - #expect(resolved.map(\.row) == [0, 0, 0, 0]) + @Test("Widgets stehen nebeneinander, nicht untereinander") + func sideBySide() { + let resolved = LayoutEngine.resolve(placements(4), availableWidth: wideScreen) + #expect(resolved.allSatisfy { $0.row == 0 }) #expect(resolved.map(\.column) == [0, 1, 2, 3]) } - @Test("Das fünfte kleine Widget rutscht in die zweite Zeile") - func fifthWraps() { - let resolved = LayoutEngine.resolve((0..<5).map { placement("w\($0)", .small) }) - - #expect(resolved[4].row == 1) - #expect(resolved[4].column == 0) + @Test("Die Reihenfolge des Nutzers bleibt die Reihenfolge im Panel") + func keepsOrder() { + let items = [WidgetPlacement(widgetID: "wetter"), + WidgetPlacement(widgetID: "kalender"), + WidgetPlacement(widgetID: "medien")] + let resolved = LayoutEngine.resolve(items, availableWidth: wideScreen) + #expect(resolved.map(\.placement.widgetID) == ["wetter", "kalender", "medien"]) } - @Test("Ein breites Widget belegt alle vier Spalten") - func wideTakesFullRow() { - let resolved = LayoutEngine.resolve([placement("media", .wide)]) - - #expect(resolved[0].column == 0) - #expect(resolved[0].columnSpan == 4) + @Test("Alle Kacheln sind gleich groß") + func allTilesSameSize() { + let resolved = LayoutEngine.resolve(placements(5), availableWidth: wideScreen) + let sizes = Set(resolved.map { LayoutEngine.frame(for: $0).size.debugDescription }) + #expect(sizes.count == 1) } - @Test("Ein hohes Widget belegt zwei Zeilen") - func largeSpansTwoRows() { - let resolved = LayoutEngine.resolve([placement("calendar", .large)]) - - #expect(resolved[0].columnSpan == 2) - #expect(resolved[0].rowSpan == 2) - } - - // MARK: - Packen - - @Test("Ein breites Widget passt nicht neben ein kleines und beginnt eine neue Zeile") - func wideStartsNewRowWhenBlocked() { - let resolved = LayoutEngine.resolve([ - placement("cpu", .small), - placement("media", .wide), - ]) - - #expect(resolved[0].row == 0) - #expect(resolved[1].row == 1) - #expect(resolved[1].column == 0) - } - - @Test("Kleine Widgets füllen die Lücke neben einem hohen Widget") - func smallFillsGapBesideLarge() { - // Ein 2×2-Widget links lässt rechts zwei freie 1×1-Plätze in Zeile 0 - // und zwei in Zeile 1 — die dürfen nicht verloren gehen. - let resolved = LayoutEngine.resolve([ - placement("calendar", .large), - placement("cpu", .small), - placement("ram", .small), - placement("gpu", .small), - ]) - - #expect(resolved[1].row == 0) - #expect(resolved[1].column == 2) - #expect(resolved[2].row == 0) - #expect(resolved[2].column == 3) - #expect(resolved[3].row == 1) - #expect(resolved[3].column == 2) - } - - @Test("Belegte Felder werden nie doppelt vergeben") - func noOverlaps() { - let resolved = LayoutEngine.resolve([ - placement("a", .large), placement("b", .medium), placement("c", .small), - placement("d", .wide), placement("e", .small), placement("f", .large), - ]) - - var occupied = Set() - for item in resolved { - for row in item.row..<(item.row + item.rowSpan) { - for column in item.column..<(item.column + item.columnSpan) { - let cell = GridCell(row: row, column: column) - #expect(!occupied.contains(cell), "Feld \(cell) doppelt belegt") - occupied.insert(cell) + @Test("Keine zwei Kacheln überlappen sich") + func noOverlap() { + // Der eigentliche Punkt. Genau das war im alten Raster zu sehen: der + // Kalender lag über dem Wetter. Eine Kachel, die eine andere verdeckt, + // ist kein Schönheitsfehler, sondern eine verlorene Auskunft. + for count in 1...12 { + let resolved = LayoutEngine.resolve(placements(count), availableWidth: 1200) + let frames = resolved.map { LayoutEngine.frame(for: $0) } + for (index, frame) in frames.enumerated() { + for other in frames[(index + 1)...] { + #expect(!frame.intersects(other), + "Überlappung bei \(count) Kacheln: \(frame) und \(other)") } } } } - @Test("Nichts ragt über den rechten Rand hinaus") - func nothingOverflowsRight() { - let resolved = LayoutEngine.resolve([ - placement("a", .small), placement("b", .medium), - placement("c", .large), placement("d", .wide), - ]) + @Test("Das Panel wächst in die Breite, nicht in die Höhe") + func growsSideways() { + let one = LayoutEngine.panelSize(for: placements(1), availableWidth: wideScreen) + let four = LayoutEngine.panelSize(for: placements(4), availableWidth: wideScreen) + #expect(four.width > one.width) + #expect(four.height == one.height) + } - for item in resolved { - #expect(item.column + item.columnSpan <= LayoutEngine.columns) + @Test("Erst wenn der Bildschirm voll ist, kommt eine zweite Reihe") + func wrapsOnlyWhenFull() { + // Ein Panel breiter als der Bildschirm wäre schlimmer als eine zweite + // Reihe: die Hälfte hinge außerhalb. + // Genau so breit, dass zwei Kacheln passen und keine dritte. + let narrow = LayoutEngine.panelSize(for: placements(2), availableWidth: wideScreen).width + let resolved = LayoutEngine.resolve(placements(3), availableWidth: narrow) + #expect(resolved.map(\.row) == [0, 0, 1]) + #expect(resolved.map(\.column) == [0, 1, 0]) + } + + @Test("Das Panel bleibt innerhalb des verfügbaren Platzes") + func neverExceedsAvailableWidth() { + let available: CGFloat = 900 + for count in 1...10 { + let size = LayoutEngine.panelSize(for: placements(count), availableWidth: available) + #expect(size.width <= available) } } - @Test("Die gespeicherte Reihenfolge bleibt erhalten") - func orderIsStable() { - let placements = [placement("a", .small), placement("b", .wide), placement("c", .small)] - let resolved = LayoutEngine.resolve(placements) - - #expect(resolved.map(\.placement.widgetID) == ["a", "b", "c"]) + @Test("Auf einem sehr schmalen Bildschirm bleibt mindestens eine Spalte") + func alwaysAtLeastOneColumn() { + // Lieber ein Panel, das über den Rand ragt, als eine Division durch + // null oder ein Panel ohne Inhalt. + let resolved = LayoutEngine.resolve(placements(2), availableWidth: 10) + #expect(resolved.map(\.column) == [0, 0]) + #expect(resolved.map(\.row) == [0, 1]) } - @Test("Leeres Layout ergibt kein Ergebnis und keinen Absturz") - func emptyLayout() { - #expect(LayoutEngine.resolve([]).isEmpty) - } - - // MARK: - Panelgröße - - @Test("Panelgröße folgt der belegten Zeilenzahl, nicht der Widgetzahl") - func panelSizeFollowsRows() { - let oneRow = LayoutEngine.panelSize(for: LayoutEngine.resolve([ - placement("a", .small), placement("b", .small), - ])) - let twoRows = LayoutEngine.panelSize(for: LayoutEngine.resolve( - (0..<5).map { placement("w\($0)", .small) })) - - #expect(twoRows.height > oneRow.height) - // Die Breite steht fest: vier Spalten, immer. - #expect(twoRows.width == oneRow.width) - } - - @Test("Ein leeres Panel hat trotzdem eine sinnvolle Mindestgröße") - func emptyPanelHasMinimumSize() { - let size = LayoutEngine.panelSize(for: []) - + @Test("Ein leeres Layout ergibt trotzdem ein sichtbares Panel") + func emptyLayoutHasSize() { + let size = LayoutEngine.panelSize(for: [], availableWidth: wideScreen) #expect(size.width > 0) #expect(size.height > 0) } - @Test("Ein hohes Widget allein ergibt zwei Zeilen Höhe") - func largeAloneIsTwoRowsTall() { - let large = LayoutEngine.panelSize(for: LayoutEngine.resolve([placement("a", .large)])) - let small = LayoutEngine.panelSize(for: LayoutEngine.resolve([placement("a", .small)])) - - #expect(large.height > small.height) + @Test("Die Kacheln stehen in gleichem Abstand") + func evenSpacing() { + let resolved = LayoutEngine.resolve(placements(3), availableWidth: wideScreen) + let frames = resolved.map { LayoutEngine.frame(for: $0) } + let gaps = zip(frames, frames.dropFirst()).map { $1.minX - $0.maxX } + #expect(gaps.allSatisfy { abs($0 - LayoutEngine.tileSpacing) < 0.001 }) } - // MARK: - Rahmen im Panel - - @Test("Ein Widget in Spalte 2 sitzt weiter rechts als eines in Spalte 0") - func framesFollowColumns() { - let resolved = LayoutEngine.resolve([ - placement("a", .medium), placement("b", .medium), - ]) - let panel = LayoutEngine.panelSize(for: resolved) - - let first = LayoutEngine.frame(for: resolved[0], in: panel) - let second = LayoutEngine.frame(for: resolved[1], in: panel) - - #expect(second.minX > first.minX) - #expect(first.minY == second.minY) - } - - @Test("Ein breites Widget ist im Rahmen wirklich breiter als ein kleines") - func wideFrameIsWider() { - let wide = LayoutEngine.resolve([placement("a", .wide)]) - let small = LayoutEngine.resolve([placement("a", .small)]) - - let wideFrame = LayoutEngine.frame(for: wide[0], in: LayoutEngine.panelSize(for: wide)) - let smallFrame = LayoutEngine.frame(for: small[0], in: LayoutEngine.panelSize(for: small)) - - #expect(wideFrame.width > smallFrame.width) + @Test("Die Kacheln liegen vollständig im Panel") + func tilesFitInsidePanel() { + // Sonst zeichnet etwas außerhalb — und im Panel heißt außerhalb oben: + // hinter der Notch. + let items = placements(7) + let size = LayoutEngine.panelSize(for: items, availableWidth: 1200) + for item in LayoutEngine.resolve(items, availableWidth: 1200) { + let frame = LayoutEngine.frame(for: item) + #expect(frame.minX >= 0) + #expect(frame.minY >= 0) + #expect(frame.maxX <= size.width + 0.001) + #expect(frame.maxY <= size.height + 0.001) + } } } diff --git a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift index 7e75768..5830329 100644 --- a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift +++ b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift @@ -24,22 +24,21 @@ struct LayoutStoreTests { let url = temporaryURL() let store = LayoutStore(url: url) let layout = [ - WidgetPlacement(widgetID: "calendar", size: .large), - WidgetPlacement(widgetID: "media", size: .wide), + WidgetPlacement(widgetID: "calendar"), + WidgetPlacement(widgetID: "media"), ] try store.save(layout) let loaded = store.load(knownWidgetIDs: known) #expect(loaded.map(\.widgetID) == ["calendar", "media"]) - #expect(loaded.map(\.size) == [.large, .wide]) } @Test("Die Kennung eines Widgets bleibt über das Speichern hinweg dieselbe") func identitySurvives() throws { let url = temporaryURL() let store = LayoutStore(url: url) - let placement = WidgetPlacement(widgetID: "cpu", size: .small) + let placement = WidgetPlacement(widgetID: "cpu") try store.save([placement]) @@ -71,9 +70,9 @@ struct LayoutStoreTests { let url = temporaryURL() let store = LayoutStore(url: url) try store.save([ - WidgetPlacement(widgetID: "calendar", size: .large), - WidgetPlacement(widgetID: "widget-aus-einer-alten-version", size: .small), - WidgetPlacement(widgetID: "media", size: .wide), + WidgetPlacement(widgetID: "calendar"), + WidgetPlacement(widgetID: "widget-aus-einer-alten-version"), + WidgetPlacement(widgetID: "media"), ]) let loaded = store.load(knownWidgetIDs: known) @@ -87,7 +86,7 @@ struct LayoutStoreTests { // leeren Panel und keiner Erklärung. let url = temporaryURL() let store = LayoutStore(url: url) - try store.save([WidgetPlacement(widgetID: "voellig-unbekannt", size: .small)]) + try store.save([WidgetPlacement(widgetID: "voellig-unbekannt")]) let loaded = store.load(knownWidgetIDs: known) @@ -109,7 +108,7 @@ struct LayoutStoreTests { @Test("Speichern legt fehlende Verzeichnisse an") func savingCreatesDirectories() throws { let url = temporaryURL() - try LayoutStore(url: url).save([WidgetPlacement(widgetID: "cpu", size: .small)]) + try LayoutStore(url: url).save([WidgetPlacement(widgetID: "cpu")]) #expect(FileManager.default.fileExists(atPath: url.path)) }