SwiftUI zeichnet Symbole in Listen und Formularen auf macOS in der Akzentfarbe des Systems — nicht in der des Fensters. `.tint` an der Wurzel half deshalb nur der Reiterleiste; die Widget-Liste blieb systemgrün neben einer blauen Leiste, obwohl beides derselben Einstellung folgen soll. Ein eigener Label-Stil setzt die Farbe am Symbol. Er vererbt sich über die Umgebung, also genügt eine Zeile an denselben vier Wurzeln, an denen auch der Farbton gesetzt wird: Panel, Popovers, Einstellungen, Einrichtung. Ein Symbol, das seine Farbe selbst mitbringt, behält sie — die innere Angabe schlägt die äußere. Der grüne Haken für „erteilt" bringt sie jetzt am Symbol mit statt am ganzen Label: Grün heißt dort „erteilt" und ist keine Geschmacksfrage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
8.8 KiB
Swift
218 lines
8.8 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)
|
|
.labelStyle(.onyx))
|
|
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)
|
|
}
|
|
}
|