Die Datei lag im Projektstamm; von dort wird nichts mitgeliefert. Jetzt in Onyx/ und damit im Bundle. Gezeichnet wird sie in `labelColor`, nicht in Weiß: unter einer hellen Menüleiste wäre ein weißes Symbol unsichtbar. Auf dunklem Grund ist labelColor weiß, also genau das Gewünschte — und auf hellem Grund schwarz, statt zu verschwinden. Eigene Vorlagen lassen sich nicht wie SF-Symbole einfärben: dort greift die Symbolkonfiguration nicht, und `color.set()` allein färbt ein Template-Bild ohnehin nicht. Der Weg ist, das Bild zu zeichnen und die Farbe mit `sourceAtop` darüberzulegen — das trifft die deckenden Bildpunkte und lässt den Rest frei. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
8.0 KiB
Swift
182 lines
8.0 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// Zeichnet ein beliebiges Bild in der gewünschten Farbe.
|
|
///
|
|
/// Für eigene Vorlagen statt SF-Symbole: dort greift die
|
|
/// Symbolkonfiguration nicht, und `color.set()` allein färbt ein
|
|
/// Template-Bild nicht. Der Weg ist, das Bild zu zeichnen und die Farbe
|
|
/// mit `sourceAtop` darüberzulegen — das trifft genau die deckenden
|
|
/// Bildpunkte und lässt den Rest frei.
|
|
public static func drawTemplate(_ image: NSImage, color: NSColor, in rect: NSRect) {
|
|
let natural = image.size
|
|
let fitted: NSSize = natural.height > 0
|
|
? NSSize(width: (natural.width / natural.height) * rect.height, height: rect.height)
|
|
: rect.size
|
|
let target = NSRect(x: rect.midX - fitted.width / 2,
|
|
y: rect.midY - fitted.height / 2,
|
|
width: fitted.width, height: fitted.height)
|
|
|
|
let tinted = NSImage(size: target.size, flipped: false) { bounds in
|
|
image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1)
|
|
color.set()
|
|
bounds.fill(using: .sourceAtop)
|
|
return true
|
|
}
|
|
tinted.draw(in: target)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|