diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index 325fb71..c138ed6 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -121,9 +121,14 @@ final class MetricStatusView: NSView, WidthReporting { private var fadeTimer: Timer? private var fadePhase: Double = 0 - private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth) private var shownWidth: CGFloat = MenuBarText.minimumWidth + /// Die Breite für die Gestalt des Werts, nicht für seinen Höchstwert — + /// zwischen „34 %" und „87 %" darf sich nichts bewegen. + private func shape(_ text: String) -> String { + MenuBarText.widestOfSameShape(text) + } + override var intrinsicContentSize: NSSize { NSSize(width: shownWidth, height: 22) } @@ -139,11 +144,11 @@ final class MetricStatusView: NSView, WidthReporting { case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4) case .graph: 28 case .bars: barsWidth - case .value: MenuBarText.width(for: displayValue) - case .valueAndGraph: MenuBarText.width(for: displayValue) + 2 + 28 - case .labelAndValue: MenuBarText.width(for: "\(label) \(displayValue)") + case .value: MenuBarText.width(for: shape(displayValue)) + case .valueAndGraph: MenuBarText.width(for: shape(displayValue)) + 2 + 28 + case .labelAndValue: MenuBarText.width(for: shape("\(label) \(displayValue)")) case .symbolAndValue: - MenuBarText.width(for: displayValue) + symbolSlot + MenuBarText.innerGap + MenuBarText.width(for: shape(displayValue)) + symbolSlot + MenuBarText.innerGap } } @@ -180,7 +185,7 @@ final class MetricStatusView: NSView, WidthReporting { updateFadeTimer() - let next = adaptive.update(needed: width) + let next = max(width, MenuBarText.minimumWidth) guard next != shownWidth else { return } shownWidth = next invalidateIntrinsicContentSize() diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift index 54efee7..f82a5a9 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift @@ -83,8 +83,13 @@ final class NetworkStatusView: NSView, WidthReporting { /// Leiste dabei nicht zappelt. private static func ratesWidth(_ snapshot: NetworkSnapshot) -> CGFloat { let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular) + // Die **Gestalt** reservieren, nicht den Höchstwert der letzten + // Messungen: der Durchsatz schwankt im Sekundentakt, und der Höchstwert + // ist fast immer größer als das, was gerade dasteht. Die Differenz + // stand als Lücke daneben. let lines = ["↓ " + Throughput.formatted(snapshot.downloadRate), "↑ " + Throughput.formatted(snapshot.uploadRate)] + .map(MenuBarText.widestOfSameShape) let widest = lines .map { NSAttributedString(string: $0, attributes: [.font: font]).size().width } .max() ?? 0 @@ -111,7 +116,6 @@ final class NetworkStatusView: NSView, WidthReporting { MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13) } - private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth) private var width: CGFloat = MenuBarText.minimumWidth var onWidthChange: ((CGFloat) -> Void)? @@ -124,7 +128,7 @@ final class NetworkStatusView: NSView, WidthReporting { self.history = history needsDisplay = true - let next = adaptive.update(needed: neededWidth()) + let next = max(neededWidth(), MenuBarText.minimumWidth) guard next != width else { return } width = next invalidateIntrinsicContentSize() @@ -175,8 +179,11 @@ final class NetworkStatusView: NSView, WidthReporting { attributes: attributes) let up = NSAttributedString(string: "↑ " + Throughput.formatted(snapshot.uploadRate), attributes: attributes) - down.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 0.5)) - up.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 10.5)) + // Mittig statt links: bleibt doch einmal Platz übrig — etwa weil die + // zweite Zeile schmaler ist —, verteilt er sich auf beide Seiten statt + // als Lücke rechts zu stehen. + down.draw(at: NSPoint(x: rect.midX - down.size().width / 2, y: rect.midY - 0.5)) + up.draw(at: NSPoint(x: rect.midX - up.size().width / 2, y: rect.midY - 10.5)) } private func drawGraph(color: NSColor, in rect: NSRect) { diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift index dc4d299..1835a96 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift @@ -57,7 +57,7 @@ public struct MenuBarModuleSettings: Codable, Sendable, Equatable { public var usesThresholdColors: Bool public init(isEnabled: Bool = false, - presentation: MenuBarPresentation = .value, + presentation: MenuBarPresentation = .symbolAndValue, refreshInterval: TimeInterval = 2, usesThresholdColors: Bool = true) { self.isEnabled = isEnabled diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift index 79eda0b..1c21718 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift @@ -39,6 +39,17 @@ public enum MenuBarText { return max(minimumWidth, content + symbol) } + /// Dieselbe Zeichenfolge mit allen Ziffern auf 9 gesetzt. + /// + /// Damit lässt sich die Breite für die **Gestalt** eines Werts reservieren + /// statt für seinen Höchstwert. Bei ständig schwankenden Zahlen — dem + /// Netzwerkdurchsatz etwa — ist der Höchstwert der letzten Messungen fast + /// immer größer als der aktuelle, und die Differenz steht als Lücke daneben. + /// So bewegt sich nur noch etwas, wenn eine Stelle dazukommt. + public static func widestOfSameShape(_ text: String) -> String { + String(text.map { $0.isNumber ? "9" : $0 }) + } + public static func width(of text: String) -> CGFloat { guard !text.isEmpty else { return 0 } return NSAttributedString(string: text, attributes: [.font: font]).size().width diff --git a/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift b/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift index 3edee00..0d9c2f5 100644 --- a/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift +++ b/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift @@ -49,3 +49,45 @@ struct MenuBarTextTests { #expect(MenuBarText.width(of: "111") == MenuBarText.width(of: "888")) } } + +// Eine Breite, die dem Höchstwert der letzten Messungen folgt, ist bei einem +// Wert, der ständig schwankt, fast immer zu groß: der Netzwerkdurchsatz +// wechselt im Sekundentakt, und das Element blieb dauerhaft auf der Breite +// eines vergangenen Ausschlags stehen. Die Differenz stand als Lücke daneben. +// +// Stattdessen: die Ziffern durch Neunen ersetzen. Das ergibt die breiteste +// Zeichenfolge **derselben Gestalt** — die Breite ändert sich dann nur noch, +// wenn eine Stelle dazukommt oder die Einheit wechselt. + +@Suite("Breite gleicher Gestalt") +struct DigitShapeTests { + + @Test("Ziffern werden zu Neunen") + func digitsBecomeNines() { + #expect(MenuBarText.widestOfSameShape("↓ 14 KB/s") == "↓ 99 KB/s") + #expect(MenuBarText.widestOfSameShape("34 %") == "99 %") + } + + @Test("Alles außer Ziffern bleibt stehen") + func nonDigitsSurvive() { + #expect(MenuBarText.widestOfSameShape("↑ 2,4 MB/s") == "↑ 9,9 MB/s") + } + + @Test("Gleich lange Zahlen ergeben dieselbe Gestalt") + func sameLengthIsStable() { + // Genau darum geht es: von 14 auf 37 KB/s darf sich nichts bewegen. + #expect(MenuBarText.widestOfSameShape("↓ 14 KB/s") + == MenuBarText.widestOfSameShape("↓ 37 KB/s")) + } + + @Test("Eine Stelle mehr ist eine andere Gestalt") + func moreDigitsIsWider() { + #expect(MenuBarText.widestOfSameShape("↓ 999 KB/s") + != MenuBarText.widestOfSameShape("↓ 99 KB/s")) + } + + @Test("Ohne Ziffern bleibt der Text, wie er ist") + func textWithoutDigitsIsUnchanged() { + #expect(MenuBarText.widestOfSameShape("–") == "–") + } +}