mac icon: render 36px retina backing

main
Peter Steinberger 2025-12-09 21:56:37 +01:00
parent 510552c5e6
commit 06fdfc2e14
2 changed files with 154 additions and 121 deletions

View File

@ -31,7 +31,7 @@ struct CritterStatusLabel: View {
Group {
if self.isPaused {
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
.frame(width: 18, height: 16)
.frame(width: 18, height: 18)
} else {
Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
@ -39,7 +39,7 @@ struct CritterStatusLabel: View {
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive))
.frame(width: 18, height: 16)
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
.onReceive(self.ticker) { now in
@ -211,7 +211,7 @@ struct CritterStatusLabel: View {
}
enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 16)
private static let size = NSSize(width: 18, height: 18)
static func makeIcon(
blink: CGFloat,
@ -220,14 +220,33 @@ enum CritterIconRenderer {
earScale: CGFloat = 1,
earHoles: Bool = false) -> NSImage
{
let image = NSImage(size: size)
image.lockFocus()
defer { image.unlockFocus() }
// 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
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0
) else {
return NSImage(size: size)
}
rep.size = size
guard let ctx = NSGraphicsContext.current?.cgContext else { return image }
NSGraphicsContext.saveGraphicsState()
if let context = NSGraphicsContext(bitmapImageRep: rep) {
NSGraphicsContext.current = context
defer { NSGraphicsContext.restoreGraphicsState() }
let w = self.size.width
let h = self.size.height
let w = size.width
let h = size.height
let bodyW = w * 0.78
let bodyH = h * 0.58
@ -236,7 +255,7 @@ enum CritterIconRenderer {
let bodyCorner = w * 0.09
let earW = w * 0.22
let earH = bodyH * 0.66 * earScale * (1 - 0.08 * abs(earWiggle))
let earH = bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle))
let earCorner = earW * 0.24
let leftEarRect = CGRect(
x: bodyX - earW * 0.55 + earWiggle,
@ -263,19 +282,19 @@ enum CritterIconRenderer {
let eyeY = bodyY + bodyH * 0.56
let eyeOffset = bodyW * 0.24
ctx.setFillColor(NSColor.labelColor.cgColor)
context.cgContext.setFillColor(NSColor.labelColor.cgColor)
ctx.addPath(CGPath(
context.cgContext.addPath(CGPath(
roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
cornerWidth: bodyCorner,
cornerHeight: bodyCorner,
transform: nil))
ctx.addPath(CGPath(
context.cgContext.addPath(CGPath(
roundedRect: leftEarRect,
cornerWidth: earCorner,
cornerHeight: earCorner,
transform: nil))
ctx.addPath(CGPath(
context.cgContext.addPath(CGPath(
roundedRect: rightEarRect,
cornerWidth: earCorner,
cornerHeight: earCorner,
@ -283,13 +302,21 @@ enum CritterIconRenderer {
for i in 0..<4 {
let x = legStartX + CGFloat(i) * (legW + legSpacing)
let lift = (i % 2 == 0 ? legLift : -legLift)
let rect = CGRect(x: x, y: legYBase + lift, width: legW, height: legH * (1 - 0.12 * legWiggle))
ctx.addPath(CGPath(roundedRect: rect, cornerWidth: legW * 0.34, cornerHeight: legW * 0.34, transform: nil))
let rect = CGRect(
x: x,
y: legYBase + lift,
width: legW,
height: legH * (1 - 0.12 * legWiggle))
context.cgContext.addPath(CGPath(
roundedRect: rect,
cornerWidth: legW * 0.34,
cornerHeight: legW * 0.34,
transform: nil))
}
ctx.fillPath()
context.cgContext.fillPath()
ctx.saveGState()
ctx.setBlendMode(.clear)
context.cgContext.saveGState()
context.cgContext.setBlendMode(CGBlendMode.clear)
let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY)
let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY)
@ -309,12 +336,12 @@ enum CritterIconRenderer {
width: holeW,
height: holeH)
ctx.addPath(CGPath(
context.cgContext.addPath(CGPath(
roundedRect: leftHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
transform: nil))
ctx.addPath(CGPath(
context.cgContext.addPath(CGPath(
roundedRect: rightHoleRect,
cornerWidth: holeCorner,
cornerHeight: holeCorner,
@ -333,11 +360,17 @@ enum CritterIconRenderer {
right.addLine(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y + eyeH))
right.closeSubpath()
ctx.addPath(left)
ctx.addPath(right)
ctx.fillPath()
ctx.restoreGState()
context.cgContext.addPath(left)
context.cgContext.addPath(right)
context.cgContext.fillPath()
context.cgContext.restoreGState()
} else {
NSGraphicsContext.restoreGraphicsState()
return NSImage(size: size)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true
return image
}

View File

@ -18,7 +18,7 @@ Wiring points
Shapes & sizes
- Base icon drawn in `CritterIconRenderer.makeIcon(blink:legWiggle:earWiggle:earScale:earHoles:)`.
- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` and toggles `earHoles=true` without changing overall frame (18×16pt template image).
- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` and toggles `earHoles=true` without changing overall frame (18×18pt template image rendered into a 36×36px Retina backing store).
- Scurry uses leg wiggle up to ~1.0 with a small horizontal jiggle; its additive to any existing idle wiggle.
Behavioral notes