refactor(mac): embed work badge in status icon
This commit is contained in:
@@ -27,98 +27,95 @@ struct CritterStatusLabel: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if self.isPaused {
|
||||
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
|
||||
.frame(width: 18, height: 18)
|
||||
} else {
|
||||
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))
|
||||
.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
|
||||
}
|
||||
ZStack(alignment: .topTrailing) {
|
||||
self.iconImage
|
||||
.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 {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
if now >= self.nextBlink {
|
||||
self.blink()
|
||||
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
}
|
||||
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
if now >= self.nextWiggle {
|
||||
self.wiggle()
|
||||
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
|
||||
}
|
||||
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
if now >= self.nextLegWiggle {
|
||||
self.wiggleLegs()
|
||||
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
}
|
||||
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
if now >= self.nextEarWiggle {
|
||||
self.wiggleEars()
|
||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
}
|
||||
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.animationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
if self.isWorkingNow {
|
||||
self.scurry()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
|
||||
.onChange(of: self.blinkTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.blink()
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
} else {
|
||||
self.resetMotion()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.earBoostActive) { _, active in
|
||||
if active {
|
||||
self.resetMotion()
|
||||
} else if self.animationsEnabled {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.gatewayNeedsAttention {
|
||||
Circle()
|
||||
.fill(self.gatewayBadgeColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
|
||||
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: 6, height: 6)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
.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() {
|
||||
@@ -213,12 +210,26 @@ struct CritterStatusLabel: View {
|
||||
enum CritterIconRenderer {
|
||||
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(
|
||||
blink: CGFloat,
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
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.
|
||||
let pixelsWide = 36
|
||||
@@ -371,6 +382,12 @@ enum CritterIconRenderer {
|
||||
context.cgContext.addPath(right)
|
||||
context.cgContext.fillPath()
|
||||
context.cgContext.restoreGState()
|
||||
|
||||
if let badge {
|
||||
self.drawBadge(
|
||||
badge,
|
||||
canvas: Canvas(w: w, h: h, snapX: snapX, snapY: snapY, context: context.cgContext))
|
||||
}
|
||||
} else {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return NSImage(size: self.size)
|
||||
@@ -381,4 +398,60 @@ enum CritterIconRenderer {
|
||||
image.isTemplate = true
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,23 +21,29 @@ enum IconState: Equatable {
|
||||
case workingOther(ActivityKind)
|
||||
case overridden(ActivityKind)
|
||||
|
||||
var glyph: String {
|
||||
enum BadgeProminence: Equatable {
|
||||
case primary
|
||||
case secondary
|
||||
case overridden
|
||||
}
|
||||
|
||||
var badgeSymbolName: String {
|
||||
switch self.activity {
|
||||
case .tool(.bash): "💻"
|
||||
case .tool(.read): "📄"
|
||||
case .tool(.write): "✍️"
|
||||
case .tool(.edit): "📝"
|
||||
case .tool(.attach): "📎"
|
||||
case .tool(.other), .job: "🛠️"
|
||||
case .tool(.bash): "terminal.fill"
|
||||
case .tool(.read): "doc.text.magnifyingglass"
|
||||
case .tool(.write): "pencil"
|
||||
case .tool(.edit): "square.and.pencil"
|
||||
case .tool(.attach): "paperclip"
|
||||
case .tool(.other), .job: "wrench.and.screwdriver.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var tint: Color {
|
||||
var badgeProminence: BadgeProminence? {
|
||||
switch self {
|
||||
case .workingMain: .accentColor
|
||||
case .workingOther: .gray
|
||||
case .overridden: .orange
|
||||
case .idle: .clear
|
||||
case .idle: nil
|
||||
case .workingMain: .primary
|
||||
case .workingOther: .secondary
|
||||
case .overridden: .overridden
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user