Files
openclaw/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift
2026-05-17 05:21:47 +01:00

380 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
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 stepX: CGFloat
let stepY: CGFloat
let snapX: (CGFloat) -> CGFloat
let snapY: (CGFloat) -> CGFloat
let context: CGContext
}
private struct Geometry {
let bodyRect: CGRect
let leftArmRect: CGRect
let rightArmRect: CGRect
let leftEarRect: CGRect
let rightEarRect: CGRect
let antennaLineWidth: CGFloat
let legW: CGFloat
let legH: CGFloat
let legSpacing: CGFloat
let legStartX: CGFloat
let legYBase: CGFloat
let legLift: CGFloat
let legHeightScale: CGFloat
let eyeSize: CGSize
let eyeY: CGFloat
let eyeOffset: CGFloat
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) {
let w = canvas.w
let h = canvas.h
let snapX = canvas.snapX
let snapY = canvas.snapY
let bodyW = snapX(w * 0.68)
let bodyH = snapY(h * 0.68)
let bodyX = snapX((w - bodyW) / 2)
let bodyY = snapY(h * 0.24)
let armSize = snapX(w * 0.2)
let armY = snapY(bodyY + bodyH * 0.36)
let leftArmRect = CGRect(
x: snapX(bodyX - armSize * 0.62),
y: armY,
width: armSize,
height: armSize)
let rightArmRect = CGRect(
x: snapX(bodyX + bodyW - armSize * 0.38),
y: armY,
width: armSize,
height: armSize)
let antennaW = snapX(w * 0.22)
let antennaH = snapY(min(bodyH * 0.24 * earScale, h * 0.19))
let antennaLineWidth = max(snapX(w * 0.095), canvas.stepX * 2) * min(1.2, 0.94 + earScale * 0.06)
let antennaLift = snapY(earWiggle * 0.35)
let leftEarRect = CGRect(
x: snapX(bodyX + bodyW * 0.18 - antennaW * 0.35 - earWiggle * 0.28),
y: snapY(bodyY + bodyH * 0.86 + antennaLift),
width: antennaW,
height: antennaH)
let rightEarRect = CGRect(
x: snapX(bodyX + bodyW * 0.82 - antennaW * 0.65 + earWiggle * 0.28),
y: snapY(bodyY + bodyH * 0.86 - antennaLift),
width: antennaW,
height: antennaH)
let legW = snapX(w * 0.15)
let legH = snapY(h * 0.25)
let legSpacing = snapX(w * 0.16)
let legsWidth = snapX(2 * legW + legSpacing)
let legStartX = snapX((w - legsWidth) / 2)
let legLift = snapY(legH * 0.35 * legWiggle)
let legYBase = snapY(bodyY - legH * 0.58)
let legHeightScale = 1 - 0.12 * legWiggle
let eyeSize = CGSize(
width: snapX(bodyW * 0.15),
height: snapY(bodyH * 0.2))
let eyeY = snapY(bodyY + bodyH * 0.58)
let eyeOffset = snapX(bodyW * 0.22)
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
self.leftArmRect = leftArmRect
self.rightArmRect = rightArmRect
self.leftEarRect = leftEarRect
self.rightEarRect = rightEarRect
self.antennaLineWidth = antennaLineWidth
self.legW = legW
self.legH = legH
self.legSpacing = legSpacing
self.legStartX = legStartX
self.legYBase = legYBase
self.legLift = legLift
self.legHeightScale = legHeightScale
self.eyeSize = eyeSize
self.eyeY = eyeY
self.eyeOffset = eyeOffset
}
}
private struct FaceOptions {
let blink: CGFloat
let eyesClosedLines: Bool
}
static func makeIcon(
blink: CGFloat,
legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
earHoles: Bool = false,
eyesClosedLines: Bool = false,
badge: Badge? = nil) -> NSImage
{
guard let rep = self.makeBitmapRep() else {
return NSImage(size: self.size)
}
rep.size = self.size
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
return NSImage(size: self.size)
}
NSGraphicsContext.current = context
context.imageInterpolation = .none
context.cgContext.setShouldAntialias(true)
let canvas = self.makeCanvas(for: rep, context: context)
let geometry = Geometry(
canvas: canvas,
legWiggle: legWiggle,
earWiggle: earWiggle,
earScale: earHoles ? max(earScale, 1.2) : earScale)
self.drawBody(in: canvas, geometry: geometry)
let face = FaceOptions(
blink: blink,
eyesClosedLines: eyesClosedLines)
self.drawFace(in: canvas, geometry: geometry, options: face)
if let badge {
self.drawBadge(badge, canvas: canvas)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true
return image
}
private static func makeBitmapRep() -> NSBitmapImageRep? {
// 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 pixelsHigh = 36
return NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0)
}
private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas {
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
let w = snapX(size.width)
let h = snapY(size.height)
return Canvas(
w: w,
h: h,
stepX: stepX,
stepY: stepY,
snapX: snapX,
snapY: snapY,
context: context.cgContext)
}
private static func drawBody(in canvas: Canvas, geometry: Geometry) {
canvas.context.setStrokeColor(NSColor.labelColor.cgColor)
canvas.context.setLineWidth(geometry.antennaLineWidth)
canvas.context.setLineCap(.round)
canvas.context.setLineJoin(.round)
let leftStart = CGPoint(
x: canvas.snapX(geometry.bodyRect.minX + geometry.bodyRect.width * 0.34),
y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22))
let leftEnd = CGPoint(
x: canvas.snapX(geometry.leftEarRect.minX),
y: canvas.snapY(geometry.leftEarRect.maxY))
let leftControl = CGPoint(
x: canvas.snapX(geometry.leftEarRect.midX),
y: canvas.snapY(geometry.leftEarRect.minY))
let rightStart = CGPoint(
x: canvas.snapX(geometry.bodyRect.maxX - geometry.bodyRect.width * 0.34),
y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22))
let rightEnd = CGPoint(
x: canvas.snapX(geometry.rightEarRect.maxX),
y: canvas.snapY(geometry.rightEarRect.maxY))
let rightControl = CGPoint(
x: canvas.snapX(geometry.rightEarRect.midX),
y: canvas.snapY(geometry.rightEarRect.minY))
let antennae = CGMutablePath()
antennae.move(to: leftStart)
antennae.addQuadCurve(to: leftEnd, control: leftControl)
antennae.move(to: rightStart)
antennae.addQuadCurve(to: rightEnd, control: rightControl)
canvas.context.addPath(antennae)
canvas.context.strokePath()
canvas.context.setFillColor(NSColor.labelColor.cgColor)
for i in 0..<2 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
let rect = CGRect(
x: x,
y: geometry.legYBase + lift,
width: geometry.legW,
height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath(
roundedRect: rect,
cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34,
transform: nil))
}
canvas.context.addEllipse(in: geometry.leftArmRect)
canvas.context.addEllipse(in: geometry.rightArmRect)
canvas.context.addEllipse(in: geometry.bodyRect)
canvas.context.fillPath()
}
private static func drawFace(
in canvas: Canvas,
geometry: Geometry,
options: FaceOptions)
{
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
let leftCenter = CGPoint(
x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset),
y: canvas.snapY(geometry.eyeY))
let rightCenter = CGPoint(
x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset),
y: canvas.snapY(geometry.eyeY))
if options.eyesClosedLines {
let lineW = canvas.snapX(geometry.eyeSize.width * 1.15)
let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06))
let corner = canvas.snapX(lineH * 0.6)
let leftRect = CGRect(
x: canvas.snapX(leftCenter.x - lineW / 2),
y: canvas.snapY(leftCenter.y - lineH / 2),
width: lineW,
height: lineH)
let rightRect = CGRect(
x: canvas.snapX(rightCenter.x - lineW / 2),
y: canvas.snapY(rightCenter.y - lineH / 2),
width: lineW,
height: lineH)
canvas.context.addPath(CGPath(
roundedRect: leftRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: rightRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
} else {
let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.eyeSize.height * eyeOpen)
let leftRect = CGRect(
x: canvas.snapX(leftCenter.x - geometry.eyeSize.width / 2),
y: canvas.snapY(leftCenter.y - eyeH / 2),
width: geometry.eyeSize.width,
height: eyeH)
let rightRect = CGRect(
x: canvas.snapX(rightCenter.x - geometry.eyeSize.width / 2),
y: canvas.snapY(rightCenter.y - eyeH / 2),
width: geometry.eyeSize.width,
height: eyeH)
canvas.context.addEllipse(in: leftRect)
canvas.context.addEllipse(in: rightRect)
}
canvas.context.fillPath()
canvas.context.restoreGState()
}
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
}
// Bigger, higher-contrast badge:
// - Increase diameter so tool activity is noticeable.
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~910pt on an 18pt canvas
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
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: -1.0, dy: -1.0))
canvas.context.fillPath()
canvas.context.restoreGState()
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * 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.25, canvas.snapX(canvas.w * 0.075)))
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(7.0, diameter * 0.82)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
symbol.draw(
in: symbolRect,
from: .zero,
operation: .sourceOver,
fraction: 1,
respectFlipped: true,
hints: nil)
canvas.context.restoreGState()
}
canvas.context.restoreGState()
}
}