Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import CodexBarCore

// swiftlint:disable:next type_body_length
enum IconRenderer {
struct QuotaLayoutPolicy: Hashable {
let reservesMissingSecondaryLane: Bool
let treatsExhaustedSecondaryAsMissing: Bool

static func provider(_ provider: UsageProvider) -> Self {
let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation
return Self(
reservesMissingSecondaryLane: presentation.reservesMissingSecondaryIconLane,
treatsExhaustedSecondaryAsMissing: presentation.treatsExhaustedSecondaryIconWindowAsMissing)
}

static func style(_ style: IconStyle) -> Self {
UsageProvider(rawValue: style.rawValue).map(self.provider)
?? Self(reservesMissingSecondaryLane: false, treatsExhaustedSecondaryAsMissing: false)
}
}

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.
Expand All @@ -28,6 +45,11 @@ enum IconRenderer {

private static let grid = PixelGrid(scale: outputScale)

static func fillWidthPixels(remaining: Double, rectWidth: Int) -> Int {
let clamped = max(0, min(remaining / 100, 1))
return max(0, min(rectWidth, Int((CGFloat(rectWidth) * CGFloat(clamped)).rounded())))
}

private struct IconCacheKey: Hashable {
let primary: Int
let weekly: Int
Expand All @@ -36,6 +58,7 @@ enum IconRenderer {
let style: Int
let indicator: Int
let hideCritters: Bool
let quotaLayoutPolicy: QuotaLayoutPolicy
}

private final class IconCacheStore: @unchecked Sendable {
Expand Down Expand Up @@ -120,8 +143,10 @@ enum IconRenderer {
wiggle: CGFloat = 0,
tilt: CGFloat = 0,
statusIndicator: ProviderStatusIndicator = .none,
hideCritters: Bool = false) -> NSImage
hideCritters: Bool = false,
quotaLayoutPolicy: QuotaLayoutPolicy? = nil) -> NSImage
{
let quotaLayoutPolicy = quotaLayoutPolicy ?? .style(style)
let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001
let render = {
self.renderImage {
Expand Down Expand Up @@ -178,8 +203,7 @@ enum IconRenderer {

// 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())))
let fillWidthPx = Self.fillWidthPixels(remaining: remaining, rectWidth: rectPx.w)
if fillWidthPx > 0 {
NSGraphicsContext.current?.cgContext.saveGState()
trackPath.addClip()
Expand Down Expand Up @@ -638,8 +662,7 @@ enum IconRenderer {

let providerPresentation = UsageProvider(rawValue: style.rawValue)
.map { ProviderDescriptorRegistry.descriptor(for: $0).presentation }
let usesMissingSecondaryLayout =
providerPresentation?.treatsExhaustedSecondaryIconWindowAsMissing == true
let usesMissingSecondaryLayout = quotaLayoutPolicy.treatsExhaustedSecondaryAsMissing
let effectiveWeeklyRemaining: Double? = {
if usesMissingSecondaryLayout, let weeklyRemaining, weeklyRemaining <= 0 {
return nil
Comment thread
akshayprabhu200 marked this conversation as resolved.
Expand Down Expand Up @@ -670,7 +693,22 @@ enum IconRenderer {
let twistFactory = decorations.contains(.factory)
let twistWarp = decorations.contains(.warp)

if weeklyAvailable {
if let bottomValue, bottomValue > 0, topValue == nil,
!quotaLayoutPolicy.reservesMissingSecondaryLane,
!usesMissingSecondaryLayout
{
// Some providers surface their only meaningful quota in the secondary slot.
drawBar(
rectPx: creditsRectPx,
remaining: bottomValue,
addNotches: twistNotches,
addFace: twistFace,
addGeminiTwist: twistGemini,
addAntigravityTwist: twistAntigravity,
addFactoryTwist: twistFactory,
addWarpTwist: twistWarp,
blink: blink)
} else if weeklyAvailable {
// Normal: top=primary, bottom=secondary (bonus/weekly).
drawBar(
rectPx: topRectPx,
Expand All @@ -693,8 +731,6 @@ enum IconRenderer {
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(
Expand All @@ -709,7 +745,22 @@ enum IconRenderer {
addWarpTwist: twistWarp,
blink: blink)
drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45)
} else if !quotaLayoutPolicy.reservesMissingSecondaryLane, let topValue {
// One meaningful quota should read as one meter. Reserving an unavailable second
// lane makes (for example) 46% remaining look like roughly 23% of the icon.
drawBar(
rectPx: creditsRectPx,
remaining: topValue,
addNotches: twistNotches,
addFace: twistFace,
addGeminiTwist: twistGemini,
addAntigravityTwist: twistAntigravity,
addFactoryTwist: twistFactory,
addWarpTwist: twistWarp,
blink: blink)
} else {
// Missing secondary (for example Claude Enterprise): preserve the normal two-lane
// layout and dim the unavailable lane.
drawBar(
rectPx: topRectPx,
remaining: topValue,
Expand Down Expand Up @@ -765,7 +816,8 @@ enum IconRenderer {
stale: stale,
style: self.styleKey(style),
indicator: self.indicatorKey(statusIndicator),
hideCritters: hideCritters)
hideCritters: hideCritters,
quotaLayoutPolicy: quotaLayoutPolicy)
if let cached = self.cachedIcon(for: key) {
return cached
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ struct StatusIconView: View {
stale: self.store.isStale(provider: self.provider),
style: self.store.style(for: self.provider),
statusIndicator: self.store.statusIndicator(for: self.provider),
hideCritters: self.store.settings.menuBarHidesCritters)
hideCritters: self.store.settings.menuBarHidesCritters,
quotaLayoutPolicy: .provider(self.provider))
}
}
6 changes: 4 additions & 2 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,8 @@ extension StatusItemController {
wiggle: wiggle,
tilt: tilt,
statusIndicator: statusIndicator,
hideCritters: self.settings.menuBarHidesCritters)
hideCritters: self.settings.menuBarHidesCritters,
quotaLayoutPolicy: .provider(primaryProvider))
self.setButtonContent(
image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image,
title: nil,
Expand Down Expand Up @@ -636,7 +637,8 @@ extension StatusItemController {
wiggle: wiggle,
tilt: tilt,
statusIndicator: statusIndicator,
hideCritters: self.settings.menuBarHidesCritters)
hideCritters: self.settings.menuBarHidesCritters,
quotaLayoutPolicy: .provider(provider))
self.setButtonContent(
image: warningFlash ? Self.quotaWarningFlashImage(base: image) : image,
title: nil,
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1432,7 +1432,8 @@ extension StatusItemController {
wiggle: 0,
tilt: 0,
statusIndicator: indicator,
hideCritters: self.settings.menuBarHidesCritters)
hideCritters: self.settings.menuBarHidesCritters,
quotaLayoutPolicy: .provider(provider))
image.isTemplate = true
return image
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ public enum ClaudeProviderDescriptor {
menuCardStyle: .claude)
},
iconDecorations: [.notches],
reservesMissingSecondaryIconLane: true,
automaticSelectionPrioritizesExhaustedWindow: false,
menuBarWindowResolver: self.menuBarWindow,
planUtilizationSeriesResolver: { snapshot in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ public struct ProviderUsagePresentation: Sendable {
private let widgetRowLimitResolver: WidgetRowLimitResolver
public let iconDecorations: ProviderIconDecorations
public let treatsExhaustedSecondaryIconWindowAsMissing: Bool
public let reservesMissingSecondaryIconLane: Bool
public let primarySemanticWindow: ProviderSemanticWindow
public let secondarySemanticWindow: ProviderSemanticWindow
public let menuBarLayoutSecondaryLabel: String?
Expand All @@ -448,6 +449,7 @@ public struct ProviderUsagePresentation: Sendable {
},
iconDecorations: ProviderIconDecorations = [],
treatsExhaustedSecondaryIconWindowAsMissing: Bool = false,
reservesMissingSecondaryIconLane: Bool = false,
semanticWindowResolver: @escaping SemanticWindowResolver = Self.standardSemanticWindows,
primarySemanticWindow: ProviderSemanticWindow = .session,
secondarySemanticWindow: ProviderSemanticWindow = .weekly,
Expand All @@ -473,6 +475,7 @@ public struct ProviderUsagePresentation: Sendable {
self.iconWindowResolver = iconWindowResolver
self.iconDecorations = iconDecorations
self.treatsExhaustedSecondaryIconWindowAsMissing = treatsExhaustedSecondaryIconWindowAsMissing
self.reservesMissingSecondaryIconLane = reservesMissingSecondaryIconLane
self.semanticWindowResolver = semanticWindowResolver
self.primarySemanticWindow = primarySemanticWindow
self.secondarySemanticWindow = secondarySemanticWindow
Expand Down
71 changes: 71 additions & 0 deletions Tests/CodexBarTests/IconRendererHideCrittersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,77 @@ struct IconRendererHideCrittersTests {
#expect(try self.pixels(decorated) != self.pixels(plain))
}

@Test
func `fill width tracks and clamps the reported percentage`() {
#expect(IconRenderer.fillWidthPixels(remaining: 46, rectWidth: 30) == 14)
#expect(IconRenderer.fillWidthPixels(remaining: -1, rectWidth: 30) == 0)
#expect(IconRenderer.fillWidthPixels(remaining: 0, rectWidth: 30) == 0)
#expect(IconRenderer.fillWidthPixels(remaining: 100, rectWidth: 30) == 30)
#expect(IconRenderer.fillWidthPixels(remaining: 120, rectWidth: 30) == 30)
}

@Test
func `single quota layout follows provider policy even in combined style`() throws {
func image(
primary: Double?,
weekly: Double?,
policy: IconRenderer.QuotaLayoutPolicy) -> NSImage
{
IconRenderer.makeIcon(
primaryRemaining: primary,
weeklyRemaining: weekly,
creditsRemaining: nil,
stale: false,
style: .combined,
hideCritters: true,
quotaLayoutPolicy: policy)
}

let compact = IconRenderer.QuotaLayoutPolicy.provider(.codex)
let reserved = IconRenderer.QuotaLayoutPolicy.provider(.claude)
let compactPrimary = image(primary: 46, weekly: nil, policy: compact)
let compactSecondary = image(primary: nil, weekly: 46, policy: compact)
let reservedPrimary = image(primary: 46, weekly: nil, policy: reserved)

#expect(try self.pixels(compactPrimary) == self.pixels(compactSecondary))
#expect(try self.pixels(compactPrimary) != self.pixels(reservedPrimary))
}

@Test
func `special and multi-value layouts remain unchanged`() throws {
func image(
primary: Double?,
weekly: Double?,
credits: Double? = nil,
policy: IconRenderer.QuotaLayoutPolicy) -> NSImage
{
IconRenderer.makeIcon(
primaryRemaining: primary,
weeklyRemaining: weekly,
creditsRemaining: credits,
stale: false,
style: .combined,
hideCritters: true,
quotaLayoutPolicy: policy)
}

let compact = IconRenderer.QuotaLayoutPolicy.provider(.codex)
let reserved = IconRenderer.QuotaLayoutPolicy.provider(.claude)
let warp = IconRenderer.QuotaLayoutPolicy.provider(.warp)

#expect(try self.pixels(image(primary: 46, weekly: 46, policy: compact))
== self.pixels(image(primary: 46, weekly: 46, policy: reserved)))
#expect(try self.pixels(image(primary: 46, weekly: 0, policy: compact))
== self.pixels(image(primary: 46, weekly: 0, policy: reserved)))
#expect(try self.pixels(image(primary: nil, weekly: nil, credits: 460, policy: compact))
== self.pixels(image(primary: nil, weekly: nil, credits: 460, policy: reserved)))
#expect(try self.pixels(image(primary: 46, weekly: nil, policy: warp))
== self.pixels(image(primary: 46, weekly: 0, policy: warp)))

let unknown = image(primary: nil, weekly: nil, policy: compact)
#expect(try self.pixels(unknown).isEmpty == false)
}

@Test
func `hiding critters is a no-op for an undecorated style`() throws {
// Cursor has no critter twist, so the flag must not alter its bars.
Expand Down
87 changes: 87 additions & 0 deletions Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import AppKit
import XCTest
@testable import CodexBar

/// Developer tool, skipped by default: renders a synthetic single-quota icon for PR proof.
///
/// Run with:
/// CODEXBAR_ICON_SCREENSHOT_DIR=docs/screenshots \
/// swift test --filter IconRendererScreenshotRenderTests
@MainActor
final class IconRendererScreenshotRenderTests: XCTestCase {
private static let canvasSize = NSSize(width: 360, height: 240)

func test_renderSyntheticSingleQuotaIcon() throws {
guard let dir = ProcessInfo.processInfo.environment["CODEXBAR_ICON_SCREENSHOT_DIR"] else {
throw XCTSkip("Set CODEXBAR_ICON_SCREENSHOT_DIR to render the synthetic icon proof.")
}
let directory = URL(fileURLWithPath: dir, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)

let icon = IconRenderer.makeIcon(
primaryRemaining: 46,
weeklyRemaining: nil,
creditsRemaining: nil,
stale: false,
style: .combined,
hideCritters: true,
quotaLayoutPolicy: .provider(.codex))
let data = try XCTUnwrap(Self.proofPNG(icon: icon), "synthetic icon proof render failed")
let url = directory.appendingPathComponent("codex-single-quota-icon.png")
try data.write(to: url, options: .atomic)
print("Wrote \(url.lastPathComponent)")
}

private static func proofPNG(icon: NSImage) -> Data? {
guard let representation = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(canvasSize.width),
pixelsHigh: Int(canvasSize.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0),
let context = NSGraphicsContext(bitmapImageRep: representation)
else { return nil }
representation.size = Self.canvasSize

NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
NSColor(srgbRed: 0.10, green: 0.11, blue: 0.12, alpha: 1).setFill()
NSRect(origin: .zero, size: Self.canvasSize).fill()

let paragraph = NSMutableParagraphStyle()
paragraph.alignment = .center
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 26, weight: .semibold),
.foregroundColor: NSColor.white,
.paragraphStyle: paragraph,
]
let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 22, weight: .regular),
.foregroundColor: NSColor(white: 0.72, alpha: 1),
.paragraphStyle: paragraph,
]
NSString(string: "Synthetic proof").draw(
in: NSRect(x: 24, y: 192, width: 312, height: 36),
withAttributes: titleAttributes)
NSString(string: "46% remaining").draw(
in: NSRect(x: 24, y: 158, width: 312, height: 32),
withAttributes: subtitleAttributes)

context.imageInterpolation = .none
icon.isTemplate = false
icon.draw(
in: NSRect(x: 108, y: 20, width: 144, height: 144),
from: NSRect(origin: .zero, size: icon.size),
operation: .sourceOver,
fraction: 1,
respectFlipped: false,
hints: nil)
NSGraphicsContext.restoreGraphicsState()
return representation.representation(using: .png, properties: [.interlaced: false])
}
}
Loading