refactor(mac): embed work badge in status icon
parent
337ae05ed8
commit
241cf10bdb
|
|
@ -27,18 +27,8 @@ struct CritterStatusLabel: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Group {
|
self.iconImage
|
||||||
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)
|
.frame(width: 18, height: 18)
|
||||||
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
|
||||||
.offset(x: self.wiggleOffset)
|
.offset(x: self.wiggleOffset)
|
||||||
|
|
@ -95,30 +85,37 @@ struct CritterStatusLabel: View {
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .idle = self.iconState {
|
private var iconImage: Image {
|
||||||
EmptyView()
|
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
|
||||||
|
CritterIconRenderer.Badge(
|
||||||
|
symbolName: self.iconState.badgeSymbolName,
|
||||||
|
prominence: prominence)
|
||||||
} else {
|
} else {
|
||||||
Text(self.iconState.glyph)
|
nil
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue