mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 20:01:39 +00:00
* 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
380 lines
14 KiB
Swift
380 lines
14 KiB
Swift
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()
|
||
}
|
||
}
|