refactor(mac): embed work badge in status icon

main
Peter Steinberger 2025-12-12 18:40:33 +00:00
parent 337ae05ed8
commit 241cf10bdb
2 changed files with 172 additions and 93 deletions

View File

@ -27,98 +27,95 @@ struct CritterStatusLabel: View {
} }
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .topTrailing) {
Group { self.iconImage
if self.isPaused { .frame(width: 18, height: 18)
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0)) .rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.frame(width: 18, height: 18) .offset(x: self.wiggleOffset)
} else { .onReceive(self.ticker) { now in
Image(nsImage: CritterIconRenderer.makeIcon( guard self.animationsEnabled, !self.earBoostActive else {
blink: self.blinkAmount, self.resetMotion()
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), return
earWiggle: self.earWiggle, }
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive))
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
.onReceive(self.ticker) { now in
guard self.animationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink { if now >= self.nextBlink {
self.blink() self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5)) self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
} }
if now >= self.nextWiggle { if now >= self.nextWiggle {
self.wiggle() self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14)) self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
} }
if now >= self.nextLegWiggle { if now >= self.nextLegWiggle {
self.wiggleLegs() self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0)) self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
} }
if now >= self.nextEarWiggle { if now >= self.nextEarWiggle {
self.wiggleEars() self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
} }
if self.isWorkingNow { if self.isWorkingNow {
self.scurry() self.scurry()
} }
} }
.onChange(of: self.isPaused) { _, _ in self.resetMotion() } .onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in .onChange(of: self.blinkTick) { _, _ in
guard !self.earBoostActive else { return } guard !self.earBoostActive else { return }
self.blink() self.blink()
} }
.onChange(of: self.sendCelebrationTick) { _, _ in .onChange(of: self.sendCelebrationTick) { _, _ in
guard !self.earBoostActive else { return } guard !self.earBoostActive else { return }
self.wiggleLegs() self.wiggleLegs()
} }
.onChange(of: self.animationsEnabled) { _, enabled in .onChange(of: self.animationsEnabled) { _, enabled in
if enabled { if enabled {
self.scheduleRandomTimers(from: Date()) self.scheduleRandomTimers(from: Date())
} else { } else {
self.resetMotion() self.resetMotion()
} }
} }
.onChange(of: self.earBoostActive) { _, active in .onChange(of: self.earBoostActive) { _, active in
if active { if active {
self.resetMotion() self.resetMotion()
} else if self.animationsEnabled { } else if self.animationsEnabled {
self.scheduleRandomTimers(from: Date()) self.scheduleRandomTimers(from: Date())
} }
}
} }
}
if self.gatewayNeedsAttention { if self.gatewayNeedsAttention {
Circle() Circle()
.fill(self.gatewayBadgeColor) .fill(self.gatewayBadgeColor)
.frame(width: 8, height: 8) .frame(width: 6, height: 6)
.offset(x: 4, y: 4) .padding(1)
}
if case .idle = self.iconState {
EmptyView()
} else {
Text(self.iconState.glyph)
.font(.system(size: 9))
.padding(3)
.background(
Circle()
.fill(self.iconState.tint.opacity(0.9)))
.foregroundStyle(Color.white)
.offset(x: -4, y: -2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
} }
} }
.frame(width: 18, height: 18)
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
symbolName: self.iconState.badgeSymbolName,
prominence: prominence)
} else {
nil
}
if self.isPaused {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
} }
private func resetMotion() { private func resetMotion() {
@ -213,12 +210,26 @@ struct CritterStatusLabel: View {
enum CritterIconRenderer { enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 18) private static let size = NSSize(width: 18, height: 18)
struct Badge {
let symbolName: String
let prominence: IconState.BadgeProminence
}
private struct Canvas {
let w: CGFloat
let h: CGFloat
let snapX: (CGFloat) -> CGFloat
let snapY: (CGFloat) -> CGFloat
let context: CGContext
}
static func makeIcon( static func makeIcon(
blink: CGFloat, blink: CGFloat,
legWiggle: CGFloat = 0, legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0, earWiggle: CGFloat = 0,
earScale: CGFloat = 1, earScale: CGFloat = 1,
earHoles: Bool = false) -> NSImage earHoles: Bool = false,
badge: Badge? = nil) -> NSImage
{ {
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina. // Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
let pixelsWide = 36 let pixelsWide = 36
@ -371,6 +382,12 @@ enum CritterIconRenderer {
context.cgContext.addPath(right) context.cgContext.addPath(right)
context.cgContext.fillPath() context.cgContext.fillPath()
context.cgContext.restoreGState() context.cgContext.restoreGState()
if let badge {
self.drawBadge(
badge,
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
}
} else { } else {
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return NSImage(size: self.size) return NSImage(size: self.size)
@ -381,4 +398,60 @@ enum CritterIconRenderer {
image.isTemplate = true image.isTemplate = true
return image return image
} }
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
let strength: CGFloat = switch badge.prominence {
case .primary: 1.0
case .secondary: 0.58
case .overridden: 0.85
}
let diameter = canvas.snapX(canvas.w * 0.44) // ~8pt on an 18pt canvas
let margin = canvas.snapX(max(0.6, canvas.w * 0.04))
let rect = CGRect(
x: canvas.snapX(canvas.w - diameter - margin),
y: canvas.snapY(margin),
width: diameter,
height: diameter)
canvas.context.saveGState()
canvas.context.setShouldAntialias(true)
// Clear the underlying pixels so the badge stays readable over the critter.
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
canvas.context.addEllipse(in: rect.insetBy(dx: -0.7, dy: -0.7))
canvas.context.fillPath()
canvas.context.restoreGState()
let fillAlpha: CGFloat = 0.33 * strength
let strokeAlpha: CGFloat = 0.92 * strength
let symbolAlpha: CGFloat = 0.98 * strength
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
canvas.context.addEllipse(in: rect)
canvas.context.fillPath()
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
canvas.context.setLineWidth(max(1.0, canvas.snapX(canvas.w * 0.065)))
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.35, dy: 0.35))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(5.0, diameter * 0.62)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold)
let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true
let symbolRect = rect.insetBy(dx: diameter * 0.20, dy: diameter * 0.20)
symbol.draw(
in: symbolRect,
from: .zero,
operation: .sourceOver,
fraction: symbolAlpha,
respectFlipped: true,
hints: nil)
}
canvas.context.restoreGState()
}
} }

View File

@ -21,23 +21,29 @@ enum IconState: Equatable {
case workingOther(ActivityKind) case workingOther(ActivityKind)
case overridden(ActivityKind) case overridden(ActivityKind)
var glyph: String { enum BadgeProminence: Equatable {
case primary
case secondary
case overridden
}
var badgeSymbolName: String {
switch self.activity { switch self.activity {
case .tool(.bash): "💻" case .tool(.bash): "terminal.fill"
case .tool(.read): "📄" case .tool(.read): "doc.text.magnifyingglass"
case .tool(.write): "✍️" case .tool(.write): "pencil"
case .tool(.edit): "📝" case .tool(.edit): "square.and.pencil"
case .tool(.attach): "📎" case .tool(.attach): "paperclip"
case .tool(.other), .job: "🛠️" case .tool(.other), .job: "wrench.and.screwdriver.fill"
} }
} }
var tint: Color { var badgeProminence: BadgeProminence? {
switch self { switch self {
case .workingMain: .accentColor case .idle: nil
case .workingOther: .gray case .workingMain: .primary
case .overridden: .orange case .workingOther: .secondary
case .idle: .clear case .overridden: .overridden
} }
} }