Files
openclaw/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift
Peter Steinberger a40e43e46e feat(mac): redesign the menu bar critter to match the mascot (#104846)
* feat(mac): redesign menu bar critter to match the mascot

* fix(mac): draw happy-eye arcs upward and guard stale celebration expiry

* docs(mac): update menu bar icon states for critter redesign

* fix(mac): own celebration expiry with a start-only generation counter

* fix(mac): give voice-wake antennae real headroom at 18pt
2026-07-12 06:35:02 +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 Antenna {
let start: CGPoint
let control: CGPoint
let tip: CGPoint
}
private struct Geometry {
let bodyRect: CGRect
let armRadius: CGFloat
let leftArmCenter: CGPoint
let rightArmCenter: CGPoint
let antennaLineWidth: CGFloat
let leftAntenna: Antenna
let rightAntenna: Antenna
let legW: CGFloat
let legH: CGFloat
let legCenters: [CGFloat]
let legYBase: CGFloat
let legLift: CGFloat
let eyeSize: CGSize
let eyeY: CGFloat
let eyeOffset: CGFloat
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat, antennaDroop: CGFloat) {
let w = canvas.w
let h = canvas.h
let cx = w / 2
// Chubby near-circular body: the mascot's dominant shape must survive 18pt.
let bodyW = w * 0.72
let bodyH = h * 0.66
let bodyX = (w - bodyW) / 2
let bodyY = h * 0.19
let bodyTop = bodyY + bodyH
// Round arm nubs poking out at the sides, slightly below center like the mascot.
let armRadius = w * 0.10
let armY = bodyY + bodyH * 0.42
self.armRadius = armRadius
self.leftArmCenter = CGPoint(x: bodyX + armRadius * 0.1, y: armY)
self.rightArmCenter = CGPoint(x: bodyX + bodyW - armRadius * 0.1, y: armY)
// Stubby antennae curling outward; droop folds them down when asleep/paused.
// Voice-wake earScale straightens and lengthens them instead of growing horns.
let antennaLineWidth = w * 0.115
let reach = 1 + (min(earScale, 1.9) - 1) * 0.6
let wiggleShift = earWiggle * 0.28
func antenna(side: CGFloat) -> Antenna {
let start = CGPoint(x: cx + side * bodyW * 0.16, y: bodyTop - antennaLineWidth * 0.9)
// Idle tips sit below the canvas clamp so the voice-wake reach has
// real headroom; boosted antennae go taller AND steeper (x pulls
// inward) or the perk would be invisible at 18pt.
let upTip = CGPoint(
x: cx + side * bodyW * (0.40 - 0.20 * (reach - 1)) + side * wiggleShift,
y: min(bodyTop + h * 0.06 * reach + earWiggle * 0.2 * side, h - antennaLineWidth * 0.55))
let upControl = CGPoint(
x: cx + side * bodyW * 0.22,
y: bodyTop + h * 0.075 * reach)
let downTip = CGPoint(x: cx + side * bodyW * 0.52, y: bodyTop - h * 0.13)
let downControl = CGPoint(x: cx + side * bodyW * 0.38, y: bodyTop + h * 0.04)
let tip = CGPoint(
x: upTip.x + (downTip.x - upTip.x) * antennaDroop,
y: upTip.y + (downTip.y - upTip.y) * antennaDroop)
let control = CGPoint(
x: upControl.x + (downControl.x - upControl.x) * antennaDroop,
y: upControl.y + (downControl.y - upControl.y) * antennaDroop)
return Antenna(start: start, control: control, tip: tip)
}
self.leftAntenna = antenna(side: -1)
self.rightAntenna = antenna(side: 1)
self.antennaLineWidth = antennaLineWidth
// Stubby legs tucked under the body; they must overlap the body so the
// silhouette stays connected at menu bar size.
let legW = w * 0.14
let legH = h * 0.18
self.legW = legW
self.legH = legH
self.legCenters = [cx - w * 0.13, cx + w * 0.13]
self.legYBase = bodyY - legH * 0.5
self.legLift = legH * 0.35 * legWiggle
// Big friendly eyes just above center.
self.eyeSize = CGSize(width: bodyW * 0.22, height: bodyH * 0.26)
self.eyeY = bodyY + bodyH * 0.58
self.eyeOffset = bodyW * 0.22
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
}
}
private struct FaceOptions {
let blink: CGFloat
let eyesClosedLines: Bool
let happyEyes: Bool
}
static func makeIcon(
blink: CGFloat,
legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
antennaDroop: CGFloat = 0,
eyesClosedLines: Bool = false,
happyEyes: 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: earScale,
antennaDroop: antennaDroop)
self.drawBody(in: canvas, geometry: geometry)
let face = FaceOptions(
blink: blink,
eyesClosedLines: eyesClosedLines,
happyEyes: happyEyes)
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) {
let ctx = canvas.context
// Antennae first so the body fill covers their roots.
ctx.setStrokeColor(NSColor.labelColor.cgColor)
ctx.setLineWidth(geometry.antennaLineWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
let antennae = CGMutablePath()
for antenna in [geometry.leftAntenna, geometry.rightAntenna] {
antennae.move(to: antenna.start)
antennae.addQuadCurve(to: antenna.tip, control: antenna.control)
}
ctx.addPath(antennae)
ctx.strokePath()
ctx.setFillColor(NSColor.labelColor.cgColor)
for (i, legCenter) in geometry.legCenters.enumerated() {
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
let rect = CGRect(
x: legCenter - geometry.legW / 2,
y: geometry.legYBase + lift,
width: geometry.legW,
height: geometry.legH)
ctx.addPath(CGPath(
roundedRect: rect,
cornerWidth: geometry.legW / 2,
cornerHeight: geometry.legW / 2,
transform: nil))
}
for center in [geometry.leftArmCenter, geometry.rightArmCenter] {
ctx.addEllipse(in: CGRect(
x: center.x - geometry.armRadius,
y: center.y - geometry.armRadius,
width: geometry.armRadius * 2,
height: geometry.armRadius * 2))
}
ctx.addEllipse(in: geometry.bodyRect)
ctx.fillPath()
}
private static func drawFace(
in canvas: Canvas,
geometry: Geometry,
options: FaceOptions)
{
let ctx = canvas.context
let leftCenter = CGPoint(x: canvas.w / 2 - geometry.eyeOffset, y: geometry.eyeY)
let rightCenter = CGPoint(x: canvas.w / 2 + geometry.eyeOffset, y: geometry.eyeY)
ctx.saveGState()
ctx.setBlendMode(.clear)
if options.happyEyes || options.eyesClosedLines {
// Curved lids: happy " " for celebrations, sleepy " " while dozing.
let radius = geometry.eyeSize.width * 0.62
let lineWidth = max(canvas.stepY * 2, geometry.eyeSize.height * 0.34)
ctx.setLineWidth(lineWidth)
ctx.setLineCap(.round)
for center in [leftCenter, rightCenter] {
let arcCenter = CGPoint(
x: center.x,
y: center.y + (options.happyEyes ? -radius * 0.4 : radius * 0.55))
let path = CGMutablePath()
// Counterclockwise sweeps the short arc in this y-up context:
// top half for "", bottom half for "".
path.addArc(
center: arcCenter,
radius: radius,
startAngle: options.happyEyes ? .pi * 0.12 : .pi * 1.12,
endAngle: options.happyEyes ? .pi * 0.88 : .pi * 1.88,
clockwise: false)
ctx.addPath(path)
}
ctx.replacePathWithStrokedPath()
ctx.fillPath()
ctx.restoreGState()
return
}
// Blink squeezes the eye toward a soft line so the face never vanishes mid-blink.
let eyeOpen = max(0.22, 1 - options.blink)
let eyeH = geometry.eyeSize.height * eyeOpen
for center in [leftCenter, rightCenter] {
ctx.addEllipse(in: CGRect(
x: center.x - geometry.eyeSize.width / 2,
y: center.y - eyeH / 2,
width: geometry.eyeSize.width,
height: eyeH))
}
ctx.fillPath()
ctx.restoreGState()
// Glossy glint inside each open eye, echoing the mascot's shiny pupils.
// Skipped while blinking: a dot inside a near-closed lid reads as noise.
if eyeH > geometry.eyeSize.height * 0.7 {
let glintR = geometry.eyeSize.width * 0.26
ctx.setFillColor(NSColor.labelColor.cgColor)
for center in [leftCenter, rightCenter] {
ctx.addEllipse(in: CGRect(
x: center.x - geometry.eyeSize.width * 0.22 - glintR,
y: center.y + geometry.eyeSize.height * 0.18 - glintR,
width: glintR * 2,
height: glintR * 2))
}
ctx.fillPath()
}
}
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
}
// Filled "puck" with the symbol knocked out (transparent hole): reads better
// in template-rendered menu bar icons than tiny monochrome glyphs. Sized so
// the critter behind it stays recognizable.
let diameter = canvas.snapX(canvas.w * 0.46 * (0.92 + 0.08 * strength))
let margin = canvas.snapX(max(0.45, canvas.w * 0.02))
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()
}
}