Files
steipete--codexbar/Sources/CodexBar/IconRenderer.swift
T
2026-07-13 12:22:33 +08:00

1070 lines
47 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}