Compare commits
5 Commits
fde3e864c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee848b86cc | ||
|
|
c94c6166f5 | ||
|
|
1432ef3a6f | ||
|
|
9975694d6a | ||
|
|
0df566d531 |
@@ -42,8 +42,16 @@ struct ColorSettingsView: View {
|
|||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mit dem Farbfleck daneben: welche Farbe das System gerade
|
||||||
|
// führt, weiß man nicht auswendig — und dann wirkt das
|
||||||
|
// Ergebnis wie ein Fehler statt wie eine Übernahme.
|
||||||
Button { theme.adoptSystemAccent() } label: {
|
Button { theme.adoptSystemAccent() } label: {
|
||||||
Text("color.adoptSystem")
|
HStack(spacing: 6) {
|
||||||
|
Text("color.adoptSystem")
|
||||||
|
Circle()
|
||||||
|
.fill(Color(nsColor: .controlAccentColor))
|
||||||
|
.frame(width: 11, height: 11)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("color.accent.hint")
|
Text("color.accent.hint")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ final class MixerMenuBarModule: MenuBarModule {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pegel nur, solange das Popover offen ist.
|
||||||
|
///
|
||||||
|
/// Der Mikrofonpegel schaltet den orangen Punkt an — das darf keine
|
||||||
|
/// Sekunde länger dauern als das Hinsehen. An der View ließ es sich nicht
|
||||||
|
/// festmachen: `onDisappear` bleibt beim Popover aus, und der Punkt blieb
|
||||||
|
/// dauerhaft stehen.
|
||||||
|
func popoverDidOpen() { system.levels.addConsumer() }
|
||||||
|
func popoverDidClose() { system.levels.removeConsumer() }
|
||||||
|
|
||||||
func makePopoverView() -> AnyView {
|
func makePopoverView() -> AnyView {
|
||||||
AnyView(
|
AnyView(
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ struct OnboardingView: View {
|
|||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
.frame(width: 520, height: 520)
|
.frame(width: 520, height: 520)
|
||||||
|
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
.task {
|
.task {
|
||||||
calendarGranted = calendarModel.hasAccess
|
calendarGranted = calendarModel.hasAccess
|
||||||
fanControl.refreshInstallState()
|
fanControl.refreshInstallState()
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
|||||||
rootView: OnboardingView(
|
rootView: OnboardingView(
|
||||||
calendarModel: calendarModel, weatherModel: weatherModel,
|
calendarModel: calendarModel, weatherModel: weatherModel,
|
||||||
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
|
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
|
||||||
onFinish: { [weak self] in self?.finish() })
|
onFinish: { [weak self] in self?.finish() }))
|
||||||
.tint(Onyx.Color.accent))
|
|
||||||
|
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: hosting)
|
||||||
window.title = "Onyx"
|
window.title = "Onyx"
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
NotchHostingView(
|
NotchHostingView(
|
||||||
rootView: NotchContainer(presentation: presentation) {
|
rootView: NotchContainer(presentation: presentation) {
|
||||||
NotchPanelContent(model: model)
|
NotchPanelContent(model: model)
|
||||||
}
|
},
|
||||||
.tint(Onyx.Color.accent),
|
|
||||||
presentation: presentation)
|
presentation: presentation)
|
||||||
}
|
}
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
@@ -365,5 +364,8 @@ private struct NotchPanelContent: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WidgetGrid(placements: model.layout, availableWidth: AppModel.availableWidth)
|
WidgetGrid(placements: model.layout, availableWidth: AppModel.availableWidth)
|
||||||
|
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ struct SettingsView: View {
|
|||||||
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
|
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
|
||||||
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
|
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
|
||||||
|
//
|
||||||
|
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
|
||||||
|
// View — SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
|
||||||
|
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
|
||||||
|
// galt, während der Farbwähler daneben schon die neue zeigte.
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx)
|
||||||
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
||||||
// wachsen, der Inhalt aber nicht mit.
|
// wachsen, der Inhalt aber nicht mit.
|
||||||
.frame(minWidth: 740, minHeight: 460)
|
.frame(minWidth: 740, minHeight: 460)
|
||||||
@@ -231,9 +239,17 @@ private struct PermissionRow: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
switch state {
|
switch state {
|
||||||
case .granted:
|
case .granted:
|
||||||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
||||||
.foregroundStyle(Onyx.Color.positive)
|
// Akzentstil färbt sonst darüber. Grün heißt hier „erteilt"
|
||||||
.labelStyle(.titleAndIcon)
|
// und ist keine Geschmacksfrage.
|
||||||
|
Label {
|
||||||
|
Text("settings.permissions.granted")
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Onyx.Color.positive)
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
case .undetermined:
|
case .undetermined:
|
||||||
Button("settings.permissions.request") { Task { await request() } }
|
Button("settings.permissions.request") { Task { await request() } }
|
||||||
case .denied:
|
case .denied:
|
||||||
|
|||||||
@@ -61,11 +61,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
showOnboarding: showOnboarding,
|
showOnboarding: showOnboarding,
|
||||||
selectedTab: Binding(
|
selectedTab: Binding(
|
||||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||||
set: { [weak self] in self?.selectedTab = $0 }))
|
set: { [weak self] in self?.selectedTab = $0 })))
|
||||||
// Ohne das folgen Haken, Regler und Auswahlfelder der
|
|
||||||
// Systemfarbe statt der eingestellten. Genau daher kam das
|
|
||||||
// Grün, das in Onyx sonst nirgends vorkommt.
|
|
||||||
.tint(Onyx.Color.accent))
|
|
||||||
|
|
||||||
let window = NSWindow(contentViewController: hosting)
|
let window = NSWindow(contentViewController: hosting)
|
||||||
window.title = "Onyx"
|
window.title = "Onyx"
|
||||||
|
|||||||
@@ -196,6 +196,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mixer.recordingHint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Solange der Mixer läuft, zeigt macOS das orange Aufnahmesymbol: Ton abgreifen heißt für das System aufnehmen, auch wenn nur die Lautstärke geregelt wird."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "While the mixer runs, macOS shows the orange recording dot: tapping audio counts as recording to the system, even when only the volume is adjusted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"mixer.release": {
|
"mixer.release": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -86,11 +86,10 @@ public struct MixerView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
||||||
// Die Pegel laufen nur, solange die Ansicht sichtbar ist. Der
|
// Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht
|
||||||
// Mikrofonpegel schaltet dabei den orangen Punkt an — ein Messgerät,
|
// an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und
|
||||||
// das im Hintergrund weiterläuft, wäre hier das Falsche.
|
// ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist
|
||||||
.onAppear { system.levels.addConsumer() }
|
// schlimmer als eines, das gar nicht misst.
|
||||||
.onDisappear { system.levels.removeConsumer() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +106,17 @@ extension MixerView {
|
|||||||
}
|
}
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
|
||||||
|
// Der Hinweis gehört hierher, weil hier die Entscheidung fällt.
|
||||||
|
//
|
||||||
|
// Ein Tap ist für macOS eine Tonaufnahme — auch wenn nur die
|
||||||
|
// Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das
|
||||||
|
// orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht,
|
||||||
|
// sucht den Fehler bei sich oder hält die App für unsauber.
|
||||||
|
Text("mixer.recordingHint", bundle: .module)
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Teilt die Terminliste in Tage.
|
||||||
|
///
|
||||||
|
/// **Ohne Tagesüberschrift ist die Liste unlesbar.** Zwei Zeilen „Nina
|
||||||
|
/// arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und nichts
|
||||||
|
/// verrät welche. Die Uhrzeit allein beantwortet nur die halbe Frage.
|
||||||
|
public enum AgendaGrouping {
|
||||||
|
|
||||||
|
public struct Day: Identifiable, Equatable, Sendable {
|
||||||
|
/// Der Tagesanfang — er ist zugleich die Kennung.
|
||||||
|
public let date: Date
|
||||||
|
public let events: [OnyxEvent]
|
||||||
|
public var id: Date { date }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppiert nach Anfangstag, in der Reihenfolge, in der die Tage kommen.
|
||||||
|
public static func group(_ events: [OnyxEvent],
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> [Day] {
|
||||||
|
var order: [Date] = []
|
||||||
|
var byDay: [Date: [OnyxEvent]] = [:]
|
||||||
|
|
||||||
|
for event in events.sorted(by: Self.isEarlier) {
|
||||||
|
let day = calendar.startOfDay(for: event.start)
|
||||||
|
if byDay[day] == nil { order.append(day) }
|
||||||
|
byDay[day, default: []].append(event)
|
||||||
|
}
|
||||||
|
return order.map { Day(date: $0, events: byDay[$0] ?? []) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ganztägige zuerst, dann nach Uhrzeit.
|
||||||
|
///
|
||||||
|
/// Ein ganztägiger Termin beginnt um Mitternacht und stünde sonst zwischen
|
||||||
|
/// den frühen Terminen — dabei gilt er für den ganzen Tag und gehört über
|
||||||
|
/// sie.
|
||||||
|
private static func isEarlier(_ left: OnyxEvent, _ right: OnyxEvent) -> Bool {
|
||||||
|
if left.isAllDay != right.isAllDay { return left.isAllDay }
|
||||||
|
return left.start < right.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Art von Tag — die Entscheidung getrennt von ihrer Beschriftung,
|
||||||
|
/// damit sie ohne Sprachkatalog prüfbar bleibt.
|
||||||
|
public enum DayKind: Equatable, Sendable {
|
||||||
|
case today
|
||||||
|
case tomorrow
|
||||||
|
case later
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func kind(for day: Date, today: Date = Date(),
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> DayKind {
|
||||||
|
let start = calendar.startOfDay(for: day)
|
||||||
|
let reference = calendar.startOfDay(for: today)
|
||||||
|
switch calendar.dateComponents([.day], from: reference, to: start).day ?? 0 {
|
||||||
|
case 0: return .today
|
||||||
|
case 1: return .tomorrow
|
||||||
|
default: return .later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Heute", „Morgen" oder das Datum.
|
||||||
|
///
|
||||||
|
/// Für die nächsten beiden Tage die Wörter: sie sind kürzer als jedes
|
||||||
|
/// Datum und beantworten die Frage sofort. Danach Wochentag und Datum —
|
||||||
|
/// der Wochentag allein wäre in drei Wochen mehrdeutig.
|
||||||
|
public static func label(for day: Date, today: Date = Date(),
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> String {
|
||||||
|
switch kind(for: day, today: today, calendar: calendar) {
|
||||||
|
case .today: return String(localized: "widget.calendar.today", bundle: .module)
|
||||||
|
case .tomorrow: return String(localized: "widget.calendar.tomorrow", bundle: .module)
|
||||||
|
case .later:
|
||||||
|
return calendar.startOfDay(for: day)
|
||||||
|
.formatted(.dateTime.weekday(.abbreviated).day().month(.defaultDigits))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -411,9 +411,19 @@ private struct AgendaList: View {
|
|||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
|
||||||
ForEach(events) { event in
|
let days = AgendaGrouping.group(events)
|
||||||
EventRow(event: event)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
|
||||||
|
Text(AgendaGrouping.label(for: day.date))
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
// Luft nur zwischen den Tagen, nicht über dem ersten.
|
||||||
|
.padding(.top, index == 0 ? 0 : 3)
|
||||||
|
ForEach(day.events) { event in
|
||||||
|
EventRow(event: event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,6 +479,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"widget.calendar.today": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Heute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Today"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"widget.calendar.tomorrow": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Morgen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Tomorrow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"widget.calendar.unreadable": {
|
"widget.calendar.unreadable": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
30
Packages/OnyxKit/Sources/OnyxDesign/OnyxLabelStyle.swift
Normal file
30
Packages/OnyxKit/Sources/OnyxDesign/OnyxLabelStyle.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Färbt das Symbol eines `Label` in der eingestellten Akzentfarbe.
|
||||||
|
///
|
||||||
|
/// **Nötig, weil `.tint` hier nicht greift.** SwiftUI zeichnet Symbole in
|
||||||
|
/// Listen und Formularen auf macOS in der Akzentfarbe des *Systems* — nicht in
|
||||||
|
/// der des Fensters. Das Ergebnis war eine Widget-Liste in Systemgrün neben
|
||||||
|
/// einer Reiterleiste in Onyx-Blau, obwohl beides derselben Einstellung folgen
|
||||||
|
/// soll.
|
||||||
|
///
|
||||||
|
/// Ein Stil statt einer Farbe an jeder Fundstelle: er vererbt sich über die
|
||||||
|
/// Umgebung, also genügt eine Zeile an der Wurzel. Ein Symbol, das seine Farbe
|
||||||
|
/// selbst mitbringt — etwa der grüne Haken für „erteilt" —, behält sie: die
|
||||||
|
/// innere Angabe schlägt die äußere.
|
||||||
|
public struct OnyxAccentLabelStyle: LabelStyle {
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func makeBody(configuration: Configuration) -> some View {
|
||||||
|
Label {
|
||||||
|
configuration.title
|
||||||
|
} icon: {
|
||||||
|
configuration.icon.foregroundStyle(Onyx.Color.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension LabelStyle where Self == OnyxAccentLabelStyle {
|
||||||
|
static var onyx: OnyxAccentLabelStyle { OnyxAccentLabelStyle() }
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
public final class MenuBarController {
|
public final class MenuBarController {
|
||||||
|
|
||||||
|
/// Hält den Delegaten am Leben — `NSPopover` merkt ihn sich nur schwach.
|
||||||
|
private var popoverWatcher: PopoverWatcher?
|
||||||
|
|
||||||
/// Ein Modul, dessen Element keine Breite bekommen hat.
|
/// Ein Modul, dessen Element keine Breite bekommen hat.
|
||||||
///
|
///
|
||||||
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
||||||
@@ -145,8 +148,14 @@ public final class MenuBarController {
|
|||||||
// Auswahlfelder auf die Systemfarbe zurück — das war der Grund, warum
|
// Auswahlfelder auf die Systemfarbe zurück — das war der Grund, warum
|
||||||
// die Popovers grün waren und die Widgets blau.
|
// die Popovers grün waren und die Widgets blau.
|
||||||
entry.popover.contentViewController = NSHostingController(
|
entry.popover.contentViewController = NSHostingController(
|
||||||
rootView: entry.module.makePopoverView().tint(Onyx.Color.accent))
|
rootView: entry.module.makePopoverView()
|
||||||
|
.tint(Onyx.Color.accent)
|
||||||
|
.labelStyle(.onyx))
|
||||||
|
let watcher = PopoverWatcher { [weak self] in self?.popoverClosedByItself() }
|
||||||
|
popoverWatcher = watcher
|
||||||
|
entry.popover.delegate = watcher
|
||||||
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
||||||
|
entry.module.popoverDidOpen()
|
||||||
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
||||||
entry.popover.contentViewController?.view.window?.makeKey()
|
entry.popover.contentViewController?.view.window?.makeKey()
|
||||||
openPopoverID = id
|
openPopoverID = id
|
||||||
@@ -188,11 +197,21 @@ public final class MenuBarController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zugegangen, ohne dass wir es veranlasst haben.
|
||||||
|
private func popoverClosedByItself() {
|
||||||
|
guard let id = openPopoverID, let entry = entries[id] else { return }
|
||||||
|
entry.module.popoverDidClose()
|
||||||
|
openPopoverID = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func closeOpenPopover() {
|
private func closeOpenPopover() {
|
||||||
if let id = openPopoverID, let entry = entries[id] {
|
if let id = openPopoverID, let entry = entries[id] {
|
||||||
entry.popover.performClose(nil)
|
entry.popover.performClose(nil)
|
||||||
}
|
}
|
||||||
openPopoverID = nil
|
// Über denselben Weg wie das Zugehen von selbst: der räumt
|
||||||
|
// `openPopoverID` ab und ist damit genau einmal wirksam, egal ob der
|
||||||
|
// Delegat vorher, nachher oder gar nicht meldet.
|
||||||
|
popoverClosedByItself()
|
||||||
globalMonitor.map(NSEvent.removeMonitor)
|
globalMonitor.map(NSEvent.removeMonitor)
|
||||||
localMonitor.map(NSEvent.removeMonitor)
|
localMonitor.map(NSEvent.removeMonitor)
|
||||||
keyMonitor.map(NSEvent.removeMonitor)
|
keyMonitor.map(NSEvent.removeMonitor)
|
||||||
@@ -213,3 +232,21 @@ public final class MenuBarController {
|
|||||||
onVisibilityChanged?(hidden)
|
onVisibilityChanged?(hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Horcht, ob das Popover zugegangen ist.
|
||||||
|
///
|
||||||
|
/// Ein eigener Typ, weil ein Delegat von AppKit ein `NSObject` sein muss und
|
||||||
|
/// der Controller keines ist. Nötig ist er trotzdem: das Popover geht auch
|
||||||
|
/// ohne Zutun zu — beim Wechsel in eine andere App etwa —, und ohne diese
|
||||||
|
/// Meldung erfährt eine laufende Messung nie, dass niemand mehr hinsieht.
|
||||||
|
final class PopoverWatcher: NSObject, NSPopoverDelegate {
|
||||||
|
private let onClose: @MainActor () -> Void
|
||||||
|
|
||||||
|
init(onClose: @escaping @MainActor () -> Void) {
|
||||||
|
self.onClose = onClose
|
||||||
|
}
|
||||||
|
|
||||||
|
func popoverDidClose(_ notification: Notification) {
|
||||||
|
MainActor.assumeIsolated { onClose() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ public protocol MenuBarModule: AnyObject {
|
|||||||
/// Der Inhalt des Popovers beim Klick.
|
/// Der Inhalt des Popovers beim Klick.
|
||||||
func makePopoverView() -> AnyView
|
func makePopoverView() -> AnyView
|
||||||
|
|
||||||
|
/// Wird gerufen, wenn das Popover dieses Moduls auf- und wieder zugeht.
|
||||||
|
///
|
||||||
|
/// **Die einzige verlässliche Stelle für alles, was nur laufen darf,
|
||||||
|
/// solange jemand hinsieht.** SwiftUIs `onDisappear` bleibt bei einem
|
||||||
|
/// `NSPopover` aus: dessen Controller lebt nach dem Schließen weiter, die
|
||||||
|
/// View verschwindet nie aus der Hierarchie. Wer eine Messung daran hängt,
|
||||||
|
/// hängt sie an etwas, das nie eintritt — im Fall des Mikrofons mit dem
|
||||||
|
/// orangen Punkt als Beweis.
|
||||||
|
func popoverDidOpen()
|
||||||
|
func popoverDidClose()
|
||||||
|
|
||||||
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier
|
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier
|
||||||
/// meldet sich das Modul beim Provider an und wieder ab.
|
/// meldet sich das Modul beim Provider an und wieder ab.
|
||||||
func activate()
|
func activate()
|
||||||
@@ -72,3 +83,9 @@ extension TimeInterval {
|
|||||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension MenuBarModule {
|
||||||
|
// Die meisten Module haben nichts abzuräumen.
|
||||||
|
func popoverDidOpen() {}
|
||||||
|
func popoverDidClose() {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import CalendarProvider
|
||||||
|
|
||||||
|
@Suite("Terminliste nach Tagen")
|
||||||
|
struct AgendaGroupingTests {
|
||||||
|
|
||||||
|
private var calendar: Calendar {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "Europe/Berlin")!
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
private func date(_ day: Int, _ hour: Int = 0) -> Date {
|
||||||
|
calendar.date(from: DateComponents(year: 2026, month: 8, day: day,
|
||||||
|
hour: hour))!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func event(_ title: String, day: Int, hour: Int = 0,
|
||||||
|
allDay: Bool = false) -> OnyxEvent {
|
||||||
|
OnyxEvent(id: "\(title)-\(day)-\(hour)", title: title,
|
||||||
|
start: date(day, hour), end: date(day, hour + 1),
|
||||||
|
isAllDay: allDay, colorHex: nil, location: nil,
|
||||||
|
calendarID: "c", calendarTitle: "C")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Termine landen unter ihrem Tag")
|
||||||
|
func groupsByDay() {
|
||||||
|
// Der Fall aus der Praxis: zweimal „Nina arbeiten, 8:00" untereinander,
|
||||||
|
// und nichts verriet, welcher Tag gemeint war.
|
||||||
|
let days = AgendaGrouping.group([
|
||||||
|
event("Nina arbeiten", day: 14, hour: 8),
|
||||||
|
event("Grillplausch", day: 15, hour: 18),
|
||||||
|
event("Nina arbeiten", day: 16, hour: 8),
|
||||||
|
], calendar: calendar)
|
||||||
|
|
||||||
|
#expect(days.count == 3)
|
||||||
|
#expect(days.map(\.events.count) == [1, 1, 1])
|
||||||
|
#expect(days[0].date == date(14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Tage kommen in ihrer Reihenfolge")
|
||||||
|
func daysStayInOrder() {
|
||||||
|
let days = AgendaGrouping.group([
|
||||||
|
event("Später", day: 16), event("Früher", day: 14),
|
||||||
|
], calendar: calendar)
|
||||||
|
#expect(days.map(\.date) == [date(14), date(16)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ganztägige Termine stehen über den Uhrzeiten")
|
||||||
|
func allDayComesFirst() {
|
||||||
|
// Sie beginnen um Mitternacht und stünden sonst zwischen den frühen
|
||||||
|
// Terminen — dabei gelten sie für den ganzen Tag.
|
||||||
|
let days = AgendaGrouping.group([
|
||||||
|
event("Frühschicht", day: 14, hour: 6),
|
||||||
|
event("Urlaub", day: 14, allDay: true),
|
||||||
|
], calendar: calendar)
|
||||||
|
#expect(days[0].events.map(\.title) == ["Urlaub", "Frühschicht"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Heute, morgen und später sind auseinandergehalten")
|
||||||
|
func dayKinds() {
|
||||||
|
let today = date(14, 12)
|
||||||
|
#expect(AgendaGrouping.kind(for: date(14), today: today, calendar: calendar) == .today)
|
||||||
|
#expect(AgendaGrouping.kind(for: date(15), today: today, calendar: calendar) == .tomorrow)
|
||||||
|
#expect(AgendaGrouping.kind(for: date(20), today: today, calendar: calendar) == .later)
|
||||||
|
// Auch rückwärts: ein Termin von gestern ist nicht „heute".
|
||||||
|
#expect(AgendaGrouping.kind(for: date(13), today: today, calendar: calendar) == .later)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Später steht das Datum in der Überschrift")
|
||||||
|
func laterDaysShowTheDate() {
|
||||||
|
// Der Wochentag allein wäre in drei Wochen mehrdeutig.
|
||||||
|
let label = AgendaGrouping.label(for: date(20), today: date(14, 12), calendar: calendar)
|
||||||
|
#expect(label.contains("20"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine späte Uhrzeit zählt noch zum selben Tag")
|
||||||
|
func lateHoursStayOnTheirDay() {
|
||||||
|
// Nicht selbstverständlich: mit UTC statt der lokalen Zone rutschte ein
|
||||||
|
// Termin um 23 Uhr auf den Folgetag.
|
||||||
|
let days = AgendaGrouping.group([
|
||||||
|
event("Spät", day: 14, hour: 23), event("Früh", day: 14, hour: 7),
|
||||||
|
], calendar: calendar)
|
||||||
|
#expect(days.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne Termine gibt es keine Tage")
|
||||||
|
func emptyStaysEmpty() {
|
||||||
|
#expect(AgendaGrouping.group([], calendar: calendar).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user