diff --git a/Onyx/NotchAccessories.swift b/Onyx/NotchAccessories.swift new file mode 100644 index 0000000..77fb945 --- /dev/null +++ b/Onyx/NotchAccessories.swift @@ -0,0 +1,315 @@ +import SwiftUI +import OnyxDesign +import OnyxNotch +import ActivityProvider +import MetricsProvider +import WeatherProvider + +/// Was links und rechts der Notch steht, solange das Panel offen ist. +/// +/// **Warum dort und nicht in einer Kachel.** Der Streifen auf Menüleistenhöhe +/// war bisher durchsichtiges, ungenutztes Fenster — bei fünf Kacheln je 376 +/// Punkte. Eine Kachel dafür zu opfern hieße, ein Zwölftel des Panels für zwei +/// Zahlen herzugeben. Hier kosten sie nichts. +/// +/// **Zwei Werte, nicht zehn.** Der Streifen ist so hoch wie die Notch, also +/// rund 38 Punkte. Was hier steht, muss im Vorbeisehen lesbar sein; eine Reihe +/// aus sechs Kürzeln wäre eine zweite Menüleiste, und eine hat man schon. +struct WeatherAccessory: View { + + let model: WeatherModel + + var body: some View { + if case .ready(let snapshot) = model.state { + HStack(spacing: Onyx.Space.sm) { + Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textSecondary) + Text(snapshot.temperature.onyxFormatted) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textPrimary) + } + // Abstand zur Bildschirmkante links, und Luft zur Notch rechts: + // ein Wert, der die schwarze Hardware berührt, sieht aus, als wäre + // er abgeschnitten. + .padding(.leading, Onyx.Space.xl) + .padding(.trailing, Onyx.Space.lg) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct BatteryAccessory: View { + + let model: MetricsModel + + var body: some View { + if let battery = model.snapshot.battery { + HStack(spacing: Onyx.Space.sm) { + Text("\(Int((battery.charge * 100).rounded())) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textPrimary) + BatteryGlyphView(charge: battery.charge, + isCharging: battery.isCharging, + isPluggedIn: battery.isPluggedIn, + height: 13) + } + .padding(.leading, Onyx.Space.lg) + .padding(.trailing, Onyx.Space.xl) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } +} + +// MARK: - Live Activities + +/// Was beim Regeln der Lautstärke unter der Notch steht. +/// +/// Kurz und ohne Anspruch: 1,6 Sekunden. Länger stünde sie noch da, wenn man +/// längst weiterarbeitet — und eine Anzeige, die nach der Handlung übrig +/// bleibt, liest man als Fehler. +struct VolumeActivity: NotchActivity { + + let id = "volume" + let rank = ActivityRank.response + let duration: TimeInterval? = 1.6 + + let level: Double + let isMuted: Bool + + /// Symbol, Schiene, Zahl — in einer Zeile unter der Notch. + var size: CGSize { CGSize(width: 196, height: 34) } + + private var symbol: String { + if isMuted { return "speaker.slash.fill" } + switch level { + case ..<0.001: return "speaker.fill" + case ..<0.34: return "speaker.wave.1.fill" + case ..<0.67: return "speaker.wave.2.fill" + default: return "speaker.wave.3.fill" + } + } + + func makeContent() -> AnyView { + let track: CGFloat = 96 + let fraction = isMuted ? 0 : min(max(level, 0), 1) + return AnyView( + HStack(spacing: Onyx.Space.lg) { + Image(systemName: symbol) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textPrimary) + .frame(width: 16) + + ZStack(alignment: .leading) { + Capsule().fill(Onyx.Color.track) + .frame(width: track, height: 5) + Capsule().fill(Onyx.Color.textPrimary) + .frame(width: track * fraction, height: 5) + } + + Text("\(Int((min(max(level, 0), 1) * 100).rounded()))") + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textSecondary) + // Feste Breite: sonst wandert die Schiene, wenn aus 9 % + // eine 10 wird. + .frame(width: 24, alignment: .trailing) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} + +/// Ein Arm des Bandes — nur ausgefahren, und nur das Zubehör. +/// +/// Die Live Activities hängen unter der Notch und nicht hier: neben ihr müssten +/// beide Arme gleich breit sein, und ein Symbol links neben einem Balken rechts +/// hieße über hundert Punkte Schwarz für das Symbol. +struct NotchBandSlot: View { + + let presentation: NotchPresentation + @ViewBuilder let content: () -> Content + + var body: some View { + if presentation.isExpanded { content() } + } +} + +/// Die Live Activity unter der Notch. +/// +/// Als eigene View und nicht als Verzweigung im Aufrufer: der Presenter ist +/// beobachtbar, und nur innerhalb eines `body` merkt SwiftUI die Abhängigkeit. +struct NotchAmbientSlot: View { + + let presenter: ActivityPresenter + + var body: some View { + if let activity = presenter.current { + activity.makeContent() + } + } +} + + +/// Die Helligkeit des eingebauten Bildschirms. +/// +/// Dieselbe Form wie die Lautstärke — Symbol, Schiene, Zahl. Das ist kein +/// Sparen an Gestaltung, sondern die Aussage: es ist dasselbe Ding. Zwei +/// verschiedene Darstellungen für „ein Regler hat sich bewegt" wären eine +/// Behauptung, dass es zwei verschiedene Vorgänge sind. +struct BrightnessActivity: NotchActivity { + + let id = "brightness" + let rank = ActivityRank.response + let duration: TimeInterval? = 1.6 + + let level: Double + + var size: CGSize { CGSize(width: 196, height: 34) } + + private var symbol: String { + // Die Sonne füllt sich mit — dasselbe Symbol, das macOS benutzt. + level < 0.5 ? "sun.min.fill" : "sun.max.fill" + } + + func makeContent() -> AnyView { + let track: CGFloat = 96 + let fraction = min(max(level, 0), 1) + return AnyView( + HStack(spacing: Onyx.Space.lg) { + Image(systemName: symbol) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textPrimary) + .frame(width: 16) + + ZStack(alignment: .leading) { + Capsule().fill(Onyx.Color.track) + .frame(width: track, height: 5) + Capsule().fill(Onyx.Color.textPrimary) + .frame(width: track * fraction, height: 5) + } + + Text("\(Int((fraction * 100).rounded()))") + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textSecondary) + .frame(width: 24, alignment: .trailing) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} + +// MARK: - Was im Band stehen darf + +/// Die Elemente, die links und rechts der Notch sitzen können. +/// +/// **Eine Wahl je Seite, keine Liste.** Der Arm ist bei fünf Kacheln rund 200 +/// Punkte breit und liegt auf Menüleistenhöhe — dort passt ein Wert, nicht drei. +/// Eine Mehrfachauswahl anzubieten hieße, ein Gedränge zu ermöglichen, das man +/// hinterher wieder wegräumen muss. +enum BandAccessory: String, Codable, CaseIterable, Sendable { + case none + case clock + case weather + case battery + case cpu + case memory + case temperature + + var localizedName: LocalizedStringKey { + switch self { + case .none: "band.none" + case .clock: "band.clock" + case .weather: "band.weather" + case .battery: "band.battery" + case .cpu: "band.cpu" + case .memory: "band.memory" + case .temperature: "band.temperature" + } + } +} + +/// Ein Bandplatz, der zeigt, was dafür eingestellt ist. +/// +/// Liest die Einstellung **im Rumpf** und nicht beim Erzeugen: der Panelinhalt +/// wird je Bildschirm einmal gebaut, und ein beim Bauen gelesener Wert bliebe +/// für immer der, der beim Start galt. +struct BandSlotView: View { + + let model: AppModel + let edge: HorizontalAlignment + let weather: WeatherModel? + let metrics: MetricsModel? + + private var kind: BandAccessory { + edge == .leading ? model.bandLeading : model.bandTrailing + } + + var body: some View { + content + .padding(.leading, edge == .leading ? Onyx.Space.xl : Onyx.Space.lg) + .padding(.trailing, edge == .leading ? Onyx.Space.lg : Onyx.Space.xl) + .frame(maxWidth: .infinity, alignment: edge == .leading ? .leading : .trailing) + } + + @ViewBuilder + private var content: some View { + switch kind { + case .none: + EmptyView() + case .clock: + // Die eine Angabe, die keine Quelle braucht. + TimelineView(.everyMinute) { context in + value(context.date.formatted(date: .omitted, time: .shortened), + symbol: nil) + } + case .weather: + if let weather, case .ready(let snapshot) = weather.state { + value(snapshot.temperature.onyxFormatted, + symbol: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) + } + case .battery: + if let battery = metrics?.snapshot.battery { + HStack(spacing: Onyx.Space.sm) { + Text("\(Int((battery.charge * 100).rounded())) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textPrimary) + BatteryGlyphView(charge: battery.charge, + isCharging: battery.isCharging, + isPluggedIn: battery.isPluggedIn, + height: 13) + } + } + case .cpu: + if let metrics { + value(MetricFormat.percent(metrics.snapshot.cpu.total), symbol: "cpu") + } + case .memory: + if let metrics { + value(MetricFormat.percent(metrics.snapshot.memory.usedFraction), + symbol: "memorychip") + } + case .temperature: + // Der wärmste Fühler: einer von zwanzig auszuwählen wäre eine + // Einstellung in einer Einstellung. + if let hottest = metrics?.snapshot.sensors.map(\.celsius).max() { + value("\(Int(hottest.rounded())) °C", symbol: "thermometer.medium") + } + } + } + + /// Symbol und Wert — dieselbe Form für alles, was hier stehen kann. + /// + /// Damit ist die Seite austauschbar: was links passt, passt auch rechts. + private func value(_ text: String, symbol: String?) -> some View { + HStack(spacing: Onyx.Space.sm) { + if let symbol { + Image(systemName: symbol) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textSecondary) + } + Text(text) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textPrimary) + } + } +} diff --git a/Onyx/PowerActivity.swift b/Onyx/PowerActivity.swift new file mode 100644 index 0000000..e742c43 --- /dev/null +++ b/Onyx/PowerActivity.swift @@ -0,0 +1,127 @@ +import SwiftUI +import ActivityProvider +import MetricsProvider +import OnyxDesign + +/// Was am Strom geschieht — Netzteil dran, ab, Limit erreicht, Akku fast leer. +/// +/// **Nicht bei jedem Messwert, sondern bei jedem Wechsel.** Der Ladestand +/// ändert sich ständig; eine Notch, die das begleitet, wäre eine Leuchtreklame. +/// Angemeldet wird nur, wenn sich der *Zustand* ändert. +struct PowerActivity: NotchActivity { + + /// Was gemeldet wird. + enum Kind: Equatable { + case charging + case plugged + case unplugged + case atLimit(Int) + case low + + var text: String { + switch self { + case .charging: String(localized: "activity.power.charging") + case .plugged: String(localized: "activity.power.plugged") + case .unplugged: String(localized: "activity.power.unplugged") + case .atLimit: String(localized: "activity.power.atLimit") + case .low: String(localized: "activity.power.low") + } + } + + /// „Fast leer" ist das eine, das man nicht verpassen darf. + var rank: Int { self == .low ? ActivityRank.alert : ActivityRank.notice } + + /// Eine Warnung darf länger stehen als eine Beiläufigkeit. + var duration: TimeInterval { self == .low ? 4 : 2.2 } + } + + let id = "power" + let kind: Kind + let charge: Double + let isCharging: Bool + let isPluggedIn: Bool + + var rank: Int { kind.rank } + var duration: TimeInterval? { kind.duration } + var size: CGSize { CGSize(width: 232, height: 34) } + + func makeContent() -> AnyView { + AnyView( + HStack(spacing: Onyx.Space.lg) { + BatteryGlyphView(charge: charge, + isCharging: isCharging, + isPluggedIn: isPluggedIn, + height: 13) + + Text(kind.text) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textPrimary) + .lineLimit(1) + + Spacer(minLength: Onyx.Space.sm) + + Text("\(Int((min(max(charge, 0), 1) * 100).rounded())) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(kind == .low ? Onyx.Color.critical + : Onyx.Color.textSecondary) + } + .padding(.horizontal, Onyx.Space.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} + +/// Was gerade spielt — beim Wechsel des Titels. +/// +/// Ohne Fortschritt und ohne Knöpfe: das steht im Panel. Hier geht es um die +/// eine Frage, die man beim Wechsel hat — was ist das jetzt? +struct MediaActivity: NotchActivity { + + let id = "media" + let rank = ActivityRank.notice + let duration: TimeInterval? = 3 + var size: CGSize { CGSize(width: 264, height: 40) } + + let title: String + let artist: String + let artwork: Data? + + func makeContent() -> AnyView { + AnyView( + HStack(spacing: Onyx.Space.lg) { + Group { + if let artwork, let image = NSImage(data: artwork) { + Image(nsImage: image).resizable() + } else { + // Kein Cover ist kein Fehler — Radiostreams haben keins. + Onyx.Color.elevated + .overlay { + Image(systemName: "music.note") + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + } + } + } + .frame(width: 26, height: 26) + .clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.chip, style: .continuous)) + + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { + Text(title) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textPrimary) + .lineLimit(1) + if !artist.isEmpty { + Text(artist) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal, Onyx.Space.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} diff --git a/Packages/OnyxKit/Sources/ActivityProvider/ActivityPresenter.swift b/Packages/OnyxKit/Sources/ActivityProvider/ActivityPresenter.swift new file mode 100644 index 0000000..01836b9 --- /dev/null +++ b/Packages/OnyxKit/Sources/ActivityProvider/ActivityPresenter.swift @@ -0,0 +1,124 @@ +import SwiftUI +import OnyxDesign +import OnyxNotch + +/// Führt die Live Activities und sagt der Notch, was gerade gilt. +/// +/// Die Entscheidung selbst trifft `ActivityQueue` — hier steht nur, was mit +/// Zeit und Bildschirm zu tun hat: den Wecker für die nächste ablaufende +/// stellen und den Controllern melden, ob überhaupt etwas anliegt. +@MainActor +@Observable +public final class ActivityPresenter { + + /// Die Activity, die gerade gezeichnet wird. + /// + /// Bleibt nach dem Ende noch stehen, solange die Fläche einfährt — sonst + /// wäre die View im selben Bild weg, in dem sich die Notch zurückziehen + /// soll, und es gäbe nichts mehr zu sehen. Was **gilt**, sagt + /// `isPresenting`. + public private(set) var current: (any NotchActivity)? + + /// Ob die Activity noch gilt oder nur noch ausblendet. + public private(set) var isPresenting = false + + /// Wird nach jeder Änderung gerufen — mit dem, was jetzt gilt. + /// + /// **Nicht nur bei „etwas / nichts".** Löst ein zweiter Lautstärkeschritt + /// die erste Activity ab, bleibt „etwas liegt an" gleich, aber die Arme + /// werden vielleicht anders breit. Wer nur auf den Wechsel von nichts zu + /// etwas hört, verpasst das. + @ObservationIgnored public var onChange: (((any NotchActivity)?) -> Void)? + + @ObservationIgnored private var queue = ActivityQueue() + @ObservationIgnored private var activities: [String: any NotchActivity] = [:] + @ObservationIgnored private var alarm: DispatchWorkItem? + @ObservationIgnored private var retirement: DispatchWorkItem? + @ObservationIgnored private let clock: () -> Date + + public init(clock: @escaping () -> Date = Date.init) { + self.clock = clock + } + + /// Meldet eine Activity an. Eine bereits stehende mit derselben Kennung + /// wird ersetzt — samt neuer Standzeit. + public func raise(_ activity: any NotchActivity) { + activities[activity.id] = activity + queue.raise(ActivityTicket(id: activity.id, + rank: activity.rank, + raised: clock(), + duration: activity.duration)) + settle() + } + + public func withdraw(id: String) { + activities[id] = nil + queue.withdraw(id: id) + settle() + } + + /// Die Fläche, die die eingeklappte Notch dafür unter sich aufspannt. + /// + /// `nil`, sobald die Activity nicht mehr gilt — auch wenn sie noch + /// gezeichnet wird. Genau daran fährt die Fläche ein und schneidet den + /// Inhalt dabei weg. + public var contentSize: CGSize? { isPresenting ? current?.size : nil } + + private func settle() { + let now = clock() + queue.prune(now: now) + + let next = queue.current(now: now).flatMap { activities[$0.id] } + if let next { + retirement?.cancel() + retirement = nil + current = next + isPresenting = true + } else if isPresenting { + isPresenting = false + scheduleRetirement() + } + onChange?(isPresenting ? current : nil) + + scheduleAlarm(after: now) + } + + /// Weckt genau dann, wenn die nächste abläuft. + /// + /// Nicht abtasten: eine Notch, die zehnmal je Sekunde nachfragt, ob etwas + /// vorbei ist, hält den Rechner wach — und zwar den ganzen Tag, für ein + /// Ereignis alle paar Minuten. + private func scheduleAlarm(after now: Date) { + alarm?.cancel() + alarm = nil + guard let next = queue.nextExpiry(after: now) else { return } + + let work = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.settle() } + } + alarm = work + DispatchQueue.main.asyncAfter(deadline: .now() + max(next.timeIntervalSince(now), 0), + execute: work) + } +} + +private extension ActivityPresenter { + + /// Räumt die ausgeblendete Activity weg, wenn die Fläche eingefahren ist. + /// + /// Nicht sofort: solange die Notch schrumpft, muss noch etwas darin stehen. + /// Die Dauer kommt aus dem Bewegungsprofil, damit sie bei „ruhig" mitwächst + /// — eine feste Zahl schnitte den Inhalt dort mittendrin ab. + func scheduleRetirement() { + retirement?.cancel() + let work = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + guard let self, !self.isPresenting else { return } + self.current = nil + } + } + retirement = work + DispatchQueue.main.asyncAfter(deadline: .now() + OnyxTheme.shared.motion.collapseDuration, + execute: work) + } +} diff --git a/Packages/OnyxKit/Sources/ActivityProvider/ActivityQueue.swift b/Packages/OnyxKit/Sources/ActivityProvider/ActivityQueue.swift new file mode 100644 index 0000000..e673893 --- /dev/null +++ b/Packages/OnyxKit/Sources/ActivityProvider/ActivityQueue.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Ein Anspruch auf die eingeklappte Notch. +/// +/// Bewusst ohne Darstellung: was eine Live Activity *zeigt*, ist eine View und +/// gehört nicht in die Entscheidung, **welche** gerade gilt. So lässt sich die +/// Regel ohne Bildschirm prüfen — wie `NotchStateMachine` und `LayoutEngine`. +public struct ActivityTicket: Equatable, Sendable, Identifiable { + + public let id: String + /// Höher verdrängt niedriger. + public let rank: Int + /// Wann angemeldet. + public let raised: Date + /// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird. + public let duration: TimeInterval? + + public init(id: String, rank: Int, raised: Date, duration: TimeInterval?) { + self.id = id + self.rank = rank + self.raised = raised + self.duration = duration + } + + /// Wann sie von selbst geht. `nil` bei dauerhaften. + public var expiry: Date? { + duration.map { raised.addingTimeInterval($0) } + } + + public func hasExpired(at now: Date) -> Bool { + guard let expiry else { return false } + return now >= expiry + } +} + +/// Wer die eingeklappte Notch gerade bekommt. +/// +/// **Warum überhaupt eine Warteschlange.** Die Anlässe kommen unabhängig +/// voneinander: die Lautstärke ändert sich, während ein Titel wechselt, während +/// das Netzteil angesteckt wird. Ohne Regel gewinnt, wer zuletzt kam — und dann +/// verschluckt ein beiläufiger Lautstärkeschritt die Meldung, dass der Akku +/// fast leer ist. +/// +/// **Die Regel ist Rang vor Aktualität.** Bei gleichem Rang gilt die jüngere: +/// zwei Lautstärkeschritte hintereinander sollen den zweiten zeigen, nicht den +/// ersten stehen lassen. +public struct ActivityQueue: Equatable, Sendable { + + private var tickets: [ActivityTicket] = [] + + public init() {} + + /// Alles, was gerade anliegt — ungefiltert, in Anmeldereihenfolge. + public var all: [ActivityTicket] { tickets } + + /// Meldet einen Anspruch an. Ein bereits vorhandener mit derselben Kennung + /// wird **ersetzt**, nicht ergänzt. + /// + /// Das ist der Normalfall und keine Ausnahme: wer die Lautstärke dreimal + /// verstellt, meldet dreimal dieselbe Activity an und will dreimal die + /// volle Standzeit — nicht drei Einträge in einer Schlange. + public mutating func raise(_ ticket: ActivityTicket) { + tickets.removeAll { $0.id == ticket.id } + tickets.append(ticket) + } + + public mutating func withdraw(id: String) { + tickets.removeAll { $0.id == id } + } + + /// Wirft weg, was abgelaufen ist. + public mutating func prune(now: Date) { + tickets.removeAll { $0.hasExpired(at: now) } + } + + /// Welche gerade gilt. + public func current(now: Date) -> ActivityTicket? { + tickets + .filter { !$0.hasExpired(at: now) } + // `last` statt `max(by:)`: bei gleichem Rang gewinnt die zuletzt + // angemeldete, und `tickets` steht in Anmeldereihenfolge. + .reduce(into: ActivityTicket?.none) { best, candidate in + guard let current = best else { best = candidate; return } + if candidate.rank >= current.rank { best = candidate } + } + } + + /// Wann das nächste Mal etwas von selbst verschwindet. + /// + /// Der Presenter stellt darauf seinen Wecker. Ohne diese Auskunft müsste er + /// abtasten — und eine Notch, die zehnmal je Sekunde nachfragt, ob etwas + /// abgelaufen ist, hält den Rechner wach. + public func nextExpiry(after now: Date) -> Date? { + tickets.compactMap(\.expiry).filter { $0 > now }.min() + } +} diff --git a/Packages/OnyxKit/Sources/ActivityProvider/NotchActivity.swift b/Packages/OnyxKit/Sources/ActivityProvider/NotchActivity.swift new file mode 100644 index 0000000..a0dd51d --- /dev/null +++ b/Packages/OnyxKit/Sources/ActivityProvider/NotchActivity.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// Was die eingeklappte Notch zeigt. +/// +/// **Unter der Notch, nicht daneben.** Neben ihr wären die Arme zwangsläufig +/// gleich breit — die Silhouette zeichnet den Steg mittig um die Hardware, und +/// verschieden breite Arme schöben die Lücke aus ihr heraus. Ein Symbol links +/// und ein Balken rechts hieße dann 132 Punkte Schwarz für ein Symbol. Darunter +/// hängt die Fläche frei und wird genau so breit, wie ihr Inhalt es braucht. +/// +/// **Die Größe steht im Protokoll, sie wird nicht gemessen.** Eine Notch, die +/// sich an ihren Inhalt anpasst, müsste ihn erst zeichnen, seine Größe +/// zurückmelden und sich dann bewegen — das ist ein Bild zu spät und sieht als +/// Ruckeln aus. Wer eine Activity schreibt, weiß, wie groß sie wird. +@MainActor +public protocol NotchActivity: Sendable { + + /// Stabil: eine erneute Anmeldung mit derselben Kennung ersetzt die alte, + /// statt sich anzureihen. + var id: String { get } + + /// Höher verdrängt niedriger. + var rank: Int { get } + + /// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird. + var duration: TimeInterval? { get } + + /// Die Fläche **unter** der Notch. Ist sie schmaler als die Notch, gibt + /// diese das Maß vor — sonst hinge unter dem Steg ein schmalerer Zipfel. + var size: CGSize { get } + + func makeContent() -> AnyView +} + +/// Die Ränge, damit sie an einer Stelle stehen und nicht als Zahlen im Code. +/// +/// Die Ordnung ist keine Geschmacksfrage: was der Nutzer **selbst gerade +/// ausgelöst** hat, muss er sehen — sonst wirkt der Regler kaputt. Was ihn +/// warnt, steht darüber, weil er es sonst verpasst. Was nur begleitet, steht +/// ganz unten. +public enum ActivityRank { + /// Läuft im Hintergrund mit, wenn sonst nichts ansteht. + public static let ambient = 10 + /// Ein Zustand, der sich von selbst geändert hat — Titelwechsel, Kopplung. + public static let notice = 30 + /// Eine Reaktion auf einen Handgriff — Lautstärke, Helligkeit. + public static let response = 50 + /// Etwas, das man nicht verpassen darf — Akku fast leer. + public static let alert = 90 +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/DisplayBrightness.swift b/Packages/OnyxKit/Sources/MetricsProvider/DisplayBrightness.swift new file mode 100644 index 0000000..db7bb42 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/DisplayBrightness.swift @@ -0,0 +1,101 @@ +import AppKit +import Observation +import os + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Brightness") + +/// Die Helligkeit des eingebauten Bildschirms — gelesen, nicht gesetzt. +/// +/// **Nur der eingebaute.** Ein Spike über drei Wege hat ergeben: von +/// `DisplayServices` bekommt man für externe Monitore Fehler 1000, `CoreDisplay` +/// liefert für jeden Bildschirm stur 1,0 und ist damit wertlos, und +/// `IODisplayConnect` gibt es auf Apple Silicon gar nicht mehr. Siehe +/// `Spikes/brightness-probe.swift`. +/// +/// **Und nicht abgetastet, sondern gemeldet.** Eine Notch, die zehnmal je +/// Sekunde nach der Helligkeit fragt, hält den Rechner wach — den ganzen Tag, +/// für ein Ereignis alle paar Stunden. `DisplayServices` kann benachrichtigen; +/// gäbe es das nicht, wäre diese Anzeige den Preis nicht wert gewesen. +/// +/// **Private API, und deshalb mit Netz.** Fehlt eines der Symbole — bei einem +/// Systemwechsel jederzeit möglich —, bleibt `level` auf `nil` und Onyx zeigt +/// schlicht keine Helligkeit an. Nichts anzuzeigen ist ein hinnehmbarer +/// Ausfall; abzustürzen wäre keiner. +@MainActor +@Observable +public final class DisplayBrightness { + + /// Der zuletzt gemeldete Stand, 0 bis 1. `nil` heißt: nicht lesbar. + public private(set) var level: Double? + + /// Ob dieser Rechner überhaupt eine lesbare Helligkeit hat. + public var isAvailable: Bool { display != nil } + + @ObservationIgnored private let display: CGDirectDisplayID? + @ObservationIgnored private let read: GetBrightness? + + private typealias GetBrightness = + @convention(c) (CGDirectDisplayID, UnsafeMutablePointer) -> Int32 + private typealias Callback = + @convention(c) (UnsafeRawPointer?, CGDirectDisplayID, CFString?, Double) -> Void + private typealias Register = + @convention(c) (CGDirectDisplayID, UnsafeRawPointer?, Callback) -> Int32 + + /// Der Rückruf ist ein reiner C-Zeiger und kann nichts einfangen. Die eine + /// laufende Instanz steht deshalb hier — schwach, damit sie sich beenden + /// lässt. + @ObservationIgnored private nonisolated(unsafe) static weak var current: DisplayBrightness? + + private static let frameworkPath = + "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices" + + public init() { + display = NSScreen.screens + .compactMap { $0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? CGDirectDisplayID } + .first { CGDisplayIsBuiltin($0) != 0 } + + guard let handle = dlopen(Self.frameworkPath, RTLD_LAZY) else { + log.notice("DisplayServices nicht ladbar — keine Helligkeitsanzeige") + read = nil + return + } + read = dlsym(handle, "DisplayServicesGetBrightness") + .map { unsafeBitCast($0, to: GetBrightness.self) } + + Self.current = self + refresh() + subscribe(handle) + } + + /// Liest den aktuellen Stand einmal. + public func refresh() { + guard let display, let read else { level = nil; return } + var value: Float = 0 + // Status ungleich null heißt: dieser Bildschirm hat keine Helligkeit, + // die man lesen könnte. Kein Fehler, nur keine Antwort. + guard read(display, &value) == 0 else { level = nil; return } + level = Double(min(max(value, 0), 1)) + } + + private func subscribe(_ handle: UnsafeMutableRawPointer) { + guard let display, + let symbol = dlsym(handle, + "DisplayServicesRegisterForBrightnessChangeNotifications") + else { + log.notice("Keine Helligkeitsmeldungen — Anzeige bleibt beim Startwert") + return + } + + let callback: Callback = { _, _, _, value in + // Der Rückruf kommt aus einem fremden Faden. + Task { @MainActor in + DisplayBrightness.current?.level = min(max(value, 0), 1) + } + } + let status = unsafeBitCast(symbol, to: Register.self)(display, nil, callback) + if status != 0 { + log.notice("Anmeldung für Helligkeitsmeldungen fehlgeschlagen: \(status, privacy: .public)") + } + } +} diff --git a/Packages/OnyxKit/Tests/ActivityProviderTests/ActivityQueueTests.swift b/Packages/OnyxKit/Tests/ActivityProviderTests/ActivityQueueTests.swift new file mode 100644 index 0000000..a7a70d0 --- /dev/null +++ b/Packages/OnyxKit/Tests/ActivityProviderTests/ActivityQueueTests.swift @@ -0,0 +1,150 @@ +import Testing +import Foundation +import CoreGraphics +import SwiftUI +import OnyxNotch +@testable import ActivityProvider + +@Suite("Warteschlange der Live Activities") +struct ActivityQueueTests { + + private let now = Date(timeIntervalSince1970: 1_780_000_000) + + private func ticket(_ id: String, rank: Int, at offset: TimeInterval = 0, + for duration: TimeInterval? = 2) -> ActivityTicket { + ActivityTicket(id: id, rank: rank, + raised: now.addingTimeInterval(offset), duration: duration) + } + + @Test("Leer heißt: nichts zu zeigen") + func emptyShowsNothing() { + #expect(ActivityQueue().current(now: now) == nil) + } + + /// Ohne diese Regel verschluckt ein beiläufiger Lautstärkeschritt die + /// Meldung, dass der Akku fast leer ist. + @Test("Rang schlägt Aktualität") + func rankBeatsRecency() { + var queue = ActivityQueue() + queue.raise(ticket("battery", rank: 90)) + queue.raise(ticket("volume", rank: 10, at: 1)) + + #expect(queue.current(now: now.addingTimeInterval(1))?.id == "battery") + } + + @Test("Bei gleichem Rang gilt die jüngere") + func sameRankPrefersNewest() { + var queue = ActivityQueue() + queue.raise(ticket("volume", rank: 10)) + queue.raise(ticket("brightness", rank: 10, at: 1)) + + #expect(queue.current(now: now.addingTimeInterval(1))?.id == "brightness") + } + + /// Wer die Lautstärke dreimal verstellt, will dreimal die volle Standzeit — + /// nicht drei Einträge in einer Schlange. + @Test("Dieselbe Kennung ersetzt, statt sich anzureihen") + func raisingAgainReplaces() { + var queue = ActivityQueue() + queue.raise(ticket("volume", rank: 10)) + queue.raise(ticket("volume", rank: 10, at: 1)) + + #expect(queue.all.count == 1) + #expect(queue.current(now: now.addingTimeInterval(1))?.raised + == now.addingTimeInterval(1)) + } + + @Test("Abgelaufene zählen nicht mehr") + func expiredIsGone() { + var queue = ActivityQueue() + queue.raise(ticket("volume", rank: 10, for: 2)) + + #expect(queue.current(now: now.addingTimeInterval(1)) != nil) + #expect(queue.current(now: now.addingTimeInterval(2)) == nil) + } + + /// Genau dafür gibt es dauerhafte: etwas, das steht, wenn sonst nichts + /// ansteht — das Wetter etwa. + @Test("Eine dauerhafte läuft nie ab") + func persistentNeverExpires() { + var queue = ActivityQueue() + queue.raise(ticket("weather", rank: 1, for: nil)) + + #expect(queue.current(now: now.addingTimeInterval(86_400))?.id == "weather") + } + + @Test("Nach dem Ablauf der kurzen kommt die dauerhafte zurück") + func persistentReturns() { + var queue = ActivityQueue() + queue.raise(ticket("weather", rank: 1, for: nil)) + queue.raise(ticket("volume", rank: 10, for: 2)) + + #expect(queue.current(now: now)?.id == "volume") + #expect(queue.current(now: now.addingTimeInterval(3))?.id == "weather") + } + + @Test("Zurückziehen wirkt sofort") + func withdrawWorks() { + var queue = ActivityQueue() + queue.raise(ticket("media", rank: 20, for: nil)) + queue.withdraw(id: "media") + + #expect(queue.current(now: now) == nil) + } + + /// Eine Notch, die zehnmal je Sekunde nachfragt, ob etwas abgelaufen ist, + /// hält den Rechner wach. Deshalb rechnet die Schlange den Weckzeitpunkt aus. + @Test("Der nächste Ablauf ist der früheste noch offene") + func nextExpiryIsEarliest() { + var queue = ActivityQueue() + queue.raise(ticket("volume", rank: 10, for: 5)) + queue.raise(ticket("charge", rank: 50, for: 2)) + queue.raise(ticket("weather", rank: 1, for: nil)) + + #expect(queue.nextExpiry(after: now) == now.addingTimeInterval(2)) + } + + @Test("Ohne offene Fristen gibt es keinen Weckzeitpunkt") + func noExpiryWithoutDurations() { + var queue = ActivityQueue() + queue.raise(ticket("weather", rank: 1, for: nil)) + + #expect(queue.nextExpiry(after: now) == nil) + } + + @Test("Aufräumen entfernt genau die abgelaufenen") + func pruneRemovesExpiredOnly() { + var queue = ActivityQueue() + queue.raise(ticket("volume", rank: 10, for: 1)) + queue.raise(ticket("weather", rank: 1, for: nil)) + queue.prune(now: now.addingTimeInterval(2)) + + #expect(queue.all.map(\.id) == ["weather"]) + } +} + +@Suite("Fläche unter der Notch") +@MainActor +struct AmbientSizeTests { + + private struct Row: NotchActivity { + let id = "volume" + let rank = ActivityRank.response + let duration: TimeInterval? = 2 + var size: CGSize { CGSize(width: 196, height: 34) } + func makeContent() -> AnyView { AnyView(EmptyView()) } + } + + @Test("Die angemeldete Activity gibt die Fläche vor") + func sizeComesFromActivity() { + let presenter = ActivityPresenter() + presenter.raise(Row()) + + #expect(presenter.contentSize == CGSize(width: 196, height: 34)) + } + + @Test("Ohne Activity gibt es keine Fläche") + func noActivityNoSize() { + #expect(ActivityPresenter().contentSize == nil) + } +} diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/PowerWatcherTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/PowerWatcherTests.swift new file mode 100644 index 0000000..76a4683 --- /dev/null +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/PowerWatcherTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import MetricsProvider + +@Suite("Strom: was gemeldet wird") +struct PowerWatcherTests { + + @Test("Der erste gelesene Zustand meldet nichts") + func firstReadingIsSilent() { + var watcher = PowerWatcher() + // Beim Start ist „am Netz" kein Ereignis, sondern die Lage. + #expect(watcher.observe(isCharging: false, isPluggedIn: true, + charge: 0.8, limit: nil) == nil) + #expect(watcher.current == .plugged) + } + + @Test("Derselbe Zustand meldet sich nicht noch einmal") + func repeatedStateIsSilent() { + var watcher = PowerWatcher() + _ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil) + #expect(watcher.observe(isCharging: true, isPluggedIn: true, + charge: 0.51, limit: nil) == nil) + #expect(watcher.observe(isCharging: true, isPluggedIn: true, + charge: 0.99, limit: nil) == nil) + } + + @Test("Netzteil abgezogen: das ist ein Ereignis") + func unplugging() { + var watcher = PowerWatcher() + _ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil) + #expect(watcher.observe(isCharging: false, isPluggedIn: false, + charge: 0.5, limit: nil) == .unplugged) + } + + @Test("Fast leer schlägt „am Akku\"") + func lowBeatsUnplugged() { + var watcher = PowerWatcher() + _ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil) + // Beides trifft zu — gemeldet wird das, was man nicht verpassen darf. + #expect(watcher.observe(isCharging: false, isPluggedIn: false, + charge: 0.15, limit: nil) == .low) + } + + @Test("Von fast leer zurück auf normal ist wieder ein Wechsel") + func recoveringFromLow() { + var watcher = PowerWatcher() + _ = watcher.observe(isCharging: false, isPluggedIn: false, charge: 0.1, limit: nil) + #expect(watcher.observe(isCharging: false, isPluggedIn: false, + charge: 0.5, limit: nil) == .unplugged) + } + + @Test("Die Schwelle liegt bei zwanzig Prozent, einschließlich") + func thresholdIsInclusive() { + var atThreshold = PowerWatcher() + _ = atThreshold.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil) + #expect(atThreshold.observe(isCharging: false, isPluggedIn: false, + charge: 0.2, limit: nil) == .low) + + var above = PowerWatcher() + _ = above.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil) + #expect(above.observe(isCharging: false, isPluggedIn: false, + charge: 0.21, limit: nil) == .unplugged) + } + + @Test("Am Ladelimit ist ein eigener Zustand, nicht „am Netz\"") + func limitIsItsOwnState() { + var watcher = PowerWatcher() + _ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: 80) + #expect(watcher.observe(isCharging: false, isPluggedIn: true, + charge: 0.80, limit: 80) == .atLimit(80)) + } +} diff --git a/Spikes/brightness-notify-probe.swift b/Spikes/brightness-notify-probe.swift new file mode 100644 index 0000000..50fd011 --- /dev/null +++ b/Spikes/brightness-notify-probe.swift @@ -0,0 +1,41 @@ +import AppKit + +// Lässt sich auf Helligkeitsänderungen *hören*, statt sie abzufragen? +// +// Eine Notch, die zehnmal je Sekunde nachfragt, hält den Rechner wach. Wenn es +// keine Benachrichtigung gibt, ist die Helligkeits-Activity nicht zu haben — +// dann lieber gar nicht als mit einem Dauerwecker. +// +// Eigener Prozess, weil die Signatur geraten ist: stimmt sie nicht, stürzt hier +// etwas ab und sonst nichts. + +typealias BrightnessCallback = @convention(c) (UnsafeRawPointer?, CGDirectDisplayID, + CFString?, Double) -> Void +typealias Register = @convention(c) (CGDirectDisplayID, UnsafeRawPointer?, + BrightnessCallback) -> Int32 + +let path = "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices" +guard let handle = dlopen(path, RTLD_LAZY), + let sym = dlsym(handle, "DisplayServicesRegisterForBrightnessChangeNotifications") else { + print("nicht vorhanden"); exit(1) +} + +let register = unsafeBitCast(sym, to: Register.self) + +let callback: BrightnessCallback = { _, display, key, value in + print(" → Bildschirm \(display), Schlüssel \(key.map { $0 as String } ?? "—"), Wert \(value)") +} + +// Nur das eingebaute Display kann Helligkeit; die externen melden Fehler. +let builtIn = NSScreen.screens.compactMap { + $0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID +}.first { CGDisplayIsBuiltin($0) != 0 } + +guard let builtIn else { print("kein eingebautes Display"); exit(1) } + +let status = register(builtIn, nil, callback) +print("Anmeldung für Bildschirm \(builtIn): status \(status) \(status == 0 ? "(erfolgreich)" : "(FEHLER)")") +print("Lausche 12 Sekunden — jede Helligkeitsänderung erscheint hier.") + +RunLoop.current.run(until: Date().addingTimeInterval(12)) +print("fertig") diff --git a/Spikes/brightness-probe.swift b/Spikes/brightness-probe.swift new file mode 100644 index 0000000..e2ba222 --- /dev/null +++ b/Spikes/brightness-probe.swift @@ -0,0 +1,81 @@ +import AppKit +import IOKit + +// Gibt es einen benutzbaren Weg, die Bildschirmhelligkeit zu *lesen* — und +// besser noch: auf ihre Änderung zu *hören*? +// +// Öffentlich gibt es keinen. Geprüft werden deshalb drei private bzw. +// halböffentliche Wege, jeder einzeln, damit hinterher klar ist, welcher trägt +// und welcher nur auf manchen Geräten funktioniert. + +let displays: [CGDirectDisplayID] = NSScreen.screens.compactMap { + $0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID +} +print("Bildschirme: \(displays)\n") + +// MARK: - Weg 1: DisplayServices + +typealias GetBrightness = @convention(c) (CGDirectDisplayID, UnsafeMutablePointer) -> Int32 +typealias RegisterNotify = @convention(c) (CGDirectDisplayID, UnsafeRawPointer?, UnsafeRawPointer?) -> Int32 + +let dsPath = "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices" +if let handle = dlopen(dsPath, RTLD_LAZY) { + print("DisplayServices: geladen") + if let sym = dlsym(handle, "DisplayServicesGetBrightness") { + let fn = unsafeBitCast(sym, to: GetBrightness.self) + for id in displays { + var value: Float = -1 + let status = fn(id, &value) + print(" GetBrightness(\(id)) → status \(status), Wert \(value)") + } + } else { + print(" DisplayServicesGetBrightness: nicht vorhanden") + } + print(" RegisterForBrightnessChangeNotifications: " + + (dlsym(handle, "DisplayServicesRegisterForBrightnessChangeNotifications") != nil + ? "vorhanden" : "FEHLT")) +} else { + print("DisplayServices: nicht ladbar — \(String(cString: dlerror()))") +} + +// MARK: - Weg 2: CoreDisplay + +typealias UserBrightness = @convention(c) (CGDirectDisplayID) -> Double + +let cdPath = "/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay" +if let handle = dlopen(cdPath, RTLD_LAZY) { + print("\nCoreDisplay: geladen") + if let sym = dlsym(handle, "CoreDisplay_Display_GetUserBrightness") { + let fn = unsafeBitCast(sym, to: UserBrightness.self) + for id in displays { + print(" GetUserBrightness(\(id)) → \(fn(id))") + } + } else { + print(" CoreDisplay_Display_GetUserBrightness: nicht vorhanden") + } +} else { + print("\nCoreDisplay: nicht ladbar") +} + +// MARK: - Weg 3: IOKit + +print("\nIOKit IODisplayGetFloatParameter:") +var iterator = io_iterator_t() +let matching = IOServiceMatching("IODisplayConnect") +if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS { + var found = false + var service = IOIteratorNext(iterator) + while service != 0 { + found = true + var brightness: Float = -1 + let status = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, + &brightness) + print(" Dienst \(service) → status \(status), Wert \(brightness)") + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + if !found { print(" kein IODisplayConnect gefunden (auf Apple Silicon üblich)") } + IOObjectRelease(iterator) +} else { + print(" IOServiceGetMatchingServices fehlgeschlagen") +} diff --git a/docs/spikes/H-helligkeit.md b/docs/spikes/H-helligkeit.md new file mode 100644 index 0000000..561013d --- /dev/null +++ b/docs/spikes/H-helligkeit.md @@ -0,0 +1,37 @@ +# Spike H — Helligkeit mitlesen + +**Frage.** Wie liest man die Bildschirmhelligkeit, ohne Bedienungshilfen-Berechtigung? +Was kostet Abtasten? + +**Gemessen am 05.09.2026**, `Spikes/brightness-probe.swift` und +`Spikes/brightness-notify-probe.swift`. Vier Bildschirme: ein eingebauter, drei externe. + +## Ergebnis + +| Weg | Befund | +|---|---| +| `DisplayServices` (privat) | **trägt — aber nur für den eingebauten Bildschirm.** Extern: Status 1000, kein Wert | +| `CoreDisplay_Display_GetUserBrightness` | liefert für **jeden** Bildschirm stur `1.0` — wertlos | +| IOKit `IODisplayGetFloatParameter` | **kein `IODisplayConnect` vorhanden** — auf Apple Silicon nicht mehr da | + +`DisplayServicesRegisterForBrightnessChangeNotifications` ist vorhanden und die +Anmeldung gibt Status 0 zurück. Damit ist **kein Abtasten nötig** — was die Frage +nach den Kosten erledigt. + +## Was das für die Bauform heißt + +- Nur der eingebaute Bildschirm bekommt eine Helligkeitsanzeige. Auf einem Mini am + Monitor gibt es keine, und dann erscheint auch nichts. +- Private API, also mit Netz: fehlt ein Symbol nach einem Systemwechsel, bleibt der + Wert leer und Onyx zeigt schlicht nichts an. Nichts anzuzeigen ist ein hinnehmbarer + Ausfall, abzustürzen wäre keiner. +- Kleine Änderungen werden verworfen: die automatische Anpassung ans Umgebungslicht + kriecht in winzigen Schritten, und jede davon als Ereignis zu zeigen wäre eine + Leuchtreklame. + +## Sackgasse + +`CoreDisplay` sieht in jeder Anleitung im Netz wie der richtige Weg aus. Auf diesem +Gerät gibt es für alle vier Bildschirme `1.0` zurück — auch für den eingebauten, der +tatsächlich auf 62 % stand. Wer den Wert ungeprüft übernimmt, baut eine Anzeige, die +immer „100 %" sagt.