diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 774589b78e..e242290ba1 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -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. @@ -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 @@ -36,6 +58,7 @@ enum IconRenderer { let style: Int let indicator: Int let hideCritters: Bool + let quotaLayoutPolicy: QuotaLayoutPolicy } private final class IconCacheStore: @unchecked Sendable { @@ -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 { @@ -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() @@ -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 @@ -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, @@ -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( @@ -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, @@ -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 } diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index d7df29cafe..5462ec7368 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -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)) } } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2ad7f1b211..5bb026843b 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -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, @@ -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, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index fc2df6a124..f467a3c51f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -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 } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 3142e7cffe..6a4bb1314b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -180,6 +180,7 @@ public enum ClaudeProviderDescriptor { menuCardStyle: .claude) }, iconDecorations: [.notches], + reservesMissingSecondaryIconLane: true, automaticSelectionPrioritizesExhaustedWindow: false, menuBarWindowResolver: self.menuBarWindow, planUtilizationSeriesResolver: { snapshot in diff --git a/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift b/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift index fba4bdc0e1..4121c101d7 100644 --- a/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift +++ b/Sources/CodexBarCore/Providers/ProviderUsagePresentation.swift @@ -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? @@ -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, @@ -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 diff --git a/Tests/CodexBarTests/IconRendererHideCrittersTests.swift b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift index eb879f1b2c..e8b8eab26f 100644 --- a/Tests/CodexBarTests/IconRendererHideCrittersTests.swift +++ b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift @@ -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. diff --git a/Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift b/Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift new file mode 100644 index 0000000000..2b27ff0e41 --- /dev/null +++ b/Tests/CodexBarTests/IconRendererScreenshotRenderTests.swift @@ -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]) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index a189eecbaa..a9414c05b9 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1719,7 +1719,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/IconRenderer.swift", - line: 668, + line: 691, anchor: "let twistGemini = decorations.contains(.gemini)", expectedProviderIDs: ["antigravity", "factory", "gemini", "warp"], expectedReferenceCount: 4, @@ -2464,7 +2464,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 572, + line: 573, anchor: "guard isLoading, style == .warp, let phase else {", expectedProviderIDs: ["warp"], expectedReferenceCount: 1, @@ -2472,7 +2472,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 926, + line: 928, anchor: "if provider == .kiro {", expectedProviderIDs: ["cursor", "kiro"], expectedReferenceCount: 2, diff --git a/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift b/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift index 77446d9eb3..93194c3ca6 100644 --- a/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift +++ b/Tests/CodexBarTests/ProviderPresentationPolicyCharacterizationTests.swift @@ -174,6 +174,15 @@ struct ProviderPresentationPolicyCharacterizationTests { } } + @Test + func `missing secondary lane reservation is claude only`() { + for provider in UsageProvider.allCases { + let actual = ProviderDescriptorRegistry.descriptor(for: provider) + .presentation.reservesMissingSecondaryIconLane + #expect(actual == (provider == .claude), "Unexpected missing-lane policy for \(provider.rawValue)") + } + } + @Test func `credit visibility exceptions are pinned`() { let codex = ProviderDescriptorRegistry.descriptor(for: .codex).metadata diff --git a/docs/screenshots/codex-single-quota-icon-after.png b/docs/screenshots/codex-single-quota-icon-after.png new file mode 100644 index 0000000000..0118c1fa91 Binary files /dev/null and b/docs/screenshots/codex-single-quota-icon-after.png differ diff --git a/docs/screenshots/codex-single-quota-icon-before.png b/docs/screenshots/codex-single-quota-icon-before.png new file mode 100644 index 0000000000..f921b09297 Binary files /dev/null and b/docs/screenshots/codex-single-quota-icon-before.png differ diff --git a/docs/screenshots/codex-single-quota-icon-proof.md b/docs/screenshots/codex-single-quota-icon-proof.md new file mode 100644 index 0000000000..d0ad401439 --- /dev/null +++ b/docs/screenshots/codex-single-quota-icon-proof.md @@ -0,0 +1,24 @@ +# Codex single-quota icon proof + +These screenshots use a fixed synthetic input: `46% remaining`, with no secondary quota and no credits. +They contain only pixels from `IconRenderer` plus the generic labels shown in the cards. No live provider, +account, Keychain, desktop, username, email, or filesystem data is read or displayed. + +| Before | After | +| --- | --- | +| ![Before: 46% occupies the upper lane while the unavailable lower lane remains](codex-single-quota-icon-before.png) | ![After: 46% occupies one prominent meter](codex-single-quota-icon-after.png) | + +The before image was rendered from upstream commit `27c7f334e3c46c96ff8c063afbe0c7944ba5e0b7` with +`style: .codex`. The after image was rendered from this branch with `style: .combined` and +`quotaLayoutPolicy: .provider(.codex)`, matching the merged-menu dispatch path. The old renderer did not +accept an explicit quota-layout policy; with critters hidden, its relevant single-quota geometry was the same +for `.codex` and `.combined`. + +Generate the after proof with: + +```sh +CODEXBAR_ICON_SCREENSHOT_DIR=docs/screenshots \ + swift test --filter IconRendererScreenshotRenderTests +``` + +The opt-in screenshot test is skipped during normal test runs.