mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-29 19:39:27 +00:00
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 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)) // ~9–10pt 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()
|
||
}
|
||
}
|