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