Files
onyx/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift
Scarriffle 43ce40676b Eigenes Symbol für den Mixer in der Menüleiste
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>
2026-08-13 13:23:21 +02:00

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
}
}