Files
onyx/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift
Scarriffle 2aa60eedf9 Farben einstellbar — und erst mal überhaupt eine Farbe
Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und
`.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das
betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets
dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt.

Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe
mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu
das Paar für Herunter- und Hochladen samt Tauschknopf.

Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt
Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören;
ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem,
sonst sucht man die Warnfarbe im Reiter und findet sie nirgends.

Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent`
ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`.
Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich
ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen,
Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen.

Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt
las sich das als englisches „J-E".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:24:40 +02:00

216 lines
8.7 KiB
Swift

import AppKit
import OnyxDesign
import SwiftUI
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
///
/// Die Reihenfolge merkt sich macOS selbst über `autosaveName`; Onyx speichert sie
/// nicht doppelt. Was Onyx speichert, ist welche Module an sind und wie sie
/// aussehen sollen.
@MainActor
public final class MenuBarController {
/// Ein Modul, dessen Element keine Breite bekommen hat.
///
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
/// zusätzlich blockiert. Ein Element, das dort nicht mehr hinpasst, wird von
/// macOS kommentarlos weggelassen für den Nutzer sieht das aus, als wäre
/// die Einstellung wirkungslos. Deshalb wird der Fall erkannt und gemeldet.
public private(set) var hiddenModuleIDs: Set<String> = []
/// Wird gerufen, wenn sich `hiddenModuleIDs` ändert die Einstellungen
/// zeigen daraufhin einen Hinweis am betroffenen Modul.
public var onVisibilityChanged: ((Set<String>) -> Void)?
private struct Entry {
let module: any MenuBarModule
let item: NSStatusItem
let popover: NSPopover
var settings: MenuBarModuleSettings
}
private var entries: [String: Entry] = [:]
private var visibilityTimer: Timer?
public init() {}
public func start() {
// Ob ein Element sichtbar ist, meldet AppKit nicht. Es bleibt nur,
// gelegentlich nachzusehen.
visibilityTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.checkVisibility() }
}
}
public func stop() {
closeOpenPopover()
visibilityTimer?.invalidate()
visibilityTimer = nil
for id in entries.keys { setEnabled(false, forModuleID: id) }
}
// MARK: - Module
public func register(_ module: any MenuBarModule, settings: MenuBarModuleSettings) {
guard entries[module.id] == nil else { return }
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.autosaveName = "onyx.menubar.\(module.id)"
item.isVisible = false
let popover = NSPopover()
popover.behavior = .transient
popover.animates = true
entries[module.id] = Entry(module: module, item: item, popover: popover, settings: settings)
apply(settings, toModuleID: module.id)
}
public func setEnabled(_ enabled: Bool, forModuleID id: String) {
guard var entry = entries[id] else { return }
entry.settings.isEnabled = enabled
entries[id] = entry
apply(entry.settings, toModuleID: id)
}
public func setPresentation(_ presentation: MenuBarPresentation, forModuleID id: String) {
guard var entry = entries[id] else { return }
entry.settings.presentation = presentation
entries[id] = entry
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
}
private func apply(_ settings: MenuBarModuleSettings, toModuleID id: String) {
guard let entry = entries[id] else { return }
entry.item.isVisible = settings.isEnabled
if settings.isEnabled {
let view = entry.module.makeStatusView(presentation: settings.presentation)
if let button = entry.item.button {
button.subviews.forEach { $0.removeFromSuperview() }
view.frame = button.bounds
view.autoresizingMask = [.width, .height]
button.addSubview(view)
button.target = self
button.action = #selector(statusItemClicked(_:))
// Beide Tasten annehmen: rechts öffnet später das Kontextmenü.
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.identifier = NSUserInterfaceItemIdentifier(id)
}
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()
}
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 }
// 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()
// Die Akzentfarbe hier und nicht in jedem Popover einzeln: `.tint`
// vererbt sich nach unten, und ohne sie fielen Haken, Regler und
// Auswahlfelder auf die Systemfarbe zurück das war der Grund, warum
// die Popovers grün waren und die Widgets blau.
entry.popover.contentViewController = NSHostingController(
rootView: entry.module.makePopoverView().tint(Onyx.Color.accent))
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() {
var hidden = Set<String>()
for (id, entry) in entries where entry.settings.isEnabled {
// `isVisible` bleibt true, auch wenn das Element mangels Platz nicht
// gezeichnet wird. Der verlässliche Hinweis ist ein Knopf ohne Fenster.
if entry.item.button?.window == nil { hidden.insert(id) }
}
guard hidden != hiddenModuleIDs else { return }
hiddenModuleIDs = hidden
onVisibilityChanged?(hidden)
}
}