diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index b514387..3cee73e 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; }; 2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; }; 378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; + 3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; }; 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 */; }; @@ -37,6 +38,7 @@ CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; }; CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; + F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; }; FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; }; /* End PBXBuildFile section */ @@ -53,6 +55,8 @@ /* Begin PBXFileReference section */ 055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = ""; }; + 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = ""; }; 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = ""; }; 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = ""; }; 45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -156,9 +160,11 @@ 0FBA13B97C53F1D0D740A633 /* Resources */, 055263008CB3306A8D09E6D4 /* AppIcon.icon */, B83C4E3CB821F903F3977E88 /* AppModel.swift */, + 085D860E654E09E4CD6E535A /* chip.svg */, FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, 89309D9F5722FA1B91CF2905 /* FanWidget.swift */, + 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */, 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */, @@ -283,6 +289,8 @@ FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */, A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */, 7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */, + 3991CB2BF41C6C014EF4528F /* chip.svg in Resources */, + F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */, B0FCCD92AF22F1636482EACD /* sound-waves.svg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Onyx/chip.svg b/Onyx/chip.svg new file mode 100644 index 0000000..46f6c5c --- /dev/null +++ b/Onyx/chip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Onyx/graphic-card.svg b/Onyx/graphic-card.svg new file mode 100644 index 0000000..e335753 --- /dev/null +++ b/Onyx/graphic-card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index b411e07..8778270 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -222,6 +222,10 @@ final class MetricStatusView: NSView, WidthReporting { if metric == .battery { drawBattery(color: color, at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2)) + } else if let image = Self.customGlyphs[metric] { + MenuBarText.drawTemplate(image, color: color, + in: NSRect(x: 0, y: bounds.midY - 6.5, + width: glyph, height: 13)) } else { MenuBarText.drawSymbol(currentSymbol, color: color, in: NSRect(x: 0, y: bounds.midY - 6.5, @@ -246,6 +250,19 @@ final class MetricStatusView: NSView, WidthReporting { MenuBarText.draw(text, color: color, in: rect ?? bounds) } + /// Eigene Vorlagen für CPU und GPU — SF Symbols hat für beides nur den + /// gleichen Chip, und „cpu" neben „cpu.fill" ist kein Unterschied, den man + /// in der Menüleiste erkennt. + private static let customGlyphs: [MetricKind: NSImage] = { + var glyphs: [MetricKind: NSImage] = [:] + for (metric, name) in [(MetricKind.cpu, "chip"), (.gpu, "graphic-card")] { + guard let url = Bundle.main.url(forResource: name, withExtension: "svg"), + let image = NSImage(contentsOf: url) else { continue } + glyphs[metric] = image + } + return glyphs + }() + private func drawSymbol(color: NSColor) { if metric == .battery { drawBattery(color: color, @@ -254,6 +271,12 @@ final class MetricStatusView: NSView, WidthReporting { return } let side: CGFloat = 15 + let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2, + width: side, height: side) + if let glyph = Self.customGlyphs[metric] { + MenuBarText.drawTemplate(glyph, color: color, in: rect) + return + } let width = MenuBarText.symbolWidth(currentSymbol, height: side) MenuBarText.drawSymbol(currentSymbol, color: color, in: NSRect(x: bounds.midX - width / 2, @@ -299,8 +322,10 @@ final class MetricStatusView: NSView, WidthReporting { /// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene /// Maße; alles andere ist ein SF-Symbol. private var symbolSlot: CGFloat { - metric == .battery ? BatteryGlyph.size.width - : MenuBarText.symbolWidth(currentSymbol, height: 13) + if metric == .battery { return BatteryGlyph.size.width } + // Eigene Vorlagen sind quadratisch; ihre Breite folgt der Höhe. + if Self.customGlyphs[metric] != nil { return 13 } + return MenuBarText.symbolWidth(currentSymbol, height: 13) } /// Das Symbol zum **Zustand**, nicht zur Metrik. @@ -316,14 +341,21 @@ final class MetricStatusView: NSView, WidthReporting { } private func drawGraph(color: NSColor, in rect: NSRect) { - guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return } - let step = rect.width / CGFloat(history.count - 1) + guard let context = NSGraphicsContext.current?.cgContext else { return } + // Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus + // der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt + // sechzig, und daraus gerechnet spannte sich der Weg über die ganze + // Breite. + let xs = GraphLayout.positions(count: history.count, + capacity: MetricsModel.historyLength, in: rect) + guard !xs.isEmpty else { return } context.setStrokeColor(color.withAlphaComponent(0.85).cgColor) context.setLineWidth(1) context.setLineJoin(.round) - for (index, value) in history.enumerated() { - let point = CGPoint(x: rect.minX + CGFloat(index) * step, + for (index, x) in xs.enumerated() { + let value = history[history.count - xs.count + index] + let point = CGPoint(x: x, y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1))) index == 0 ? context.move(to: point) : context.addLine(to: point) } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift index 4b6e2df..0257947 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift @@ -13,6 +13,11 @@ struct MetricPopoverContent: View { var body: some View { VStack(alignment: .leading, spacing: 12) { + // Ein Titel oben: bei drei gleich aussehenden Ringen sieht man + // sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht. + Text(String(localized: .init(metric.localizationKey), bundle: .module)) + .font(.headline) + switch metric { case .cpu, .gpu: ProcessorDetail(model: model, metric: metric) case .memory: MemoryDetail(snapshot: model.snapshot) @@ -64,7 +69,9 @@ private struct Dial: View { var tint: Color = .accentColor var body: some View { - VStack(spacing: 2) { + // Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und + // sah aus, als gehörte sie noch hinein. + VStack(spacing: 6) { ZStack { Circle().stroke(.quaternary, lineWidth: 5) Circle() @@ -76,6 +83,7 @@ private struct Dial: View { .frame(width: 62, height: 62) Text(caption).font(.caption2).foregroundStyle(.secondary) } + .padding(.bottom, 2) } } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index a12a4f2..e92680f 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -1,6 +1,7 @@ import SwiftUI import OnyxDesign import OnyxWidgetKit +import OnyxMenuBar /// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit /// dieselbe Messschleife. @@ -137,21 +138,31 @@ private struct WideMetric: View { public struct Sparkline: View { let values: [Double] let tint: Color + /// Wie viele Werte der Verlauf fasst — die Schrittweite hängt daran, nicht + /// an der Anzahl der bisher vorhandenen. + let capacity: Int - public init(values: [Double], tint: Color) { + public init(values: [Double], tint: Color, capacity: Int = MetricsModel.historyLength) { self.values = values self.tint = tint + self.capacity = capacity } public var body: some View { GeometryReader { geometry in - let points = Array(values.suffix(MetricsModel.historyLength)) + let points = Array(values.suffix(capacity)) if points.count > 1 { let size = geometry.size - let step = size.width / CGFloat(points.count - 1) - let coordinates = points.enumerated().map { index, value in - CGPoint(x: CGFloat(index) * step, - y: size.height * (1 - CGFloat(min(max(value, 0), 1)))) + // Die Schrittweite kommt aus der **vollen** Länge des Verlaufs, + // nicht aus der Anzahl vorhandener Werte. Frisch gestartet sind + // es zwei statt sechzig; daraus gerechnet spannte sich der Weg + // zwischen ihnen über die ganze Breite — ein riesiger Zickzack, + // der nach viel Bewegung aussah und nichts bedeutete. + let xs = GraphLayout.positions(count: points.count, + capacity: capacity, + in: CGRect(origin: .zero, size: size)) + let coordinates = zip(xs, points).map { x, value in + CGPoint(x: x, y: size.height * (1 - CGFloat(min(max(value, 0), 1)))) } let line = Path { path in @@ -162,10 +173,15 @@ public struct Sparkline: View { // Dieselbe Kurve, unten geschlossen — die Fläche darunter gibt // dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre. + // Die Fläche beginnt am ersten Messwert, nicht am linken Rand: + // sonst zieht sich bei wenigen Werten ein Keil über die ganze + // Breite, obwohl dort noch nichts gemessen wurde. let area = Path { path in - path.move(to: CGPoint(x: 0, y: size.height)) + guard let first = coordinates.first, let last = coordinates.last + else { return } + path.move(to: CGPoint(x: first.x, y: size.height)) coordinates.forEach { path.addLine(to: $0) } - path.addLine(to: CGPoint(x: size.width, y: size.height)) + path.addLine(to: CGPoint(x: last.x, y: size.height)) path.closeSubpath() } diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift index 698906c..ae8150f 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift @@ -184,12 +184,19 @@ final class NetworkStatusView: NSView, WidthReporting { } private func drawGraph(color: NSColor, in rect: NSRect) { - guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return } - let step = rect.width / CGFloat(history.count - 1) + guard let context = NSGraphicsContext.current?.cgContext else { return } + // Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus + // der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt + // sechzig, und daraus gerechnet spannte sich der Weg über die ganze + // Breite. + let xs = GraphLayout.positions(count: history.count, + capacity: NetworkModel.historyLength, in: rect) + guard !xs.isEmpty else { return } context.setStrokeColor(color.withAlphaComponent(0.85).cgColor) context.setLineWidth(1) - for (index, value) in history.enumerated() { - let point = CGPoint(x: rect.minX + CGFloat(index) * step, + for (index, x) in xs.enumerated() { + let value = history[history.count - xs.count + index] + let point = CGPoint(x: x, y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1))) index == 0 ? context.move(to: point) : context.addLine(to: point) } diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift index 42ca614..da0ec73 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift @@ -1,5 +1,6 @@ import SwiftUI import MetricsProvider +import OnyxMenuBar @MainActor struct NetworkPopoverContent: View { @@ -164,12 +165,21 @@ private struct TrafficChart: View { var body: some View { GeometryReader { geometry in - let points = history.suffix(60) + let points = history.suffix(NetworkModel.historyLength) let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1) - let width = geometry.size.width / CGFloat(max(points.count, 1)) + // Die Balkenbreite kommt aus der **vollen** Länge des Verlaufs. + // Vorher war sie `Breite / Anzahl`: bei fünf Messwerten also ein + // Fünftel je Balken, und mit jedem weiteren wurden sie schmaler. + // Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag. + let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength, + totalWidth: geometry.size.width, spacing: 1) let half = geometry.size.height / 2 HStack(alignment: .center, spacing: 1) { + // Fehlende Messwerte bleiben als freie Fläche links stehen; der + // Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm. + Spacer(minLength: 0) + ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in VStack(spacing: 1) { Rectangle() @@ -181,7 +191,7 @@ private struct TrafficChart: View { .frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5)) .frame(maxHeight: .infinity, alignment: .top) } - .frame(width: max(width - 1, 1)) + .frame(width: width) } } .frame(width: geometry.size.width, height: geometry.size.height) diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift index afeae43..ceec354 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift @@ -154,7 +154,8 @@ private struct NetworkWidgetView: View { Rates(snapshot: model.snapshot, compact: false) - Sparkline(values: model.series(download: true), tint: Onyx.Color.accent) + Sparkline(values: model.series(download: true), tint: Onyx.Color.accent, + capacity: NetworkModel.historyLength) .frame(height: 18) Divider().overlay(Onyx.Color.hairline) diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/GraphLayout.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/GraphLayout.swift new file mode 100644 index 0000000..a2ac408 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/GraphLayout.swift @@ -0,0 +1,41 @@ +import CoreGraphics + +/// Wo die Punkte eines Verlaufsdiagramms liegen. +/// +/// Die Schrittweite kommt aus der **vollen** Länge des Verlaufs, nicht aus der +/// Anzahl der bisher vorhandenen Werte. Frisch gestartet hat der Verlauf zwei +/// Messwerte statt sechzig — wurde daraus gerechnet, spannte sich der Weg +/// zwischen ihnen über die ganze Breite. Das sah nach viel Bewegung aus und +/// bedeutete nichts. +public enum GraphLayout { + + /// - Parameters: + /// - count: wie viele Werte vorliegen. + /// - capacity: wie viele der Verlauf fasst. + public static func positions(count: Int, capacity: Int, in rect: CGRect) -> [CGFloat] { + // Eine Linie braucht zwei Punkte. Einen zu zeichnen ergäbe einen Strich + // am Rand, der wie ein Messwert aussieht. + guard count >= 2, capacity >= 2 else { return [] } + + let shown = min(count, capacity) + let step = rect.width / CGFloat(capacity - 1) + // Neue Werte rechts: der Verlauf wächst nach links weg. + let start = rect.maxX - CGFloat(shown - 1) * step + return (0.. CGFloat { + guard capacity > 0 else { return totalWidth } + let gaps = spacing * CGFloat(capacity - 1) + // Sichtbar bleiben, auch wenn es eng wird — sonst verschwindet der + // Verlauf bei schmalem Fenster ganz. + return max((totalWidth - gaps) / CGFloat(capacity), 0.5) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxMenuBarTests/GraphLayoutTests.swift b/Packages/OnyxKit/Tests/OnyxMenuBarTests/GraphLayoutTests.swift new file mode 100644 index 0000000..1b1ca69 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxMenuBarTests/GraphLayoutTests.swift @@ -0,0 +1,78 @@ +import Testing +import CoreGraphics +@testable import OnyxMenuBar + +// Frisch gestartet hat der Verlauf zwei Messwerte statt sechzig. Wurde die +// Schrittweite aus der vorhandenen Anzahl gerechnet, spannte sich der Weg +// zwischen ihnen über die ganze Breite — ein riesiger Zickzack, der nach viel +// Bewegung aussah und nichts bedeutete. + +@Suite("Verlaufsdiagramm") +struct GraphLayoutTests { + + private let rect = CGRect(x: 0, y: 0, width: 60, height: 10) + + @Test("Bei vollem Verlauf reicht er von Rand zu Rand") + func fullHistorySpansTheWidth() { + let xs = GraphLayout.positions(count: 60, capacity: 60, in: rect) + #expect(xs.first == rect.minX) + #expect(abs(xs.last! - rect.maxX) < 0.001) + } + + @Test("Wenige Werte behalten dieselbe Schrittweite") + func fewValuesKeepTheStep() { + // Der Kern: zwei Messwerte stehen so eng wie zwei von sechzig, nicht + // über die volle Breite verteilt. + let full = GraphLayout.positions(count: 60, capacity: 60, in: rect) + let few = GraphLayout.positions(count: 2, capacity: 60, in: rect) + let fullStep = full[1] - full[0] + #expect(abs((few[1] - few[0]) - fullStep) < 0.001) + } + + @Test("Neue Werte stehen rechts") + func newValuesAreOnTheRight() { + // Der Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm. + let xs = GraphLayout.positions(count: 3, capacity: 60, in: rect) + #expect(abs(xs.last! - rect.maxX) < 0.001) + #expect(xs.first! > rect.midX) + } + + @Test("Unter zwei Werten gibt es nichts zu zeichnen") + func lessThanTwoDrawsNothing() { + // Eine Linie braucht zwei Punkte. Einen zu zeichnen ergäbe einen + // Strich am Rand, der wie ein Messwert aussieht. + #expect(GraphLayout.positions(count: 1, capacity: 60, in: rect).isEmpty) + #expect(GraphLayout.positions(count: 0, capacity: 60, in: rect).isEmpty) + } + + @Test("Mehr Werte als Platz laufen nicht über") + func overflowIsClamped() { + let xs = GraphLayout.positions(count: 200, capacity: 60, in: rect) + #expect(xs.allSatisfy { $0 >= rect.minX - 0.001 && $0 <= rect.maxX + 0.001 }) + } +} + +@Suite("Balkendiagramm") +struct BarLayoutTests { + + @Test("Die Balkenbreite hängt nicht an der Anzahl der Werte") + func barWidthIsIndependentOfCount() { + // Der Fehler, um den es geht: bei fünf Messwerten war jeder Balken ein + // Fünftel breit, und mit jedem weiteren wurden sie schmaler. + let a = GraphLayout.barWidth(capacity: 60, totalWidth: 300, spacing: 1) + #expect(a == GraphLayout.barWidth(capacity: 60, totalWidth: 300, spacing: 1)) + } + + @Test("Alle Balken samt Abstand füllen die Breite") + func barsFillTheWidth() { + let width = GraphLayout.barWidth(capacity: 60, totalWidth: 300, spacing: 1) + let total = 60 * width + 59 * 1 + #expect(abs(total - 300) < 0.001) + } + + @Test("Ein Balken bleibt sichtbar, auch wenn es eng wird") + func barsStayVisible() { + // Sonst verschwindet der Verlauf bei schmalem Popover ganz. + #expect(GraphLayout.barWidth(capacity: 500, totalWidth: 100, spacing: 1) >= 0.5) + } +}