Auslösefläche einstellbar, Blitz immer lesbar, Menüleiste schmaler

Der Blitz war ein Loch in der Füllung — über dem gefüllten Teil liest man
ihn, über dem leeren ist es Transparenz auf Transparenz. Bei wenig Ladung
sitzt er fast ganz im Leeren und verschwindet.

Statt zwischen Schwarz und Weiß zu faden geht er den Weg von macOS: geteilt
gezeichnet, über der Füllung ausgestanzt, daneben ausgemalt. Damit ist er
bei jedem Ladestand lesbar, ohne Bewegung in der Menüleiste. Ein Test
prüft, dass sich die beiden Teile an der Kante treffen statt zu überlappen —
sonst wäre der Blitz an der Nahtstelle dicker.

Die Auslösefläche der Notch lässt sich jetzt einstellen, mit Vorschau. Wie
genau jemand die Notch trifft, hängt an Hand und Zeigegerät; feste Werte im
Code waren dafür die falsche Antwort. Die Vorschau zeigt einen
nachgebildeten Bildschirm mit Notch und eingefärbter Fläche — „24 Punkte
unter der Notch" ist keine Vorstellung, die jemand im Kopf hat.

Die Menüleistenbreiten folgen jetzt dem, was dasteht. „Temp 53°" stand in
einer Box für „Temp 100 °C", und in der Menüleiste ist Platz das knappste
Gut. Dieselbe Hysterese wie beim Netzwerk: sofort wachsen, zögernd
schrumpfen.

Das Mixer-Symbol war ausgeschaltet auf 55 % abgeblendet. Das war meine Idee
und ein Fehlgriff: über einem hellen Schreibtischhintergrund ist es damit
praktisch weg. Ob der Mixer läuft, erfährt man beim Klick — dafür muss man
das Symbol aber erst finden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 12:46:02 +02:00
parent bf799d57d9
commit a84e5a0c8e
18 changed files with 536 additions and 50 deletions

View File

@@ -49,12 +49,34 @@ public enum BatteryGlyph {
return isPluggedIn ? .plug : .none
}
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
///
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
public static func boltSplit(charge: Double,
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
let width = fillWidth(charge: charge, inner: inner.width)
let filled = CGRect(x: inner.minX, y: inner.minY,
width: width, height: inner.height)
let empty = CGRect(x: inner.minX + width, y: inner.minY,
width: inner.width - width, height: inner.height)
return (filled, empty)
}
public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
// sich schon erledigt.
!isCharging && charge < lowThreshold
}
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
return image.withSymbolConfiguration(configuration)
}
/// Zeichnet den Akku in `rect`.
///
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
@@ -105,9 +127,20 @@ public enum BatteryGlyph {
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
y: rect.midY - glyphHeight / 2,
width: glyphWidth, height: glyphHeight)
// Geteilt zeichnen: über der Füllung ein Loch, daneben ein Glyph.
let split = boltSplit(charge: charge, inner: inner)
NSGraphicsContext.saveGraphicsState()
NSBezierPath(rect: split.filled).setClip()
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
NSGraphicsContext.restoreGraphicsState()
NSGraphicsContext.saveGraphicsState()
NSBezierPath(rect: split.empty).setClip()
if let tinted = tint(glyph, with: color) {
tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
}
NSGraphicsContext.restoreGraphicsState()
}
}
@@ -155,11 +188,31 @@ public struct BatteryGlyphView: View {
}
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
.overlay {
// Geteilt wie in der Menüleiste: über der Füllung ein Loch,
// daneben ein ausgemaltes Glyph. Nur auszustanzen hieße, dass
// der Blitz bei wenig Ladung im Leeren verschwindet.
if let name = BatteryGlyph.indicator(isCharging: isCharging,
isPluggedIn: isPluggedIn).symbolName {
Image(systemName: name)
.font(.system(size: height * 0.68))
.blendMode(.destinationOut)
GeometryReader { geometry in
let inner = CGRect(origin: .zero, size: geometry.size)
.insetBy(dx: 2 * scale, dy: 2 * scale)
let split = BatteryGlyph.boltSplit(charge: charge, inner: inner)
let glyph = Image(systemName: name)
.font(.system(size: height * 0.68))
.frame(width: geometry.size.width, height: geometry.size.height)
glyph.blendMode(.destinationOut)
.mask(alignment: .topLeading) {
Rectangle().frame(width: split.filled.width,
height: geometry.size.height)
.offset(x: split.filled.minX)
}
glyph.mask(alignment: .topLeading) {
Rectangle().frame(width: split.empty.width,
height: geometry.size.height)
.offset(x: split.empty.minX)
}
}
}
}
.compositingGroup()

View File

@@ -113,23 +113,29 @@ final class MetricStatusView: NSView, WidthReporting {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth)
private var shownWidth: CGFloat = MenuBarText.minimumWidth
override var intrinsicContentSize: NSSize {
NSSize(width: width, height: 22)
NSSize(width: shownWidth, height: 22)
}
/// Gemessen statt geschätzt und zwar an der **breitesten Zeichenfolge,
/// die vorkommen kann**, nicht an der gerade angezeigten. Sonst schiebt
/// jedes Prozent alle Symbole rechts davon hin und her.
/// Der Platz für das, was **gerade** dasteht.
///
/// Auf die breiteste denkbare Zeichenfolge zu dimensionieren war zu teuer:
/// Temp 53°" stand in einer Box für Temp 100 °C", und in der Menüleiste
/// ist Platz das knappste Gut. `AdaptiveWidth` nimmt dem die Unruhe
/// sofort wachsen, zögernd schrumpfen.
private var width: CGFloat {
switch presentation {
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
case .graph: 28
case .bars: barsWidth
case .value: MenuBarText.width(for: reference)
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
case .value: MenuBarText.width(for: displayValue)
case .valueAndGraph: MenuBarText.width(for: displayValue) + 2 + 28
case .labelAndValue: MenuBarText.width(for: "\(label) \(displayValue)")
case .symbolAndValue:
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
MenuBarText.width(for: displayValue) + symbolSlot + MenuBarText.innerGap
}
}
@@ -150,13 +156,6 @@ final class MetricStatusView: NSView, WidthReporting {
return MetricSummary.value(metric, snapshot, mode: mode)
}
/// Die breiteste Zeichenfolge dieser Metrik.
private var reference: String {
switch (metric, mode) {
case (.temperature, _), (_, .temperature): "100 °C"
default: "100 %"
}
}
/// Meldet, wenn sich die nötige Breite geändert hat der Controller stellt
/// das Element danach neu ein.
@@ -166,17 +165,16 @@ final class MetricStatusView: NSView, WidthReporting {
var sensorCelsius: Double?
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
let before = width
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
// Die Kernzahl steht erst nach der ersten Messung fest, und der Modus
// wechselt zwischen 100 %" und 100 °C".
if width != before {
invalidateIntrinsicContentSize()
onWidthChange?(width)
}
let next = adaptive.update(needed: width)
guard next != shownWidth else { return }
shownWidth = next
invalidateIntrinsicContentSize()
onWidthChange?(next)
}
override func draw(_ dirtyRect: NSRect) {