Nachgemessen statt geraten. Das Netzwerkelement war 69 Punkte breit, meine Rechnung ergab 47 — und die anderen Module lagen konstant 16 darüber. Sechs Punkte hatte allein das Netzwerk zusätzlich. Zwei Ursachen. Die Hysterese hielt die Breite auf dem Höchstwert der letzten zwanzig Sekunden; bei einem Wert, der im Sekundentakt schwankt, ist der fast immer größer als der aktuelle. Und der Text wurde linksbündig gezeichnet, die Differenz stand also komplett rechts als Lücke. Jetzt wird die Breite für die **Gestalt** reserviert: alle Ziffern auf 9 gesetzt, das ergibt die breiteste Zeichenfolge derselben Form. Von 14 auf 37 KB/s bewegt sich damit nichts, und breiter wird es nur, wenn eine Stelle dazukommt. Gezeichnet wird mittig, damit ein Rest sich auf beide Seiten verteilt statt rechts zu stehen. Ergebnis: 69 → 57 Punkte. Die verbleibenden rund 16 Punkte je Element legt macOS selbst um jedes Statuselement; die Lücken zwischen benachbarten Elementen sind gemessen 0. Neue Menüleistenmodule starten jetzt mit „Symbol + Wert" statt „Wert" — bestehende Einstellungen bleiben, wie sie sind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
7.4 KiB
Swift
168 lines
7.4 KiB
Swift
import AppKit
|
|
|
|
/// Beschriftung und Breite eines Menüleisten-Elements.
|
|
///
|
|
/// Die Breite wird **gemessen**, nicht geschätzt. Vorher stand je Darstellungsart
|
|
/// eine feste Zahl im Code, großzügig gewählt, damit nichts abgeschnitten wird —
|
|
/// mit dem Ergebnis, dass neben jedem Wert Platz für ein weiteres Symbol blieb,
|
|
/// in dem nichts stand. In einer Menüleiste ist das der teuerste Platz des
|
|
/// ganzen Bildschirms.
|
|
///
|
|
/// Gemessen wird die **breiteste Zeichenfolge, die vorkommen kann**, nicht die
|
|
/// gerade angezeigte. Sonst ändert das Element bei jeder Messung seine Größe und
|
|
/// schiebt alle Symbole rechts davon hin und her.
|
|
public enum MenuBarText {
|
|
|
|
/// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
|
|
///
|
|
/// Als berechnete Eigenschaft, nicht als gespeicherte: `NSFont` ist nicht
|
|
/// `Sendable`, und die Schrift jedes Mal neu zu erfragen kostet nichts —
|
|
/// AppKit gibt dieselbe zwischengespeicherte Instanz zurück.
|
|
public static var font: NSFont {
|
|
NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
|
|
}
|
|
|
|
/// Ein Punkt je Seite. macOS legt um jedes Statuselement ohnehin rund
|
|
/// acht Punkte eigene Polsterung — was hier draufkommt, kommt oben drauf.
|
|
public static let sidePadding: CGFloat = 1
|
|
public static let symbolWidth: CGFloat = 13
|
|
/// Abstand zwischen Symbol und Zahl.
|
|
public static let innerGap: CGFloat = 2
|
|
/// Schmaler geht nicht, ohne dass ein Glyph anstößt.
|
|
public static let minimumWidth: CGFloat = 18
|
|
|
|
/// Die Breite eines Elements aus gemessener Textbreite. Rein, damit die
|
|
/// Rechnung prüfbar bleibt.
|
|
public static func itemWidth(textWidth: CGFloat, includesSymbol: Bool) -> CGFloat {
|
|
let symbol = includesSymbol ? symbolWidth + innerGap : 0
|
|
let content = textWidth > 0 ? ceil(textWidth) + 2 * sidePadding : 2 * sidePadding
|
|
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
|
|
}
|
|
|
|
/// Die Breite für die breiteste vorkommende Zeichenfolge.
|
|
public static func width(for reference: String, includesSymbol: Bool = false) -> CGFloat {
|
|
itemWidth(textWidth: width(of: reference), includesSymbol: includesSymbol)
|
|
}
|
|
|
|
/// Zeichnet ein SF-Symbol in der gewünschten Farbe.
|
|
///
|
|
/// `color.set()` vor `image.draw(in:)` reicht **nicht**. Ein Template-Bild
|
|
/// trägt seine Farbe nicht aus dem Grafikzustand, sondern wird schwarz
|
|
/// gezeichnet — auf einer dunklen Menüleiste also unsichtbar. AppKit färbt
|
|
/// Template-Bilder nur ein, wenn sie als `image` eines Buttons gesetzt sind;
|
|
/// in einer selbst gezeichneten Ansicht muss die Farbe in die
|
|
/// Symbolkonfiguration.
|
|
public static func drawSymbol(_ name: String, color: NSColor, in rect: NSRect) {
|
|
guard let tinted = symbol(name, color: color, height: rect.height) else { return }
|
|
// Seitenverhältnis wahren. Ein Batteriesymbol ist doppelt so breit wie
|
|
// hoch; in ein Quadrat gezeichnet sieht es zerquetscht aus.
|
|
let size = fittedSize(of: tinted, height: rect.height)
|
|
tinted.draw(in: NSRect(x: rect.midX - size.width / 2,
|
|
y: rect.midY - size.height / 2,
|
|
width: size.width, height: size.height))
|
|
}
|
|
|
|
/// Wie breit ein Symbol bei dieser Höhe tatsächlich ist.
|
|
///
|
|
/// Ohne diese Messung bekommt ein breites Symbol denselben Platz wie ein
|
|
/// schmales — entweder gequetscht oder mit einer Lücke daneben.
|
|
public static func symbolWidth(_ name: String, height: CGFloat) -> CGFloat {
|
|
guard let image = symbol(name, color: .labelColor, height: height) else {
|
|
return height
|
|
}
|
|
return fittedSize(of: image, height: height).width
|
|
}
|
|
|
|
private static func symbol(_ name: String, color: NSColor, height: CGFloat) -> NSImage? {
|
|
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
|
else { return nil }
|
|
let configuration = NSImage.SymbolConfiguration(pointSize: height, weight: .regular)
|
|
.applying(NSImage.SymbolConfiguration(paletteColors: [color]))
|
|
return image.withSymbolConfiguration(configuration) ?? image
|
|
}
|
|
|
|
private static func fittedSize(of image: NSImage, height: CGFloat) -> NSSize {
|
|
let natural = image.size
|
|
guard natural.height > 0 else { return NSSize(width: height, height: height) }
|
|
return NSSize(width: (natural.width / natural.height) * height, height: height)
|
|
}
|
|
|
|
public static func draw(_ text: String, color: NSColor, in rect: NSRect) {
|
|
let string = NSAttributedString(string: text,
|
|
attributes: [.font: font, .foregroundColor: color])
|
|
let size = string.size()
|
|
string.draw(at: NSPoint(x: rect.midX - size.width / 2,
|
|
y: rect.midY - size.height / 2))
|
|
}
|
|
}
|
|
|
|
/// Eine Statusansicht, deren nötige Breite sich ändern kann.
|
|
///
|
|
/// Der Controller stellt das Statuselement daraufhin neu ein. Ohne diesen Weg
|
|
/// bliebe ein Element in der Breite stehen, die beim allerersten Zeichnen
|
|
/// gebraucht wurde — vor der ersten Messung also.
|
|
@MainActor
|
|
public protocol WidthReporting: AnyObject {
|
|
var onWidthChange: ((CGFloat) -> Void)? { get set }
|
|
}
|
|
|
|
/// Eine Breite, die mitwächst — aber nur ungern schrumpft.
|
|
///
|
|
/// Eine feste Breite für den schlimmsten Fall lässt neben jedem gewöhnlichen
|
|
/// Wert Platz stehen: „↓ 13 KB/s" braucht gut halb so viel wie „↓ 999,9 MB/s".
|
|
/// Eine Breite, die jedem Messwert folgt, schiebt dagegen bei jeder Messung
|
|
/// alle Symbole rechts davon hin und her.
|
|
///
|
|
/// Der Kompromiss ist unsymmetrisch, und das mit Absicht: **sofort wachsen**,
|
|
/// denn abgeschnittene Messwerte sind falsche Messwerte. **Zögernd
|
|
/// schrumpfen**, damit ein kurzer Lastausschlag die Leiste nicht zum Pumpen
|
|
/// bringt.
|
|
public struct AdaptiveWidth: Sendable {
|
|
|
|
/// Wie viele ruhigere Messungen es braucht, bevor Platz abgegeben wird.
|
|
/// Bei zwei Sekunden Abtastung sind das gut zwanzig Sekunden Geduld.
|
|
public static let shrinkAfter = 10
|
|
|
|
public let minimum: CGFloat
|
|
private var current: CGFloat = 0
|
|
private var calmSamples = 0
|
|
|
|
public init(minimum: CGFloat) {
|
|
self.minimum = minimum
|
|
}
|
|
|
|
public mutating func update(needed: CGFloat) -> CGFloat {
|
|
let wanted = max(needed, minimum)
|
|
|
|
if wanted > current {
|
|
current = wanted
|
|
calmSamples = 0
|
|
} else if wanted < current {
|
|
calmSamples += 1
|
|
if calmSamples >= Self.shrinkAfter {
|
|
current = wanted
|
|
calmSamples = 0
|
|
}
|
|
} else {
|
|
calmSamples = 0
|
|
}
|
|
return current
|
|
}
|
|
}
|