mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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
This commit is contained in:
@@ -18,100 +18,102 @@ enum CritterIconRenderer {
|
||||
let context: CGContext
|
||||
}
|
||||
|
||||
private struct Antenna {
|
||||
let start: CGPoint
|
||||
let control: CGPoint
|
||||
let tip: CGPoint
|
||||
}
|
||||
|
||||
private struct Geometry {
|
||||
let bodyRect: CGRect
|
||||
let leftArmRect: CGRect
|
||||
let rightArmRect: CGRect
|
||||
let leftEarRect: CGRect
|
||||
let rightEarRect: 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 legSpacing: CGFloat
|
||||
let legStartX: CGFloat
|
||||
let legCenters: [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) {
|
||||
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat, antennaDroop: CGFloat) {
|
||||
let w = canvas.w
|
||||
let h = canvas.h
|
||||
let snapX = canvas.snapX
|
||||
let snapY = canvas.snapY
|
||||
let cx = w / 2
|
||||
|
||||
let bodyW = snapX(w * 0.68)
|
||||
let bodyH = snapY(h * 0.68)
|
||||
let bodyX = snapX((w - bodyW) / 2)
|
||||
let bodyY = snapY(h * 0.24)
|
||||
// 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
|
||||
|
||||
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)
|
||||
// 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)
|
||||
|
||||
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)
|
||||
// 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
|
||||
|
||||
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
|
||||
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.legSpacing = legSpacing
|
||||
self.legStartX = legStartX
|
||||
self.legYBase = legYBase
|
||||
self.legLift = legLift
|
||||
self.legHeightScale = legHeightScale
|
||||
self.eyeSize = eyeSize
|
||||
self.eyeY = eyeY
|
||||
self.eyeOffset = eyeOffset
|
||||
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(
|
||||
@@ -119,8 +121,9 @@ enum CritterIconRenderer {
|
||||
legWiggle: CGFloat = 0,
|
||||
earWiggle: CGFloat = 0,
|
||||
earScale: CGFloat = 1,
|
||||
earHoles: Bool = false,
|
||||
antennaDroop: CGFloat = 0,
|
||||
eyesClosedLines: Bool = false,
|
||||
happyEyes: Bool = false,
|
||||
badge: Badge? = nil) -> NSImage
|
||||
{
|
||||
guard let rep = self.makeBitmapRep() else {
|
||||
@@ -143,12 +146,14 @@ enum CritterIconRenderer {
|
||||
canvas: canvas,
|
||||
legWiggle: legWiggle,
|
||||
earWiggle: earWiggle,
|
||||
earScale: earHoles ? max(earScale, 1.2) : earScale)
|
||||
earScale: earScale,
|
||||
antennaDroop: antennaDroop)
|
||||
|
||||
self.drawBody(in: canvas, geometry: geometry)
|
||||
let face = FaceOptions(
|
||||
blink: blink,
|
||||
eyesClosedLines: eyesClosedLines)
|
||||
eyesClosedLines: eyesClosedLines,
|
||||
happyEyes: happyEyes)
|
||||
self.drawFace(in: canvas, geometry: geometry, options: face)
|
||||
|
||||
if let badge {
|
||||
@@ -199,59 +204,48 @@ enum CritterIconRenderer {
|
||||
}
|
||||
|
||||
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 ctx = canvas.context
|
||||
|
||||
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))
|
||||
// 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()
|
||||
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()
|
||||
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()
|
||||
|
||||
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
||||
ctx.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
for i in 0..<2 {
|
||||
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
||||
for (i, legCenter) in geometry.legCenters.enumerated() {
|
||||
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
|
||||
let rect = CGRect(
|
||||
x: x,
|
||||
x: legCenter - geometry.legW / 2,
|
||||
y: geometry.legYBase + lift,
|
||||
width: geometry.legW,
|
||||
height: geometry.legH * geometry.legHeightScale)
|
||||
canvas.context.addPath(CGPath(
|
||||
height: geometry.legH)
|
||||
ctx.addPath(CGPath(
|
||||
roundedRect: rect,
|
||||
cornerWidth: geometry.legW * 0.34,
|
||||
cornerHeight: geometry.legW * 0.34,
|
||||
cornerWidth: geometry.legW / 2,
|
||||
cornerHeight: geometry.legW / 2,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
canvas.context.addEllipse(in: geometry.leftArmRect)
|
||||
canvas.context.addEllipse(in: geometry.rightArmRect)
|
||||
canvas.context.addEllipse(in: geometry.bodyRect)
|
||||
canvas.context.fillPath()
|
||||
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(
|
||||
@@ -259,60 +253,67 @@ enum CritterIconRenderer {
|
||||
geometry: Geometry,
|
||||
options: FaceOptions)
|
||||
{
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
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)
|
||||
|
||||
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))
|
||||
ctx.saveGState()
|
||||
ctx.setBlendMode(.clear)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
canvas.context.fillPath()
|
||||
canvas.context.restoreGState()
|
||||
// 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) {
|
||||
@@ -322,12 +323,11 @@ enum CritterIconRenderer {
|
||||
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))
|
||||
// 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),
|
||||
|
||||
@@ -41,7 +41,7 @@ extension CritterStatusLabel {
|
||||
}
|
||||
.onChange(of: self.sendCelebrationTick) { _, _ in
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
self.wiggleLegs()
|
||||
self.celebrate()
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled, !self.isSleeping {
|
||||
@@ -145,11 +145,17 @@ extension CritterStatusLabel {
|
||||
}
|
||||
|
||||
if self.isPaused {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
|
||||
// Paused reads as "off duty": awake but with drooped antennae, distinct
|
||||
// from idle (perked) and sleeping (drooped + closed eyes).
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, antennaDroop: 1, badge: nil))
|
||||
}
|
||||
|
||||
if self.isSleeping {
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
blink: 1,
|
||||
antennaDroop: 1,
|
||||
eyesClosedLines: true,
|
||||
badge: nil))
|
||||
}
|
||||
|
||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||
@@ -157,18 +163,35 @@ extension CritterStatusLabel {
|
||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||
earWiggle: self.earWiggle,
|
||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||
earHoles: self.earBoostActive,
|
||||
happyEyes: self.celebrating,
|
||||
badge: badge))
|
||||
}
|
||||
|
||||
private func resetMotion() {
|
||||
self.blinkAmount = 0
|
||||
self.celebrating = false
|
||||
self.wiggleAngle = 0
|
||||
self.wiggleOffset = 0
|
||||
self.legWiggle = 0
|
||||
self.earWiggle = 0
|
||||
}
|
||||
|
||||
/// Message sent: flash happy "∩ ∩" eyes and kick the legs.
|
||||
private func celebrate() {
|
||||
self.celebrating = true
|
||||
self.wiggleLegs()
|
||||
// Generation advances only for celebrations that actually start, so the
|
||||
// newest flash always owns the clear: older expiry tasks bail, and the
|
||||
// eyes can never stick on after a skipped send tick.
|
||||
self.celebrationGeneration += 1
|
||||
let generation = self.celebrationGeneration
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
guard self.celebrationGeneration == generation else { return }
|
||||
self.celebrating = false
|
||||
}
|
||||
}
|
||||
|
||||
private func blink() {
|
||||
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
|
||||
Task { @MainActor in
|
||||
@@ -290,6 +313,7 @@ extension CritterStatusLabel {
|
||||
label.wiggleLegs()
|
||||
label.wiggleEars()
|
||||
label.scurry()
|
||||
label.celebrate()
|
||||
label.scheduleRandomTimers(from: Date())
|
||||
_ = label.gatewayNeedsAttention
|
||||
_ = label.gatewayBadgeColor
|
||||
@@ -338,8 +362,9 @@ extension CritterStatusLabel {
|
||||
legWiggle: 0.8,
|
||||
earWiggle: 0.4,
|
||||
earScale: 1.4,
|
||||
earHoles: true,
|
||||
antennaDroop: 0.5,
|
||||
eyesClosedLines: true,
|
||||
happyEyes: true,
|
||||
badge: .init(symbolName: "gearshape.fill", prominence: .secondary))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ struct CritterStatusLabel: View {
|
||||
var voiceWakeMeterActive: Bool = false
|
||||
|
||||
@State var blinkAmount: CGFloat = 0
|
||||
@State var celebrating = false
|
||||
@State var celebrationGeneration = 0
|
||||
@State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
@State var wiggleAngle: Double = 0
|
||||
@State var wiggleOffset: CGFloat = 0
|
||||
|
||||
@@ -10,7 +10,6 @@ struct CritterIconRendererTests {
|
||||
legWiggle: 0.5,
|
||||
earWiggle: 0.2,
|
||||
earScale: 1,
|
||||
earHoles: true,
|
||||
badge: nil)
|
||||
|
||||
#expect(image.size.width == 18)
|
||||
@@ -24,12 +23,22 @@ struct CritterIconRendererTests {
|
||||
legWiggle: 0,
|
||||
earWiggle: 0,
|
||||
earScale: 1,
|
||||
earHoles: false,
|
||||
badge: .init(symbolName: "terminal.fill", prominence: .primary))
|
||||
|
||||
#expect(image.tiffRepresentation != nil)
|
||||
}
|
||||
|
||||
@Test func `make icon renders expressive states`() {
|
||||
let sleeping = CritterIconRenderer.makeIcon(
|
||||
blink: 1,
|
||||
antennaDroop: 1,
|
||||
eyesClosedLines: true)
|
||||
let celebrating = CritterIconRenderer.makeIcon(blink: 0, happyEyes: true)
|
||||
|
||||
#expect(sleeping.tiffRepresentation != nil)
|
||||
#expect(celebrating.tiffRepresentation != nil)
|
||||
}
|
||||
|
||||
@Test func `critter status label exercises helpers`() async {
|
||||
await CritterStatusLabel.exerciseForTesting()
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ Scope: macOS app (`apps/macos`). Rendering: `CritterIconRenderer.makeIcon(...)`.
|
||||
|
||||
| State | Trigger | Visual |
|
||||
| --------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Idle | Default | Normal blink/wiggle animation |
|
||||
| Paused | `isPaused=true` | Status item uses `appearsDisabled`; no motion |
|
||||
| Voice wake (big ears) | Wake word heard | Ears scale to `1.9x` with `earHoles=true` (circular holes for readability); drops after silence |
|
||||
| Idle | Default | Normal blink/wiggle animation; open eyes keep a glossy glint |
|
||||
| Paused | `isPaused=true` | Antennae droop ("off duty") with open eyes; no motion |
|
||||
| Sleeping | Gateway disconnected/unconfigured | Antennae droop and eyes close into `⌣ ⌣` lids; no motion |
|
||||
| Celebrate | Message sent (`sendCelebrationTick`) | Eyes flash happy `∩ ∩` arcs for ~0.9s plus a leg kick |
|
||||
| Voice wake (big ears) | Wake word heard | Antennae perk up straight and taller (`earScale=1.9`); drops after silence |
|
||||
| Working | `isWorking=true` or an active `IconState` | Faster leg wiggle (`legWiggle` up to `1.0`) plus a small horizontal offset; additive to idle wiggle |
|
||||
|
||||
A tool-activity badge (SF Symbol puck, e.g. `chevron.left.slash.chevron.right` for exec) can render on top of the same critter icon when a session has an active job or tool. That badge comes from `IconState`/`ActivityKind`; see [Menu bar](/platforms/mac/menu-bar) for the full state model.
|
||||
@@ -30,7 +32,8 @@ A tool-activity badge (SF Symbol puck, e.g. `chevron.left.slash.chevron.right` f
|
||||
## Shapes and sizes
|
||||
|
||||
- Canvas: 18x18pt template image, rendered into a 36x36px bitmap backing store (2x) so the icon stays crisp on Retina.
|
||||
- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` and `earHoles=true` without changing the overall frame.
|
||||
- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` without changing the overall frame.
|
||||
- `antennaDroop` (0-1) folds the antennae down for the paused and sleeping poses.
|
||||
- Leg scurry uses `legWiggle` up to `1.0` with a small horizontal jiggle.
|
||||
|
||||
## Behavioral notes
|
||||
|
||||
Reference in New Issue
Block a user