mac icon: render 36px retina backing

This commit is contained in:
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 { Group {
if self.isPaused { if self.isPaused {
Image(nsImage: CritterIconRenderer.makeIcon(blink: 0)) Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
.frame(width: 18, height: 16) .frame(width: 18, height: 18)
} else { } else {
Image(nsImage: CritterIconRenderer.makeIcon( Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount, blink: self.blinkAmount,
@@ -39,7 +39,7 @@ struct CritterStatusLabel: View {
earWiggle: self.earWiggle, earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0, earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive)) earHoles: self.earBoostActive))
.frame(width: 18, height: 16) .frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center) .rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset) .offset(x: self.wiggleOffset)
.onReceive(self.ticker) { now in .onReceive(self.ticker) { now in
@@ -211,7 +211,7 @@ struct CritterStatusLabel: View {
} }
enum CritterIconRenderer { enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 16) private static let size = NSSize(width: 18, height: 18)
static func makeIcon( static func makeIcon(
blink: CGFloat, blink: CGFloat,
@@ -220,14 +220,33 @@ enum CritterIconRenderer {
earScale: CGFloat = 1, earScale: CGFloat = 1,
earHoles: Bool = false) -> NSImage earHoles: Bool = false) -> NSImage
{ {
let image = NSImage(size: size) // Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
image.lockFocus() let pixelsWide = 36
defer { image.unlockFocus() } 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 w = size.width
let h = self.size.height let h = size.height
let bodyW = w * 0.78 let bodyW = w * 0.78
let bodyH = h * 0.58 let bodyH = h * 0.58
@@ -236,7 +255,7 @@ enum CritterIconRenderer {
let bodyCorner = w * 0.09 let bodyCorner = w * 0.09
let earW = w * 0.22 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 earCorner = earW * 0.24
let leftEarRect = CGRect( let leftEarRect = CGRect(
x: bodyX - earW * 0.55 + earWiggle, x: bodyX - earW * 0.55 + earWiggle,
@@ -263,19 +282,19 @@ enum CritterIconRenderer {
let eyeY = bodyY + bodyH * 0.56 let eyeY = bodyY + bodyH * 0.56
let eyeOffset = bodyW * 0.24 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), roundedRect: CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH),
cornerWidth: bodyCorner, cornerWidth: bodyCorner,
cornerHeight: bodyCorner, cornerHeight: bodyCorner,
transform: nil)) transform: nil))
ctx.addPath(CGPath( context.cgContext.addPath(CGPath(
roundedRect: leftEarRect, roundedRect: leftEarRect,
cornerWidth: earCorner, cornerWidth: earCorner,
cornerHeight: earCorner, cornerHeight: earCorner,
transform: nil)) transform: nil))
ctx.addPath(CGPath( context.cgContext.addPath(CGPath(
roundedRect: rightEarRect, roundedRect: rightEarRect,
cornerWidth: earCorner, cornerWidth: earCorner,
cornerHeight: earCorner, cornerHeight: earCorner,
@@ -283,13 +302,21 @@ enum CritterIconRenderer {
for i in 0..<4 { for i in 0..<4 {
let x = legStartX + CGFloat(i) * (legW + legSpacing) let x = legStartX + CGFloat(i) * (legW + legSpacing)
let lift = (i % 2 == 0 ? legLift : -legLift) let lift = (i % 2 == 0 ? legLift : -legLift)
let rect = CGRect(x: x, y: legYBase + lift, width: legW, height: legH * (1 - 0.12 * legWiggle)) let rect = CGRect(
ctx.addPath(CGPath(roundedRect: rect, cornerWidth: legW * 0.34, cornerHeight: legW * 0.34, transform: nil)) 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() context.cgContext.saveGState()
ctx.setBlendMode(.clear) context.cgContext.setBlendMode(CGBlendMode.clear)
let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY) let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY)
let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY) let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY)
@@ -309,12 +336,12 @@ enum CritterIconRenderer {
width: holeW, width: holeW,
height: holeH) height: holeH)
ctx.addPath(CGPath( context.cgContext.addPath(CGPath(
roundedRect: leftHoleRect, roundedRect: leftHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
transform: nil)) transform: nil))
ctx.addPath(CGPath( context.cgContext.addPath(CGPath(
roundedRect: rightHoleRect, roundedRect: rightHoleRect,
cornerWidth: holeCorner, cornerWidth: holeCorner,
cornerHeight: holeCorner, cornerHeight: holeCorner,
@@ -333,11 +360,17 @@ enum CritterIconRenderer {
right.addLine(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y + eyeH)) right.addLine(to: CGPoint(x: rightCenter.x + eyeW / 2, y: rightCenter.y + eyeH))
right.closeSubpath() right.closeSubpath()
ctx.addPath(left) context.cgContext.addPath(left)
ctx.addPath(right) context.cgContext.addPath(right)
ctx.fillPath() context.cgContext.fillPath()
ctx.restoreGState() context.cgContext.restoreGState()
} else {
NSGraphicsContext.restoreGraphicsState()
return NSImage(size: size)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true image.isTemplate = true
return image return image
} }

View File

@@ -18,7 +18,7 @@ Wiring points
Shapes & sizes Shapes & sizes
- Base icon drawn in `CritterIconRenderer.makeIcon(blink:legWiggle:earWiggle:earScale:earHoles:)`. - 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. - Scurry uses leg wiggle up to ~1.0 with a small horizontal jiggle; its additive to any existing idle wiggle.
Behavioral notes Behavioral notes