1070 lines
47 KiB
Swift
1070 lines
47 KiB
Swift
import AppKit
|
||
import CodexBarCore
|
||
|
||
// swiftlint:disable:next type_body_length
|
||
enum IconRenderer {
|
||
private static let creditsCap: Double = 1000
|
||
private static let baseSize = NSSize(width: 18, height: 18)
|
||
// Render to an 18×18 pt template (36×36 px at 2×) to match the system menu bar size.
|
||
private static let outputSize = NSSize(width: 18, height: 18)
|
||
private static let outputScale: CGFloat = 2
|
||
private static let canvasPx = Int(outputSize.width * outputScale)
|
||
|
||
private struct PixelGrid {
|
||
let scale: CGFloat
|
||
|
||
func pt(_ px: Int) -> CGFloat {
|
||
CGFloat(px) / self.scale
|
||
}
|
||
|
||
func rect(x: Int, y: Int, w: Int, h: Int) -> CGRect {
|
||
CGRect(x: self.pt(x), y: self.pt(y), width: self.pt(w), height: self.pt(h))
|
||
}
|
||
|
||
func snapDelta(_ value: CGFloat) -> CGFloat {
|
||
(value * self.scale).rounded() / self.scale
|
||
}
|
||
}
|
||
|
||
private static let grid = PixelGrid(scale: outputScale)
|
||
|
||
private struct IconCacheKey: Hashable {
|
||
let primary: Int
|
||
let weekly: Int
|
||
let credits: Int
|
||
let stale: Bool
|
||
let style: Int
|
||
let indicator: Int
|
||
let hideCritters: Bool
|
||
}
|
||
|
||
private final class IconCacheStore: @unchecked Sendable {
|
||
private var cache: [IconCacheKey: NSImage] = [:]
|
||
private var order: [IconCacheKey] = []
|
||
private let lock = NSLock()
|
||
|
||
func cachedIcon(for key: IconCacheKey) -> NSImage? {
|
||
self.lock.lock()
|
||
defer { self.lock.unlock() }
|
||
guard let image = self.cache[key] else { return nil }
|
||
if let idx = self.order.firstIndex(of: key) {
|
||
self.order.remove(at: idx)
|
||
self.order.append(key)
|
||
}
|
||
return image
|
||
}
|
||
|
||
func storeIcon(_ image: NSImage, for key: IconCacheKey, limit: Int) {
|
||
self.lock.lock()
|
||
defer { self.lock.unlock() }
|
||
self.cache[key] = image
|
||
self.order.removeAll { $0 == key }
|
||
self.order.append(key)
|
||
while self.order.count > limit {
|
||
let oldest = self.order.removeFirst()
|
||
self.cache.removeValue(forKey: oldest)
|
||
}
|
||
}
|
||
}
|
||
|
||
private static let iconCacheStore = IconCacheStore()
|
||
private static let iconCacheLimit = 64
|
||
private static let morphBucketCount = 200
|
||
private static let morphCache = MorphCache(limit: 512)
|
||
|
||
private final class MorphCache: @unchecked Sendable {
|
||
private let cache = NSCache<NSNumber, NSImage>()
|
||
|
||
init(limit: Int) {
|
||
self.cache.countLimit = limit
|
||
}
|
||
|
||
func image(for key: NSNumber) -> NSImage? {
|
||
self.cache.object(forKey: key)
|
||
}
|
||
|
||
func set(_ image: NSImage, for key: NSNumber) {
|
||
self.cache.setObject(image, forKey: key)
|
||
}
|
||
}
|
||
|
||
private struct RectPx: Hashable {
|
||
let x: Int
|
||
let y: Int
|
||
let w: Int
|
||
let h: Int
|
||
|
||
var midXPx: Int {
|
||
self.x + self.w / 2
|
||
}
|
||
|
||
var midYPx: Int {
|
||
self.y + self.h / 2
|
||
}
|
||
|
||
func rect() -> CGRect {
|
||
Self.grid.rect(x: self.x, y: self.y, w: self.w, h: self.h)
|
||
}
|
||
|
||
private static let grid = IconRenderer.grid
|
||
}
|
||
|
||
// swiftlint:disable function_body_length
|
||
static func makeIcon(
|
||
primaryRemaining: Double?,
|
||
weeklyRemaining: Double?,
|
||
creditsRemaining: Double?,
|
||
stale: Bool,
|
||
style: IconStyle,
|
||
blink: CGFloat = 0,
|
||
wiggle: CGFloat = 0,
|
||
tilt: CGFloat = 0,
|
||
statusIndicator: ProviderStatusIndicator = .none,
|
||
hideCritters: Bool = false) -> NSImage
|
||
{
|
||
let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001
|
||
let render = {
|
||
self.renderImage {
|
||
// Keep monochrome template icons; Claude uses subtle shape cues only.
|
||
let baseFill = NSColor.labelColor
|
||
let trackFillAlpha: CGFloat = stale ? 0.18 : 0.28
|
||
let trackStrokeAlpha: CGFloat = stale ? 0.28 : 0.44
|
||
let fillColor = baseFill.withAlphaComponent(stale ? 0.55 : 1.0)
|
||
|
||
let barWidthPx = 30 // 15 pt at 2×, uses the slot better without touching edges.
|
||
let barXPx = (Self.canvasPx - barWidthPx) / 2
|
||
|
||
func drawBar(
|
||
rectPx: RectPx,
|
||
remaining: Double?,
|
||
alpha: CGFloat = 1.0,
|
||
addNotches: Bool = false,
|
||
addFace: Bool = false,
|
||
addGeminiTwist: Bool = false,
|
||
addAntigravityTwist: Bool = false,
|
||
addFactoryTwist: Bool = false,
|
||
addWarpTwist: Bool = false,
|
||
blink: CGFloat = 0,
|
||
drawTrackFill: Bool = true,
|
||
warpEyesFilled: Bool = false)
|
||
{
|
||
let rect = rectPx.rect()
|
||
// Claude reads better as a blockier critter; Codex stays as a capsule.
|
||
// Warp uses small corner radius for rounded rectangle (matching logo style)
|
||
let cornerRadiusPx = addNotches ? 0 : (addWarpTwist ? 3 : rectPx.h / 2)
|
||
let radius = Self.grid.pt(cornerRadiusPx)
|
||
|
||
let trackPath = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||
if drawTrackFill {
|
||
baseFill.withAlphaComponent(trackFillAlpha * alpha).setFill()
|
||
trackPath.fill()
|
||
}
|
||
|
||
// Crisp outline: stroke an inset path so the stroke stays within pixel bounds.
|
||
let strokeWidthPx = 2 // 1 pt == 2 px at 2×
|
||
let insetPx = strokeWidthPx / 2
|
||
let strokeRect = Self.grid.rect(
|
||
x: rectPx.x + insetPx,
|
||
y: rectPx.y + insetPx,
|
||
w: max(0, rectPx.w - insetPx * 2),
|
||
h: max(0, rectPx.h - insetPx * 2))
|
||
let strokePath = NSBezierPath(
|
||
roundedRect: strokeRect,
|
||
xRadius: Self.grid.pt(max(0, cornerRadiusPx - insetPx)),
|
||
yRadius: Self.grid.pt(max(0, cornerRadiusPx - insetPx)))
|
||
strokePath.lineWidth = CGFloat(strokeWidthPx) / Self.outputScale
|
||
baseFill.withAlphaComponent(trackStrokeAlpha * alpha).setStroke()
|
||
strokePath.stroke()
|
||
|
||
// Fill: clip to the capsule and paint a left-to-right rect so the progress edge is straight.
|
||
if let remaining {
|
||
let clamped = max(0, min(remaining / 100, 1))
|
||
let fillWidthPx = max(0, min(rectPx.w, Int((CGFloat(rectPx.w) * CGFloat(clamped)).rounded())))
|
||
if fillWidthPx > 0 {
|
||
NSGraphicsContext.current?.cgContext.saveGState()
|
||
trackPath.addClip()
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
NSBezierPath(
|
||
rect: Self.grid.rect(
|
||
x: rectPx.x,
|
||
y: rectPx.y,
|
||
w: fillWidthPx,
|
||
h: rectPx.h)).fill()
|
||
NSGraphicsContext.current?.cgContext.restoreGState()
|
||
}
|
||
}
|
||
|
||
// Codex face: eye cutouts plus faint eyelids to give the prompt some personality.
|
||
if addFace {
|
||
let ctx = NSGraphicsContext.current?.cgContext
|
||
let eyeSizePx = 4
|
||
let eyeOffsetPx = 7
|
||
let eyeCenterYPx = rectPx.y + rectPx.h / 2
|
||
let centerXPx = rectPx.midXPx
|
||
|
||
ctx?.saveGState()
|
||
ctx?.setShouldAntialias(false)
|
||
ctx?.clear(Self.grid.rect(
|
||
x: centerXPx - eyeOffsetPx - eyeSizePx / 2,
|
||
y: eyeCenterYPx - eyeSizePx / 2,
|
||
w: eyeSizePx,
|
||
h: eyeSizePx))
|
||
ctx?.clear(Self.grid.rect(
|
||
x: centerXPx + eyeOffsetPx - eyeSizePx / 2,
|
||
y: eyeCenterYPx - eyeSizePx / 2,
|
||
w: eyeSizePx,
|
||
h: eyeSizePx))
|
||
ctx?.restoreGState()
|
||
|
||
// Blink: refill eyes from the top down using the bar fill color.
|
||
if blink > 0.001 {
|
||
let clamped = max(0, min(blink, 1))
|
||
let blinkHeightPx = Int((CGFloat(eyeSizePx) * clamped).rounded())
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let blinkRectLeft = Self.grid.rect(
|
||
x: centerXPx - eyeOffsetPx - eyeSizePx / 2,
|
||
y: eyeCenterYPx + eyeSizePx / 2 - blinkHeightPx,
|
||
w: eyeSizePx,
|
||
h: blinkHeightPx)
|
||
let blinkRectRight = Self.grid.rect(
|
||
x: centerXPx + eyeOffsetPx - eyeSizePx / 2,
|
||
y: eyeCenterYPx + eyeSizePx / 2 - blinkHeightPx,
|
||
w: eyeSizePx,
|
||
h: blinkHeightPx)
|
||
NSBezierPath(rect: blinkRectLeft).fill()
|
||
NSBezierPath(rect: blinkRectRight).fill()
|
||
}
|
||
|
||
// Hat: a tiny cap hovering above the eyes to give the face more character.
|
||
let hatWidthPx = 18
|
||
let hatHeightPx = 4
|
||
let hatRect = Self.grid.rect(
|
||
x: centerXPx - hatWidthPx / 2,
|
||
y: rectPx.y + rectPx.h - hatHeightPx,
|
||
w: hatWidthPx,
|
||
h: hatHeightPx)
|
||
ctx?.saveGState()
|
||
if abs(tilt) > 0.0001 {
|
||
// Tilt only the hat; keep eyes pixel-crisp and axis-aligned.
|
||
let faceCenter = CGPoint(x: Self.grid.pt(centerXPx), y: Self.grid.pt(eyeCenterYPx))
|
||
ctx?.translateBy(x: faceCenter.x, y: faceCenter.y)
|
||
ctx?.rotate(by: tilt)
|
||
ctx?.translateBy(x: -faceCenter.x, y: -faceCenter.y - abs(tilt) * 1.2)
|
||
}
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
NSBezierPath(rect: hatRect).fill()
|
||
ctx?.restoreGState()
|
||
}
|
||
|
||
// Claude twist: blocky crab-style critter (arms + legs + vertical eyes).
|
||
if addNotches {
|
||
let ctx = NSGraphicsContext.current?.cgContext
|
||
let wiggleOffset = Self.grid.snapDelta(wiggle * 0.6)
|
||
let wigglePx = Int((wiggleOffset * Self.outputScale).rounded())
|
||
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
|
||
// Arms/claws: mid-height side protrusions.
|
||
// Keep within the 18×18pt canvas: barX is 3px, so 3px arms reach the edge without clipping.
|
||
let armWidthPx = 3
|
||
let armHeightPx = max(0, rectPx.h - 6)
|
||
let armYPx = rectPx.y + 3 + wigglePx / 6
|
||
let leftArm = Self.grid.rect(
|
||
x: rectPx.x - armWidthPx,
|
||
y: armYPx,
|
||
w: armWidthPx,
|
||
h: armHeightPx)
|
||
let rightArm = Self.grid.rect(
|
||
x: rectPx.x + rectPx.w,
|
||
y: armYPx,
|
||
w: armWidthPx,
|
||
h: armHeightPx)
|
||
NSBezierPath(rect: leftArm).fill()
|
||
NSBezierPath(rect: rightArm).fill()
|
||
|
||
// Legs: 4 little pixels underneath, like a tiny crab.
|
||
let legCount = 4
|
||
let legWidthPx = 2
|
||
let legHeightPx = 3
|
||
let legYPx = rectPx.y - legHeightPx + wigglePx / 6
|
||
let stepPx = max(1, rectPx.w / (legCount + 1))
|
||
for idx in 0..<legCount {
|
||
let cx = rectPx.x + stepPx * (idx + 1)
|
||
let leg = Self.grid.rect(
|
||
x: cx - legWidthPx / 2,
|
||
y: legYPx,
|
||
w: legWidthPx,
|
||
h: legHeightPx)
|
||
NSBezierPath(rect: leg).fill()
|
||
}
|
||
|
||
// Eyes: tall vertical cutouts near the top.
|
||
let eyeWidthPx = 2
|
||
let eyeHeightPx = 5
|
||
let eyeOffsetPx = 6
|
||
let eyeYPx = rectPx.y + rectPx.h - eyeHeightPx - 2 + wigglePx / 8
|
||
ctx?.saveGState()
|
||
ctx?.setShouldAntialias(false)
|
||
ctx?.clear(Self.grid.rect(
|
||
x: rectPx.midXPx - eyeOffsetPx - eyeWidthPx / 2,
|
||
y: eyeYPx,
|
||
w: eyeWidthPx,
|
||
h: eyeHeightPx))
|
||
ctx?.clear(Self.grid.rect(
|
||
x: rectPx.midXPx + eyeOffsetPx - eyeWidthPx / 2,
|
||
y: eyeYPx,
|
||
w: eyeWidthPx,
|
||
h: eyeHeightPx))
|
||
ctx?.restoreGState()
|
||
|
||
// Blink: fill the eyes from the top down (blocky).
|
||
if blink > 0.001 {
|
||
let clamped = max(0, min(blink, 1))
|
||
let blinkHeightPx = Int((CGFloat(eyeHeightPx) * clamped).rounded())
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let leftBlink = Self.grid.rect(
|
||
x: rectPx.midXPx - eyeOffsetPx - eyeWidthPx / 2,
|
||
y: eyeYPx + eyeHeightPx - blinkHeightPx,
|
||
w: eyeWidthPx,
|
||
h: blinkHeightPx)
|
||
let rightBlink = Self.grid.rect(
|
||
x: rectPx.midXPx + eyeOffsetPx - eyeWidthPx / 2,
|
||
y: eyeYPx + eyeHeightPx - blinkHeightPx,
|
||
w: eyeWidthPx,
|
||
h: blinkHeightPx)
|
||
NSBezierPath(rect: leftBlink).fill()
|
||
NSBezierPath(rect: rightBlink).fill()
|
||
}
|
||
}
|
||
|
||
// Gemini twist: sparkle-inspired design with prominent 4-pointed stars as eyes
|
||
// and decorative points extending from the bar.
|
||
if addGeminiTwist {
|
||
let ctx = NSGraphicsContext.current?.cgContext
|
||
let centerXPx = rectPx.midXPx
|
||
let eyeCenterYPx = rectPx.y + rectPx.h / 2
|
||
|
||
ctx?.saveGState()
|
||
ctx?.setShouldAntialias(true)
|
||
|
||
// 4-pointed star cutouts (Gemini sparkle eyes) - BIGGER
|
||
let starSizePx = 8
|
||
let eyeOffsetPx = 8
|
||
let sr = Self.grid.pt(starSizePx / 2)
|
||
let innerR = sr * 0.25
|
||
|
||
func drawStarCutout(cx: CGFloat, cy: CGFloat) {
|
||
let path = NSBezierPath()
|
||
for i in 0..<8 {
|
||
let angle = CGFloat(i) * .pi / 4 - .pi / 2
|
||
let radius = (i % 2 == 0) ? sr : innerR
|
||
let px = cx + cos(angle) * radius
|
||
let py = cy + sin(angle) * radius
|
||
if i == 0 {
|
||
path.move(to: NSPoint(x: px, y: py))
|
||
} else {
|
||
path.line(to: NSPoint(x: px, y: py))
|
||
}
|
||
}
|
||
path.close()
|
||
path.fill()
|
||
}
|
||
|
||
let ldCx = Self.grid.pt(centerXPx - eyeOffsetPx)
|
||
let rdCx = Self.grid.pt(centerXPx + eyeOffsetPx)
|
||
let yCy = Self.grid.pt(eyeCenterYPx)
|
||
|
||
// Clear star shapes for eyes
|
||
ctx?.setBlendMode(.clear)
|
||
drawStarCutout(cx: ldCx, cy: yCy)
|
||
drawStarCutout(cx: rdCx, cy: yCy)
|
||
ctx?.setBlendMode(.normal)
|
||
|
||
// Decorative sparkle points extending from bar (sized to stay within 36px canvas)
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let pointHeightPx = 4
|
||
let pointWidthPx = 4
|
||
|
||
// Top center point (like a crown/sparkle)
|
||
let topPointPath = NSBezierPath()
|
||
let topCx = Self.grid.pt(centerXPx)
|
||
let topBaseY = Self.grid.pt(rectPx.y + rectPx.h)
|
||
let topPeakY = Self.grid.pt(rectPx.y + rectPx.h + pointHeightPx)
|
||
let halfW = Self.grid.pt(pointWidthPx / 2)
|
||
topPointPath.move(to: NSPoint(x: topCx - halfW, y: topBaseY))
|
||
topPointPath.line(to: NSPoint(x: topCx, y: topPeakY))
|
||
topPointPath.line(to: NSPoint(x: topCx + halfW, y: topBaseY))
|
||
topPointPath.close()
|
||
topPointPath.fill()
|
||
|
||
// Bottom center point
|
||
let bottomPointPath = NSBezierPath()
|
||
let bottomBaseY = Self.grid.pt(rectPx.y)
|
||
let bottomPeakY = Self.grid.pt(rectPx.y - pointHeightPx)
|
||
bottomPointPath.move(to: NSPoint(x: topCx - halfW, y: bottomBaseY))
|
||
bottomPointPath.line(to: NSPoint(x: topCx, y: bottomPeakY))
|
||
bottomPointPath.line(to: NSPoint(x: topCx + halfW, y: bottomBaseY))
|
||
bottomPointPath.close()
|
||
bottomPointPath.fill()
|
||
|
||
// Side points (max 3px to stay within canvas edge)
|
||
let sidePointH = 3
|
||
let sidePointW = 3
|
||
let sideHalfW = Self.grid.pt(sidePointW / 2)
|
||
let barMidY = Self.grid.pt(eyeCenterYPx)
|
||
|
||
// Left side point
|
||
let leftSidePath = NSBezierPath()
|
||
let leftBaseX = Self.grid.pt(rectPx.x)
|
||
let leftPeakX = Self.grid.pt(rectPx.x - sidePointH)
|
||
leftSidePath.move(to: NSPoint(x: leftBaseX, y: barMidY - sideHalfW))
|
||
leftSidePath.line(to: NSPoint(x: leftPeakX, y: barMidY))
|
||
leftSidePath.line(to: NSPoint(x: leftBaseX, y: barMidY + sideHalfW))
|
||
leftSidePath.close()
|
||
leftSidePath.fill()
|
||
|
||
// Right side point
|
||
let rightSidePath = NSBezierPath()
|
||
let rightBaseX = Self.grid.pt(rectPx.x + rectPx.w)
|
||
let rightPeakX = Self.grid.pt(rectPx.x + rectPx.w + sidePointH)
|
||
rightSidePath.move(to: NSPoint(x: rightBaseX, y: barMidY - sideHalfW))
|
||
rightSidePath.line(to: NSPoint(x: rightPeakX, y: barMidY))
|
||
rightSidePath.line(to: NSPoint(x: rightBaseX, y: barMidY + sideHalfW))
|
||
rightSidePath.close()
|
||
rightSidePath.fill()
|
||
|
||
ctx?.restoreGState()
|
||
|
||
// Blink: fill star eyes
|
||
if blink > 0.001 {
|
||
let clamped = max(0, min(blink, 1))
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let blinkR = sr * clamped
|
||
let blinkInnerR = blinkR * 0.25
|
||
|
||
func drawBlinkStar(cx: CGFloat, cy: CGFloat) {
|
||
let path = NSBezierPath()
|
||
for i in 0..<8 {
|
||
let angle = CGFloat(i) * .pi / 4 - .pi / 2
|
||
let radius = (i % 2 == 0) ? blinkR : blinkInnerR
|
||
let px = cx + cos(angle) * radius
|
||
let py = cy + sin(angle) * radius
|
||
if i == 0 {
|
||
path.move(to: NSPoint(x: px, y: py))
|
||
} else {
|
||
path.line(to: NSPoint(x: px, y: py))
|
||
}
|
||
}
|
||
path.close()
|
||
path.fill()
|
||
}
|
||
|
||
drawBlinkStar(cx: ldCx, cy: yCy)
|
||
drawBlinkStar(cx: rdCx, cy: yCy)
|
||
}
|
||
}
|
||
|
||
if addAntigravityTwist {
|
||
let dotSizePx = 3
|
||
let dotOffsetXPx = rectPx.x + rectPx.w + 2
|
||
let dotOffsetYPx = rectPx.y + rectPx.h - 2
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let dotRect = Self.grid.rect(
|
||
x: dotOffsetXPx - dotSizePx / 2,
|
||
y: dotOffsetYPx - dotSizePx / 2,
|
||
w: dotSizePx,
|
||
h: dotSizePx)
|
||
NSBezierPath(ovalIn: dotRect).fill()
|
||
}
|
||
|
||
// Factory twist: 8-pointed asterisk/gear-like eyes with cog teeth accents
|
||
if addFactoryTwist {
|
||
let ctx = NSGraphicsContext.current?.cgContext
|
||
let centerXPx = rectPx.midXPx
|
||
let eyeCenterYPx = rectPx.y + rectPx.h / 2
|
||
|
||
ctx?.saveGState()
|
||
ctx?.setShouldAntialias(true)
|
||
|
||
// 8-pointed asterisk cutouts (Factory gear-like eyes)
|
||
let starSizePx = 7
|
||
let eyeOffsetPx = 8
|
||
let sr = Self.grid.pt(starSizePx / 2)
|
||
let innerR = sr * 0.3
|
||
|
||
func drawAsteriskCutout(cx: CGFloat, cy: CGFloat) {
|
||
let path = NSBezierPath()
|
||
// 8 points for the asterisk
|
||
for i in 0..<16 {
|
||
let angle = CGFloat(i) * .pi / 8 - .pi / 2
|
||
let radius = (i % 2 == 0) ? sr : innerR
|
||
let px = cx + cos(angle) * radius
|
||
let py = cy + sin(angle) * radius
|
||
if i == 0 {
|
||
path.move(to: NSPoint(x: px, y: py))
|
||
} else {
|
||
path.line(to: NSPoint(x: px, y: py))
|
||
}
|
||
}
|
||
path.close()
|
||
path.fill()
|
||
}
|
||
|
||
let ldCx = Self.grid.pt(centerXPx - eyeOffsetPx)
|
||
let rdCx = Self.grid.pt(centerXPx + eyeOffsetPx)
|
||
let yCy = Self.grid.pt(eyeCenterYPx)
|
||
|
||
// Clear asterisk shapes for eyes
|
||
ctx?.setBlendMode(.clear)
|
||
drawAsteriskCutout(cx: ldCx, cy: yCy)
|
||
drawAsteriskCutout(cx: rdCx, cy: yCy)
|
||
ctx?.setBlendMode(.normal)
|
||
|
||
// Small gear teeth on top and bottom edges
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let toothWidthPx = 3
|
||
let toothHeightPx = 2
|
||
|
||
// Top teeth (2 small rectangles)
|
||
let topY = Self.grid.pt(rectPx.y + rectPx.h)
|
||
let tooth1X = Self.grid.pt(centerXPx - 5 - toothWidthPx / 2)
|
||
let tooth2X = Self.grid.pt(centerXPx + 5 - toothWidthPx / 2)
|
||
NSBezierPath(rect: CGRect(
|
||
x: tooth1X,
|
||
y: topY,
|
||
width: Self.grid.pt(toothWidthPx),
|
||
height: Self.grid.pt(toothHeightPx))).fill()
|
||
NSBezierPath(rect: CGRect(
|
||
x: tooth2X,
|
||
y: topY,
|
||
width: Self.grid.pt(toothWidthPx),
|
||
height: Self.grid.pt(toothHeightPx))).fill()
|
||
|
||
// Bottom teeth
|
||
let bottomY = Self.grid.pt(rectPx.y - toothHeightPx)
|
||
NSBezierPath(rect: CGRect(
|
||
x: tooth1X,
|
||
y: bottomY,
|
||
width: Self.grid.pt(toothWidthPx),
|
||
height: Self.grid.pt(toothHeightPx))).fill()
|
||
NSBezierPath(rect: CGRect(
|
||
x: tooth2X,
|
||
y: bottomY,
|
||
width: Self.grid.pt(toothWidthPx),
|
||
height: Self.grid.pt(toothHeightPx))).fill()
|
||
|
||
ctx?.restoreGState()
|
||
|
||
// Blink: fill asterisk eyes
|
||
if blink > 0.001 {
|
||
let clamped = max(0, min(blink, 1))
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
let blinkR = sr * clamped
|
||
let blinkInnerR = blinkR * 0.3
|
||
|
||
func drawBlinkAsterisk(cx: CGFloat, cy: CGFloat) {
|
||
let path = NSBezierPath()
|
||
for i in 0..<16 {
|
||
let angle = CGFloat(i) * .pi / 8 - .pi / 2
|
||
let radius = (i % 2 == 0) ? blinkR : blinkInnerR
|
||
let px = cx + cos(angle) * radius
|
||
let py = cy + sin(angle) * radius
|
||
if i == 0 {
|
||
path.move(to: NSPoint(x: px, y: py))
|
||
} else {
|
||
path.line(to: NSPoint(x: px, y: py))
|
||
}
|
||
}
|
||
path.close()
|
||
path.fill()
|
||
}
|
||
|
||
drawBlinkAsterisk(cx: ldCx, cy: yCy)
|
||
drawBlinkAsterisk(cx: rdCx, cy: yCy)
|
||
}
|
||
}
|
||
|
||
// Warp twist: "Warp" style face with tilted-eye cutouts.
|
||
if addWarpTwist {
|
||
let ctx = NSGraphicsContext.current?.cgContext
|
||
let centerXPx = rectPx.midXPx
|
||
let eyeCenterYPx = rectPx.y + rectPx.h / 2
|
||
|
||
ctx?.saveGState()
|
||
ctx?.setShouldAntialias(true) // Smooth edges for tilted ellipse eyes
|
||
|
||
// 1. Draw Eyes (Tilted ellipse cutouts - "fox eye" / "cat eye" style)
|
||
// Keep sizes in integer pixels so grid conversion stays exact.
|
||
let eyeWidthPx = 5
|
||
let eyeHeightPx = 8
|
||
let eyeOffsetPx = 7
|
||
let eyeTiltAngle: CGFloat = .pi / 3 // 60 degrees tilt
|
||
|
||
let leftEyeCx = Self.grid.pt(centerXPx) - Self.grid.pt(eyeOffsetPx)
|
||
let rightEyeCx = Self.grid.pt(centerXPx) + Self.grid.pt(eyeOffsetPx)
|
||
let eyeCy = Self.grid.pt(eyeCenterYPx)
|
||
let eyeW = Self.grid.pt(eyeWidthPx)
|
||
let eyeH = Self.grid.pt(eyeHeightPx)
|
||
|
||
/// Draw a tilted ellipse eye at the given center.
|
||
func drawTiltedEyeCutout(cx: CGFloat, cy: CGFloat, tiltAngle: CGFloat) {
|
||
guard let ctx else { return }
|
||
let eyeRect = CGRect(x: -eyeW / 2, y: -eyeH / 2, width: eyeW, height: eyeH)
|
||
|
||
// Use CGContext transforms instead of AffineTransform-on-path so the rotation origin
|
||
// is unambiguous and the current blend mode is consistently respected.
|
||
ctx.saveGState()
|
||
ctx.translateBy(x: cx, y: cy)
|
||
ctx.rotate(by: tiltAngle)
|
||
ctx.addEllipse(in: eyeRect)
|
||
ctx.fillPath()
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
if warpEyesFilled {
|
||
fillColor.withAlphaComponent(alpha).setFill()
|
||
drawTiltedEyeCutout(cx: leftEyeCx, cy: eyeCy, tiltAngle: eyeTiltAngle)
|
||
drawTiltedEyeCutout(cx: rightEyeCx, cy: eyeCy, tiltAngle: -eyeTiltAngle)
|
||
} else {
|
||
// Clear eyes using blend mode
|
||
ctx?.setBlendMode(.clear)
|
||
drawTiltedEyeCutout(cx: leftEyeCx, cy: eyeCy, tiltAngle: eyeTiltAngle)
|
||
drawTiltedEyeCutout(cx: rightEyeCx, cy: eyeCy, tiltAngle: -eyeTiltAngle)
|
||
ctx?.setBlendMode(.normal)
|
||
}
|
||
ctx?.restoreGState() // Restore graphics state
|
||
}
|
||
}
|
||
|
||
let effectiveWeeklyRemaining: Double? = {
|
||
if style == .warp, let weeklyRemaining, weeklyRemaining <= 0 {
|
||
return nil
|
||
}
|
||
return weeklyRemaining
|
||
}()
|
||
let topValue = primaryRemaining
|
||
let bottomValue = effectiveWeeklyRemaining
|
||
let creditsRatio = creditsRemaining.map { min($0 / Self.creditsCap * 100, 100) }
|
||
|
||
let hasWeekly = (bottomValue != nil)
|
||
let weeklyAvailable = hasWeekly && (bottomValue ?? 0) > 0
|
||
let creditsAlpha: CGFloat = 1.0
|
||
let topRectPx = RectPx(x: barXPx, y: 19, w: barWidthPx, h: 12)
|
||
let bottomRectPx = RectPx(x: barXPx, y: 5, w: barWidthPx, h: 8)
|
||
let creditsRectPx = RectPx(x: barXPx, y: 14, w: barWidthPx, h: 16)
|
||
let creditsBottomRectPx = RectPx(x: barXPx, y: 4, w: barWidthPx, h: 6)
|
||
|
||
// Warp special case: when no bonus or bonus exhausted, show "top monthly, bottom dimmed"
|
||
let warpNoBonus = style == .warp && !weeklyAvailable
|
||
|
||
// "Hide critters" renders plain meter bars: suppress all face/decoration twists.
|
||
let twistFace = !hideCritters && style == .codex
|
||
let twistNotches = !hideCritters && style == .claude
|
||
let twistGemini = !hideCritters && (style == .gemini || style == .antigravity)
|
||
let twistAntigravity = !hideCritters && style == .antigravity
|
||
let twistFactory = !hideCritters && style == .factory
|
||
let twistWarp = !hideCritters && style == .warp
|
||
|
||
if weeklyAvailable {
|
||
// Normal: top=primary, bottom=secondary (bonus/weekly).
|
||
drawBar(
|
||
rectPx: topRectPx,
|
||
remaining: topValue,
|
||
addNotches: twistNotches,
|
||
addFace: twistFace,
|
||
addGeminiTwist: twistGemini,
|
||
addAntigravityTwist: twistAntigravity,
|
||
addFactoryTwist: twistFactory,
|
||
addWarpTwist: twistWarp,
|
||
blink: blink)
|
||
drawBar(rectPx: bottomRectPx, remaining: bottomValue)
|
||
} else if !hasWeekly || warpNoBonus {
|
||
if style == .warp {
|
||
// Warp: no bonus or bonus exhausted -> top=monthly credits, bottom=dimmed track
|
||
drawBar(
|
||
rectPx: topRectPx,
|
||
remaining: topValue,
|
||
addWarpTwist: twistWarp,
|
||
blink: blink)
|
||
drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45)
|
||
} else {
|
||
// Weekly missing (e.g. Claude enterprise): keep normal layout but
|
||
// dim the bottom track to indicate N/A.
|
||
if topValue == nil, let ratio = creditsRatio {
|
||
// Credits-only: show credits prominently (e.g. credits loaded before usage).
|
||
drawBar(
|
||
rectPx: creditsRectPx,
|
||
remaining: ratio,
|
||
alpha: creditsAlpha,
|
||
addNotches: twistNotches,
|
||
addFace: twistFace,
|
||
addGeminiTwist: twistGemini,
|
||
addAntigravityTwist: twistAntigravity,
|
||
addFactoryTwist: twistFactory,
|
||
addWarpTwist: twistWarp,
|
||
blink: blink)
|
||
drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45)
|
||
} else {
|
||
drawBar(
|
||
rectPx: topRectPx,
|
||
remaining: topValue,
|
||
addNotches: twistNotches,
|
||
addFace: twistFace,
|
||
addGeminiTwist: twistGemini,
|
||
addAntigravityTwist: twistAntigravity,
|
||
addFactoryTwist: twistFactory,
|
||
addWarpTwist: twistWarp,
|
||
blink: blink)
|
||
drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45)
|
||
}
|
||
}
|
||
} else {
|
||
// Weekly exhausted/missing: show credits on top (thicker), weekly (likely 0) on bottom.
|
||
if let ratio = creditsRatio {
|
||
drawBar(
|
||
rectPx: creditsRectPx,
|
||
remaining: ratio,
|
||
alpha: creditsAlpha,
|
||
addNotches: twistNotches,
|
||
addFace: twistFace,
|
||
addGeminiTwist: twistGemini,
|
||
addAntigravityTwist: twistAntigravity,
|
||
addFactoryTwist: twistFactory,
|
||
addWarpTwist: twistWarp,
|
||
blink: blink)
|
||
} else {
|
||
// No credits available; fall back to 5h if present.
|
||
drawBar(
|
||
rectPx: topRectPx,
|
||
remaining: topValue,
|
||
addNotches: twistNotches,
|
||
addFace: twistFace,
|
||
addGeminiTwist: twistGemini,
|
||
addAntigravityTwist: twistAntigravity,
|
||
addFactoryTwist: twistFactory,
|
||
addWarpTwist: twistWarp,
|
||
blink: blink)
|
||
}
|
||
drawBar(rectPx: creditsBottomRectPx, remaining: bottomValue)
|
||
}
|
||
|
||
Self.drawStatusOverlay(indicator: statusIndicator)
|
||
}
|
||
}
|
||
|
||
if shouldCache {
|
||
let key = IconCacheKey(
|
||
primary: self.quantizedPercent(primaryRemaining),
|
||
weekly: self.quantizedPercent(weeklyRemaining),
|
||
credits: self.quantizedCredits(creditsRemaining),
|
||
stale: stale,
|
||
style: self.styleKey(style),
|
||
indicator: self.indicatorKey(statusIndicator),
|
||
hideCritters: hideCritters)
|
||
if let cached = self.cachedIcon(for: key) {
|
||
return cached
|
||
}
|
||
let image = render()
|
||
self.storeIcon(image, for: key)
|
||
return image
|
||
}
|
||
|
||
return render()
|
||
}
|
||
|
||
// swiftlint:enable function_body_length
|
||
|
||
/// Morph helper: unbraids a simplified knot into our bar icon.
|
||
static func makeMorphIcon(progress: Double, style: IconStyle, hideCritters: Bool = false) -> NSImage {
|
||
let clamped = max(0, min(progress, 1))
|
||
let key = self.morphCacheKey(progress: clamped, style: style, hideCritters: hideCritters)
|
||
if let cached = self.morphCache.image(for: key) {
|
||
return cached
|
||
}
|
||
let image = self.renderImage {
|
||
self.drawUnbraidMorph(t: clamped, style: style, hideCritters: hideCritters)
|
||
}
|
||
self.morphCache.set(image, for: key)
|
||
return image
|
||
}
|
||
|
||
private static func quantizedPercent(_ value: Double?) -> Int {
|
||
guard let value else { return -1 }
|
||
return Int((value * 10).rounded())
|
||
}
|
||
|
||
private static func quantizedCredits(_ value: Double?) -> Int {
|
||
guard let value else { return -1 }
|
||
let clamped = max(0, min(value, self.creditsCap))
|
||
return Int((clamped * 10).rounded())
|
||
}
|
||
|
||
private static let styleKeyLookup: [IconStyle: Int] = {
|
||
var lookup: [IconStyle: Int] = [:]
|
||
for (index, style) in IconStyle.allCases.enumerated() {
|
||
lookup[style] = index
|
||
}
|
||
return lookup
|
||
}()
|
||
|
||
private static func styleKey(_ style: IconStyle) -> Int {
|
||
self.styleKeyLookup[style] ?? 0
|
||
}
|
||
|
||
private static func indicatorKey(_ indicator: ProviderStatusIndicator) -> Int {
|
||
switch indicator {
|
||
case .none: 0
|
||
case .minor: 1
|
||
case .major: 2
|
||
case .critical: 3
|
||
case .maintenance: 4
|
||
case .unknown: 5
|
||
}
|
||
}
|
||
|
||
private static func morphCacheKey(progress: Double, style: IconStyle, hideCritters: Bool) -> NSNumber {
|
||
let bucket = Int((progress * Double(self.morphBucketCount)).rounded())
|
||
let key = (hideCritters ? 1_000_000 : 0) + self.styleKey(style) * 1000 + bucket
|
||
return NSNumber(value: key)
|
||
}
|
||
|
||
private static func cachedIcon(for key: IconCacheKey) -> NSImage? {
|
||
self.iconCacheStore.cachedIcon(for: key)
|
||
}
|
||
|
||
private static func storeIcon(_ image: NSImage, for key: IconCacheKey) {
|
||
self.iconCacheStore.storeIcon(image, for: key, limit: self.iconCacheLimit)
|
||
}
|
||
|
||
private static func drawUnbraidMorph(t: Double, style: IconStyle, hideCritters: Bool) {
|
||
let t = CGFloat(max(0, min(t, 1)))
|
||
let size = Self.baseSize
|
||
let center = CGPoint(x: size.width / 2, y: size.height / 2)
|
||
let baseColor = NSColor.labelColor
|
||
|
||
struct Segment {
|
||
let startCenter: CGPoint
|
||
let endCenter: CGPoint
|
||
let startAngle: CGFloat
|
||
let endAngle: CGFloat
|
||
let startLength: CGFloat
|
||
let endLength: CGFloat
|
||
let startThickness: CGFloat
|
||
let endThickness: CGFloat
|
||
let fadeOut: Bool
|
||
}
|
||
|
||
let segments: [Segment] = [
|
||
// Upper ribbon -> top bar
|
||
.init(
|
||
startCenter: center.offset(dx: 0, dy: 2),
|
||
endCenter: CGPoint(x: center.x, y: 9.0),
|
||
startAngle: -30,
|
||
endAngle: 0,
|
||
startLength: 16,
|
||
endLength: 14,
|
||
startThickness: 3.4,
|
||
endThickness: 3.0,
|
||
fadeOut: false),
|
||
// Lower ribbon -> bottom bar
|
||
.init(
|
||
startCenter: center.offset(dx: 0, dy: -2),
|
||
endCenter: CGPoint(x: center.x, y: 4.0),
|
||
startAngle: 210,
|
||
endAngle: 0,
|
||
startLength: 16,
|
||
endLength: 12,
|
||
startThickness: 3.4,
|
||
endThickness: 2.4,
|
||
fadeOut: false),
|
||
// Side ribbon fades away
|
||
.init(
|
||
startCenter: center,
|
||
endCenter: center.offset(dx: 0, dy: 6),
|
||
startAngle: 90,
|
||
endAngle: 0,
|
||
startLength: 16,
|
||
endLength: 8,
|
||
startThickness: 3.4,
|
||
endThickness: 1.8,
|
||
fadeOut: true),
|
||
]
|
||
|
||
for seg in segments {
|
||
let p = seg.fadeOut ? t * 1.1 : t
|
||
let c = seg.startCenter.lerp(to: seg.endCenter, p: p)
|
||
let angle = seg.startAngle.lerp(to: seg.endAngle, p: p)
|
||
let length = seg.startLength.lerp(to: seg.endLength, p: p)
|
||
let thickness = seg.startThickness.lerp(to: seg.endThickness, p: p)
|
||
let alpha = seg.fadeOut ? (1 - p) : 1
|
||
|
||
self.drawRoundedRibbon(
|
||
center: c,
|
||
length: length,
|
||
thickness: thickness,
|
||
angle: angle,
|
||
color: baseColor.withAlphaComponent(alpha))
|
||
}
|
||
|
||
// Cross-fade in bar fill emphasis near the end of the morph.
|
||
if t > 0.55 {
|
||
let barT = (t - 0.55) / 0.45
|
||
let bars = self.makeIcon(
|
||
primaryRemaining: 100,
|
||
weeklyRemaining: 100,
|
||
creditsRemaining: nil,
|
||
stale: false,
|
||
style: style,
|
||
hideCritters: hideCritters)
|
||
bars.draw(in: CGRect(origin: .zero, size: size), from: .zero, operation: .sourceOver, fraction: barT)
|
||
}
|
||
}
|
||
|
||
private static func drawRoundedRibbon(
|
||
center: CGPoint,
|
||
length: CGFloat,
|
||
thickness: CGFloat,
|
||
angle: CGFloat,
|
||
color: NSColor)
|
||
{
|
||
var transform = AffineTransform.identity
|
||
transform.translate(x: center.x, y: center.y)
|
||
transform.rotate(byDegrees: angle)
|
||
transform.translate(x: -center.x, y: -center.y)
|
||
|
||
let rect = CGRect(
|
||
x: center.x - length / 2,
|
||
y: center.y - thickness / 2,
|
||
width: length,
|
||
height: thickness)
|
||
|
||
let path = NSBezierPath(roundedRect: rect, xRadius: thickness / 2, yRadius: thickness / 2)
|
||
path.transform(using: transform)
|
||
color.setFill()
|
||
path.fill()
|
||
}
|
||
|
||
private static func drawStatusOverlay(indicator: ProviderStatusIndicator) {
|
||
guard indicator.hasIssue else { return }
|
||
let color = NSColor.labelColor
|
||
|
||
switch indicator {
|
||
case .minor, .maintenance:
|
||
let size: CGFloat = 4
|
||
let rect = Self.snapRect(
|
||
x: Self.baseSize.width - size - 2,
|
||
y: 2,
|
||
width: size,
|
||
height: size)
|
||
Self.clearStatusOverlayHalo(
|
||
NSBezierPath(ovalIn: rect.insetBy(dx: -1, dy: -1)))
|
||
let path = NSBezierPath(ovalIn: rect)
|
||
color.setFill()
|
||
path.fill()
|
||
case .major, .critical, .unknown:
|
||
let lineRect = Self.snapRect(
|
||
x: Self.baseSize.width - 6,
|
||
y: 4,
|
||
width: 2.0,
|
||
height: 6)
|
||
let dotRect = Self.snapRect(
|
||
x: Self.baseSize.width - 6,
|
||
y: 2,
|
||
width: 2.0,
|
||
height: 2.0)
|
||
|
||
let haloRect = lineRect.union(dotRect).insetBy(dx: -1, dy: -1)
|
||
Self.clearStatusOverlayHalo(
|
||
NSBezierPath(roundedRect: haloRect, xRadius: 2, yRadius: 2))
|
||
|
||
let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1)
|
||
color.setFill()
|
||
linePath.fill()
|
||
NSBezierPath(ovalIn: dotRect).fill()
|
||
case .none:
|
||
break
|
||
}
|
||
}
|
||
|
||
private static func clearStatusOverlayHalo(_ path: NSBezierPath) {
|
||
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
||
ctx.saveGState()
|
||
ctx.setBlendMode(.clear)
|
||
// The fill color is ignored by .clear; it only drives the path fill operation.
|
||
NSColor.black.setFill()
|
||
path.fill()
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
private static func withScaledContext(_ draw: () -> Void) {
|
||
guard let ctx = NSGraphicsContext.current?.cgContext else {
|
||
draw()
|
||
return
|
||
}
|
||
ctx.saveGState()
|
||
ctx.setShouldAntialias(true)
|
||
ctx.interpolationQuality = .none
|
||
draw()
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
private static func snap(_ value: CGFloat) -> CGFloat {
|
||
(value * self.outputScale).rounded() / self.outputScale
|
||
}
|
||
|
||
private static func snapRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> CGRect {
|
||
CGRect(x: self.snap(x), y: self.snap(y), width: self.snap(width), height: self.snap(height))
|
||
}
|
||
|
||
private static func renderImage(_ draw: () -> Void) -> NSImage {
|
||
let image = NSImage(size: Self.outputSize)
|
||
|
||
if let rep = NSBitmapImageRep(
|
||
bitmapDataPlanes: nil,
|
||
pixelsWide: Int(Self.outputSize.width * Self.outputScale),
|
||
pixelsHigh: Int(Self.outputSize.height * Self.outputScale),
|
||
bitsPerSample: 8,
|
||
samplesPerPixel: 4,
|
||
hasAlpha: true,
|
||
isPlanar: false,
|
||
colorSpaceName: .deviceRGB,
|
||
bytesPerRow: 0,
|
||
bitsPerPixel: 0)
|
||
{
|
||
rep.size = Self.outputSize // points
|
||
image.addRepresentation(rep)
|
||
|
||
NSGraphicsContext.saveGraphicsState()
|
||
if let ctx = NSGraphicsContext(bitmapImageRep: rep) {
|
||
NSGraphicsContext.current = ctx
|
||
Self.withScaledContext(draw)
|
||
}
|
||
NSGraphicsContext.restoreGraphicsState()
|
||
} else {
|
||
// Fallback to legacy focus if the bitmap rep fails for any reason.
|
||
image.lockFocus()
|
||
Self.withScaledContext(draw)
|
||
image.unlockFocus()
|
||
}
|
||
|
||
image.isTemplate = true
|
||
return image
|
||
}
|
||
}
|
||
|
||
extension CGPoint {
|
||
fileprivate func lerp(to other: CGPoint, p: CGFloat) -> CGPoint {
|
||
CGPoint(x: self.x + (other.x - self.x) * p, y: self.y + (other.y - self.y) * p)
|
||
}
|
||
|
||
fileprivate func offset(dx: CGFloat, dy: CGFloat) -> CGPoint {
|
||
CGPoint(x: self.x + dx, y: self.y + dy)
|
||
}
|
||
}
|
||
|
||
extension CGFloat {
|
||
fileprivate func lerp(to other: CGFloat, p: CGFloat) -> CGFloat {
|
||
self + (other - self) * p
|
||
}
|
||
}
|