diff --git a/.github/pr-proof/widget-readability-redesign.log b/.github/pr-proof/widget-readability-redesign.log new file mode 100644 index 0000000000..da9e8e98f9 --- /dev/null +++ b/.github/pr-proof/widget-readability-redesign.log @@ -0,0 +1,110 @@ +CodexBar usage widget redesign - headless render proof +Captured: 2026-08-22T04:45:37Z +Base: upstream/main 27c7f334e (0.54.1) +Host: macOS 26.5.2 (25F84), arm64, Swift 6.3.2 + +Setup +- Widget views were rendered through SwiftUI ImageRenderer at the exact macOS tile + point sizes (155x155, 329x155, 329x345), driving the same UsageTile code path the + extension uses. Tile size is passed explicitly rather than read from widgetFamily, + so every layout is reachable outside a widget host. +- All fixtures are synthetic. No account identity, no real spend, and no real token + counts appear in any attached image. +- The vibrant/clear pass masks the rendered content by its alpha, which is what the + system compositor does. Masking by luminance instead punches dark text out entirely + and is not a valid simulation. + +Run +- 40 tiles rendered across light, dark and clear appearances: both widgets at all + three families, plus the gallery placeholder path (placeholder(in:), which is what + the widget picker shows and which no test previously covered). +- Edge cases rendered and inspected: 3%-left lane, seven quota lanes on one provider, + nine enabled providers, a balance-only provider with no quota lanes, the show-used + preference inverted, and the longest widget-selectable provider name + ("Alibaba Token Plan"). +- Contact sheets attached at docs/screenshots/widget-redesign-{light,dark,clear}.png. + +Defects this caught before review +- WidgetKit applies its own content margins on top of the tile's padding. Every tile + was inset roughly twice as far as designed. Reproduced by adding 16pt to the render + and matching it against a device screenshot; fixed with .contentMarginsDisabled(). +- Tinted and clear appearances render through a luminance mask, so the brand-filled + provider mark became an empty white square with no monogram. Confirmed on device, + then fixed by inverting the mark outside .fullColor. +- Small tiles truncated the provider name to "C..." because the timestamp claimed the + width. The name now wins; the timestamp appears on small only when data is stale. +- An HStack of provider marks wider than the tile drags the whole layout off the tile + rather than clipping. Reproduced with nine providers before the pager replaced the + chip row. +- RelativeDateTimeFormatter renders a just-stamped placeholder as "in 0s", a future + time. Anything under a minute now reads "Now". + +Automated review follow-up (Codex, reviewed commit c9feddf3a2) +- P1, binding lane hidden by a provider row cap. Confirmed: Kimi writes four rows + (primary, secondary, kimi-monthly, kimi-code-7d) while its widgetRowLimitResolver + returns 3 for compact families, and the cap was applied before the headline was + chosen. A 1%-left kimi-code-7d lane was dropped, a healthier lane became the + headline, and overflowCount stayed 0 so nothing indicated the omission. The cap now + curates only what a tile LISTS; headline and overflow are computed against every lane + the provider reports. +- P2, reset caption missing for non-Codex providers. Confirmed against + UsageStore+WidgetSnapshot.swift: the generic writer emits primary/secondary/tertiary + rows with a percentage only and leaves the reset on the entry's own windows, and only + legacy Codex windows were being reconstructed. Matches a device screenshot where the + Claude tile showed no reset line. Reset is now recovered from the matching entry slot; + the row keeps its own percentage. Fixed widget-side so already-persisted snapshots + benefit without a format change. +- P2, stale reset wording after the reset date. Confirmed: a known resetsAt in the past + fell through to the cached description, so a stale snapshot kept reading + "Resets in 4h" after the reset. An expired known date now renders nothing. +- Each fix carries a regression test that fails without it. + +Checks +- make check 0 violations, formatting clean +- make test 923 selections; see limit below +- swift build --target CodexBarWidget clean +- xcodebuild CodexBarWidgetExtension Release, arm64 + x86_64, BUILD SUCCEEDED +- 46 focused tests over the pure layout logic (headline selection, lane capacity, + overflow reservation, reset rounding, pager wrap-around, monogram uniqueness, + tinted-mode mark styling, WCAG contrast for every provider colour), including the + combined curation/reservation regression, a density guard, and a sweep across + lane count x curation x budget x binding-lane position + +Limit +- StatusMenuSwitcherRefreshTests "merged provider switch updates live tab rows in + place" fails with 2 issues. It fails identically on a clean checkout of the same + base (27c7f334e) with this branch's changes absent, verified in a separate + worktree. It is a headless AppKit menu-identity assertion and is unrelated to this + diff, which touches only Sources/CodexBarWidget. +- The before/after contact sheets are headless renders. Runtime behaviour is proven + separately below with real WidgetKit desktop screenshots; those frames carry a + synthetic seeded snapshot, so no live account usage appears in any attached image. + +Runtime proof (2cdfe0734 + docs commit) +- Packaged the real appex (CodexBarWidget.appex inside CodexBar.app, debug config, + Developer ID signed, app group .com.steipete.codexbar.debug) and installed it + as /Applications/CodexBar (Test).app on macOS 26. pluginkit showed + com.steipete.codexbar.debug.widget registered. +- Launched the app once; it wrote widget-snapshot.json into the app-group container + (live pipeline). Quit the app, then replaced the file with a synthetic snapshot + (5 providers incl. a 4-lane Kimi shape with a 1%-left code lane) validated against + the real decoder first. Everything below ran with the app process dead - timelines + and intents are the extension's own. +- Added the Switcher widget at small, medium and large from the gallery; all three + rendered the seeded data with fresh timestamps (widget-redesign-runtime-pager.png, + widget-redesign-runtime-modes.png). +- Pager: clicked the chevrons through Codex 1/5 -> Claude 2/5 -> Gemini 3/5 -> + Cursor 4/5 -> Kimi 5/5 -> wrap to 1/5, in place, no Edit Widget. Claude's reset + caption came from the slot-recovery path (its seeded rows carry no window), Gemini's + 7% hero rendered in severity colour with full-width bar. +- Kimi shape on-device: medium showed hero 1% Code 7d + Session/Weekly + "+1 more" + (three rows in the three-row budget); small reserved the overflow slot and showed + hero + Session + "+2 more" (two rows in the two-row budget); large listed every + lane with no overflow line. That is the combined curation/reservation fix from this + review round, live. +- Tinted: set "Dim widgets on desktop" to Always, captured all three sizes in the + vibrant rendering - monograms, bars, chart and pager all legible - then restored + the setting. Margins: tile content sits on its own 14pt inset with no doubled + system margin at any size. +- Cleanup: widgets removed, test app and its group container deleted, production + CodexBar untouched throughout. diff --git a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift index 0620a59fca..e9ad08f336 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift @@ -26,6 +26,9 @@ struct CodexBarSwitcherWidget: Widget { .configurationDisplayName("CodexBar Switcher") .description("Usage widget with a provider switcher.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + // The tiles own their padding (`WidgetLayout.tilePadding`). Without this, WidgetKit adds + // its default content margins on top and every tile floats well inside its own edges. + .contentMarginsDisabled() } } @@ -43,6 +46,9 @@ struct CodexBarUsageWidget: Widget { .configurationDisplayName("CodexBar Usage") .description("Session and weekly usage with credits and costs.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + // The tiles own their padding (`WidgetLayout.tilePadding`). Without this, WidgetKit adds + // its default content margins on top and every tile floats well inside its own edges. + .contentMarginsDisabled() } } @@ -60,6 +66,9 @@ struct CodexBarHistoryWidget: Widget { .configurationDisplayName("CodexBar History") .description("Usage history chart with recent totals.") .supportedFamilies([.systemMedium, .systemLarge]) + // The tiles own their padding (`WidgetLayout.tilePadding`). Without this, WidgetKit adds + // its default content margins on top and every tile floats well inside its own edges. + .contentMarginsDisabled() } } @@ -77,6 +86,9 @@ struct CodexBarCompactWidget: Widget { .configurationDisplayName("CodexBar Metric") .description("Compact widget for credits or cost.") .supportedFamilies([.systemSmall]) + // The tiles own their padding (`WidgetLayout.tilePadding`). Without this, WidgetKit adds + // its default content margins on top and every tile floats well inside its own edges. + .contentMarginsDisabled() } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index bfe3e09065..cdbbc9d6a1 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -2,8 +2,14 @@ import CodexBarCore import SwiftUI import WidgetKit -extension EnvironmentValues { - @Entry fileprivate var widgetUsageShowsUsed: Bool = false +extension WidgetTileSize { + init(family: WidgetFamily) { + switch family { + case .systemSmall: self = .small + case .systemMedium: self = .medium + default: self = .large + } + } } struct CodexBarUsageWidgetView: View { @@ -14,39 +20,21 @@ struct CodexBarUsageWidgetView: View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider.instanceID } Group { if let providerEntry { - self.content(providerEntry: providerEntry) + UsageTile(entry: providerEntry, size: WidgetTileSize(family: self.family)) { + TileHeader( + provider: providerEntry.provider, + updatedAt: providerEntry.updatedAt, + size: WidgetTileSize(family: self.family)) + } } else { - self.emptyState + WidgetEmptyState(message: "Usage data will appear once the app refreshes.") + .padding(WidgetLayout.tilePadding) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } - - @ViewBuilder - private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View { - switch self.family { - case .systemSmall: - SmallUsageView(entry: providerEntry) - case .systemMedium: - MediumUsageView(entry: providerEntry) - default: - LargeUsageView(entry: providerEntry) - } - } - - private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") - .font(.body) - .fontWeight(.semibold) - Text("Usage data will appear once the app refreshes.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(12) - } } struct CodexBarHistoryWidgetView: View { @@ -59,24 +47,13 @@ struct CodexBarHistoryWidgetView: View { if let providerEntry { HistoryView(entry: providerEntry, isLarge: self.family == .systemLarge) } else { - self.emptyState + WidgetEmptyState(message: "Usage history will appear after a refresh.") + .padding(WidgetLayout.tilePadding) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) } - - private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") - .font(.body) - .fontWeight(.semibold) - Text("Usage history will appear after a refresh.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(12) - } } struct CodexBarCompactWidgetView: View { @@ -88,24 +65,13 @@ struct CodexBarCompactWidgetView: View { if let providerEntry { CompactMetricView(entry: providerEntry, metric: self.entry.metric) } else { - self.emptyState + WidgetEmptyState(message: "Usage data will appear once the app refreshes.") + .padding(WidgetLayout.tilePadding) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) } - - private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") - .font(.body) - .fontWeight(.semibold) - Text("Usage data will appear once the app refreshes.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(12) - } } struct CodexBarSwitcherWidgetView: View { @@ -114,47 +80,32 @@ struct CodexBarSwitcherWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider.instanceID } - VStack(alignment: .leading, spacing: 10) { - ProviderSwitcherRow( - providers: self.entry.availableProviders, - selected: self.entry.provider, - updatedAt: providerEntry?.updatedAt ?? Date(), - compact: self.family == .systemSmall, - showsTimestamp: self.family != .systemSmall) + let size = WidgetTileSize(family: self.family) + Group { if let providerEntry { - self.content(providerEntry: providerEntry) + UsageTile(entry: providerEntry, size: size) { + ProviderPagerHeader( + providers: self.entry.availableProviders, + selected: self.entry.provider, + updatedAt: providerEntry.updatedAt, + size: size) + } } else { - self.emptyState + VStack(alignment: .leading, spacing: WidgetLayout.sectionSpacing) { + ProviderPagerHeader( + providers: self.entry.availableProviders, + selected: self.entry.provider, + updatedAt: Date(), + size: size) + WidgetEmptyState(message: "Usage data appears after a refresh.") + } + .padding(WidgetLayout.tilePadding) } } - .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } - - @ViewBuilder - private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View { - switch self.family { - case .systemSmall: - SwitcherSmallUsageView(entry: providerEntry) - case .systemMedium: - SwitcherMediumUsageView(entry: providerEntry) - default: - SwitcherLargeUsageView(entry: providerEntry) - } - } - - private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Open CodexBar") - .font(.caption) - .foregroundStyle(.secondary) - Text("Usage data appears after a refresh.") - .font(.caption2) - .foregroundStyle(.secondary) - } - } } private struct CompactMetricView: View { @@ -163,23 +114,20 @@ private struct CompactMetricView: View { var body: some View { let display = CompactMetricFormatter.display(for: self.entry, metric: self.metric) - VStack(alignment: .leading, spacing: 8) { - HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - VStack(alignment: .leading, spacing: 2) { - Text(display.value) - .font(.title2) - .fontWeight(.semibold) - Text(display.label) - .font(.caption2) - .foregroundStyle(.secondary) - if let detail = display.detail { - Text(detail) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - .padding(12) + VStack(alignment: .leading, spacing: WidgetLayout.sectionSpacing) { + TileHeader(provider: self.entry.provider, updatedAt: self.entry.updatedAt, size: .small) + Spacer(minLength: 0) + // This tile carries a single figure, so it gets the full width and sits centred rather + // than clinging to the header with dead space underneath. + HeroBlock( + value: display.value, + caption: display.label, + detail: display.detail, + numberSize: WidgetLayout.compactMetricNumberSize) + Spacer(minLength: 0) + } + .padding(WidgetLayout.tilePadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } @@ -199,12 +147,16 @@ enum CompactMetricFormatter { label: "Extra usage balance", detail: nil) } - let value = entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" - return CompactMetricDisplay(value: value, label: "Credits left", detail: nil) + let value = entry.creditsRemaining.map(WidgetFormat.credits) ?? WidgetFormat.unavailable + return CompactMetricDisplay( + value: value, + label: "Credits left", + detail: Self.unavailableDetail(value: value, entry: entry)) case .todayCost: let value = entry.tokenUsage.map { token in - token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" - } ?? "—" + token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } + ?? WidgetFormat.unavailable + } ?? WidgetFormat.unavailable let detail = entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) let label = entry.tokenUsage.map { WidgetFormat.tokenRowTitle( @@ -215,8 +167,9 @@ enum CompactMetricFormatter { return CompactMetricDisplay(value: value, label: label, detail: detail) case .last30DaysCost: let value = entry.tokenUsage.map { token in - token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" - } ?? "—" + token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } + ?? WidgetFormat.unavailable + } ?? WidgetFormat.unavailable let detail = entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) let label = entry.tokenUsage.map { WidgetFormat.tokenRowTitle( @@ -228,6 +181,16 @@ enum CompactMetricFormatter { } } + /// A tile pinned to a metric its provider never reports would otherwise be a blank square. Name + /// the provider so the reason is obvious and the widget can be reconfigured. + static func unavailableDetail(value: String, entry: WidgetSnapshot.ProviderEntry) -> String? { + guard value == WidgetFormat.unavailable else { return nil } + let name = entry.provider.firstPartyProvider + .flatMap { ProviderDefaults.metadata[$0]?.displayName } + ?? entry.provider.rawValue.capitalized + return "Not reported by \(name)" + } + static func costMetricLabel(_ label: String, provider: ProviderInstanceID) -> String { // Provider-specific by design: old Codex widget timelines lack the API-estimate billing disclaimer. guard provider == .codex else { return "\(label) cost" } @@ -238,328 +201,94 @@ enum CompactMetricFormatter { } } -private struct ProviderSwitcherRow: View { +/// Header for the switcher widget: the selected provider spelled out in full, plus a pager to +/// move through the others. +/// +/// A widget cannot open a menu or a picker, so paging is the control that fits: it costs one line +/// instead of a whole chip row, and the provider whose numbers are on screen is always named. +private struct ProviderPagerHeader: View { let providers: [UsageProvider] let selected: UsageProvider let updatedAt: Date - let compact: Bool - let showsTimestamp: Bool - - var body: some View { - HStack(spacing: self.compact ? 4 : 6) { - ForEach(self.providers, id: \.self) { provider in - ProviderSwitchChip( - provider: provider, - selected: provider == self.selected, - compact: self.compact) - } - if self.showsTimestamp { - Spacer(minLength: 6) - Text(WidgetFormat.relativeDate(self.updatedAt)) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } -} - -private struct ProviderSwitchChip: View { - let provider: UsageProvider - let selected: Bool - let compact: Bool + let size: WidgetTileSize var body: some View { - let label = self.compact ? self.shortLabel : self.longLabel - let background = self.selected - ? WidgetColors.color(for: self.provider.instanceID).opacity(0.2) - : Color.primary.opacity(0.08) - - if let choice = ProviderChoice(provider: self.provider) { - Button(intent: SwitchWidgetProviderIntent(provider: choice)) { - Text(label) - .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold)) - .foregroundStyle(self.selected ? Color.primary : Color.secondary) - .padding(.horizontal, self.compact ? 6 : 8) - .padding(.vertical, self.compact ? 3 : 4) - .background(Capsule().fill(background)) - } - .buttonStyle(.plain) - } else { - Text(label) - .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold)) - .foregroundStyle(self.selected ? Color.primary : Color.secondary) - .padding(.horizontal, self.compact ? 6 : 8) - .padding(.vertical, self.compact ? 3 : 4) - .background(Capsule().fill(background)) - } - } - - private var longLabel: String { - ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized - } - - private var shortLabel: String { - ProviderDefaults.metadata[self.provider]?.shortDisplayName ?? self.provider.rawValue.capitalized - } -} - -private struct SwitcherSmallUsageView: View { - let entry: WidgetSnapshot.ProviderEntry - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(WidgetUsageRow.rows( - for: self.entry, - limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) - { row in - UsageBarRow( - title: row.title, - percentLeft: row.percentLeft, - color: WidgetColors.color(for: self.entry.provider)) - } - if let codeReview = entry.codeReviewRemainingPercent { - UsageBarRow( - title: "Code review", - percentLeft: codeReview, - color: WidgetColors.color(for: self.entry.provider)) - } - if let token = WidgetUsageRow.compactTokenUsage(for: self.entry) { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) - } - if let balance = extraUsageBalanceLine(for: entry) { - balance - } - } - } -} - -private struct SwitcherMediumUsageView: View { - let entry: WidgetSnapshot.ProviderEntry - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - ForEach(WidgetUsageRow.rows( - for: self.entry, - limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) - { row in - UsageBarRow( - title: row.title, - percentLeft: row.percentLeft, - color: WidgetColors.color(for: self.entry.provider)) - } - if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) - } - if let token = entry.tokenUsage { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) + let pager = ProviderPager.make(providers: self.providers, selected: self.selected) + HStack(spacing: 7) { + ProviderMark(provider: self.selected, isSelected: true, size: self.size.markSize) + Text(ProviderTitle.text(for: self.selected, size: self.size)) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .layoutPriority(2) + if self.size != .small { + FreshnessLabel(updatedAt: self.updatedAt) + .layoutPriority(0) } - if let balance = extraUsageBalanceLine(for: entry) { - balance + Spacer(minLength: 4) + if let pager, pager.isPageable { + ProviderPagerControls(pager: pager, size: self.size) + .layoutPriority(1) } } } } -private struct SwitcherLargeUsageView: View { - let entry: WidgetSnapshot.ProviderEntry +private struct ProviderPagerControls: View { + let pager: ProviderPager + let size: WidgetTileSize var body: some View { - VStack(alignment: .leading, spacing: 12) { - ForEach(WidgetUsageRow.rows(for: self.entry)) { row in - UsageBarRow( - title: row.title, - percentLeft: row.percentLeft, - color: WidgetColors.color(for: self.entry.provider)) - } - if let codeReview = entry.codeReviewRemainingPercent { - UsageBarRow( - title: "Code review", - percentLeft: codeReview, - color: WidgetColors.color(for: self.entry.provider)) - } - if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) - } - if let token = entry.tokenUsage { - VStack(alignment: .leading, spacing: 4) { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.last30DaysLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens, - currencyCode: token.currencyCode)) - } - } - if let balance = extraUsageBalanceLine(for: entry) { - balance + HStack(spacing: 3) { + if self.size != .small { + ProviderPageButton(provider: self.pager.previous, symbol: "chevron.left", size: self.size) + Text(self.pager.positionText) + .font(.caption2.weight(.medium)) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() } - UsageHistoryChart( - points: self.entry.dailyUsage, - color: WidgetColors.color(for: self.entry.provider), - currencyCode: self.entry.tokenUsage?.currencyCode) - .frame(height: 50) + ProviderPageButton(provider: self.pager.next, symbol: "chevron.right", size: self.size) } + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Provider \(self.pager.positionText)")) } } -private struct SmallUsageView: View { - let entry: WidgetSnapshot.ProviderEntry +private struct ProviderPageButton: View { + let provider: UsageProvider + let symbol: String + let size: WidgetTileSize var body: some View { - VStack(alignment: .leading, spacing: 8) { - HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - ForEach(WidgetUsageRow.rows( - for: self.entry, - limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) - { row in - UsageBarRow( - title: row.title, - percentLeft: row.percentLeft, - color: WidgetColors.color(for: self.entry.provider)) - } - if let codeReview = entry.codeReviewRemainingPercent { - UsageBarRow( - title: "Code review", - percentLeft: codeReview, - color: WidgetColors.color(for: self.entry.provider)) - } - if let token = WidgetUsageRow.compactTokenUsage(for: self.entry) { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) - } - if let balance = extraUsageBalanceLine(for: entry) { - balance + if let choice = ProviderChoice(provider: self.provider) { + Button(intent: SwitchWidgetProviderIntent(provider: choice)) { + self.glyph } + .buttonStyle(.plain) + .accessibilityLabel(Text(ProviderPageButtonLabel.text(for: self.provider))) + } else { + self.glyph.opacity(0.4) } - .padding(12) } -} -private struct MediumUsageView: View { - let entry: WidgetSnapshot.ProviderEntry - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - ForEach(WidgetUsageRow.rows( - for: self.entry, - limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) - { row in - UsageBarRow( - title: row.title, - percentLeft: row.percentLeft, - color: WidgetColors.color(for: self.entry.provider)) - } - if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) - } - if let token = entry.tokenUsage { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) - } - if let balance = extraUsageBalanceLine(for: entry) { - balance - } - } - .padding(12) + private var glyph: some View { + let side = self.size.markSize - 2 + return Image(systemName: self.symbol) + .font(.system(size: side * 0.44, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: side, height: side) + .background( + RoundedRectangle(cornerRadius: side * 0.3, style: .continuous) + .fill(Color.primary.opacity(0.07))) } } -private struct LargeUsageView: View { - let entry: WidgetSnapshot.ProviderEntry - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - ForEach(WidgetUsageRow.rows(for: self.entry)) { row in - UsageBarRow( - title: row.title, - percentLeft: row.percentLeft, - color: WidgetColors.color(for: self.entry.provider)) - } - if let codeReview = entry.codeReviewRemainingPercent { - UsageBarRow( - title: "Code review", - percentLeft: codeReview, - color: WidgetColors.color(for: self.entry.provider)) - } - if let credits = entry.creditsRemaining { - ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) - } - if let token = entry.tokenUsage { - VStack(alignment: .leading, spacing: 4) { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.last30DaysLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens, - currencyCode: token.currencyCode)) - } - } - if let balance = extraUsageBalanceLine(for: entry) { - balance - } - UsageHistoryChart( - points: self.entry.dailyUsage, - color: WidgetColors.color(for: self.entry.provider), - currencyCode: self.entry.tokenUsage?.currencyCode) - .frame(height: 50) - } - .padding(12) +enum ProviderPageButtonLabel { + static func text(for provider: UsageProvider) -> String { + let name = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized + return "Switch to \(name)" } } @@ -567,6 +296,10 @@ struct WidgetUsageRow: Identifiable, Equatable { let id: String let title: String let percentLeft: Double? + /// Carried through from the row's rate window so tiles can show when the lane frees up; the + /// snapshot has always had it and the widgets used to drop it. + var resetsAt: Date? + var resetDescription: String? private enum AntigravityQuotaFamily { case gemini @@ -611,10 +344,17 @@ struct WidgetUsageRow: Identifiable, Equatable { window: window) } let sourceRows = resolvedSnapshots.map { row in - WidgetUsageRow( + // The generic snapshot writer stores only a percentage for the primary/secondary/ + // tertiary slots and leaves their reset on the entry's own windows, so recover it + // here. Without this the reset caption is present for Codex and absent for every + // other provider. Resolved reset only — the row keeps its own percentage. + let reset = row.window ?? self.slotWindow(for: row.id, entry: entry) + return WidgetUsageRow( id: row.id, title: row.title, - percentLeft: row.window?.remainingPercent ?? row.percentLeft) + percentLeft: row.window?.remainingPercent ?? row.percentLeft, + resetsAt: reset?.resetsAt, + resetDescription: reset?.resetDescription) } rows = self.applyingCodexWeeklyCap( sourceRows, @@ -627,17 +367,23 @@ struct WidgetUsageRow: Identifiable, Equatable { WidgetUsageRow( id: "primary", title: metadata?.sessionLabel ?? "Session", - percentLeft: entry.primary?.remainingPercent), + percentLeft: entry.primary?.remainingPercent, + resetsAt: entry.primary?.resetsAt, + resetDescription: entry.primary?.resetDescription), WidgetUsageRow( id: "secondary", title: metadata?.weeklyLabel ?? "Weekly", - percentLeft: entry.secondary?.remainingPercent), + percentLeft: entry.secondary?.remainingPercent, + resetsAt: entry.secondary?.resetsAt, + resetDescription: entry.secondary?.resetDescription), ] if metadata?.supportsOpus == true { defaultRows.append(WidgetUsageRow( id: "tertiary", title: metadata?.opusLabel ?? "Opus", - percentLeft: entry.tertiary?.remainingPercent)) + percentLeft: entry.tertiary?.remainingPercent, + resetsAt: entry.tertiary?.resetsAt, + resetDescription: entry.tertiary?.resetDescription)) } rows = defaultRows.filter { $0.percentLeft != nil } } @@ -690,7 +436,23 @@ struct WidgetUsageRow: Identifiable, Equatable { } return rows.map { row in guard row.id == "session" else { return row } - return WidgetUsageRow(id: row.id, title: row.title, percentLeft: 0) + return WidgetUsageRow( + id: row.id, + title: row.title, + percentLeft: 0, + resetsAt: weekly.resetsAt, + resetDescription: weekly.resetDescription) + } + } + + /// Reset metadata for the slot IDs the generic snapshot writer emits. Deliberately narrow: + /// provider-specific row IDs carry their own window when the writer has one. + static func slotWindow(for rowID: String, entry: WidgetSnapshot.ProviderEntry) -> RateWindow? { + switch rowID { + case "primary": entry.primary + case "secondary": entry.secondary + case "tertiary": entry.tertiary + default: nil } } @@ -774,109 +536,49 @@ private struct HistoryView: View { let isLarge: Bool var body: some View { - VStack(alignment: .leading, spacing: 12) { - HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) + VStack(alignment: .leading, spacing: WidgetLayout.sectionSpacing) { + TileHeader( + provider: self.entry.provider, + updatedAt: self.entry.updatedAt, + size: self.isLarge ? .large : .medium) UsageHistoryChart( points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider), currencyCode: self.entry.tokenUsage?.currencyCode) - .frame(height: self.isLarge ? 90 : 60) + .frame(minHeight: self.isLarge ? 110 : 58, maxHeight: .infinity) if let token = entry.tokenUsage { - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.sessionLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.sessionCostUSD, - tokens: token.sessionTokens, - currencyCode: token.currencyCode)) - ValueLine( - title: WidgetFormat.tokenRowTitle( - token.last30DaysLabel, - summary: token, - entryUpdatedAt: self.entry.updatedAt), - value: WidgetFormat.costAndTokens( - cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens, - currencyCode: token.currencyCode)) - } - } - .padding(12) - } -} - -private struct HeaderView: View { - let provider: ProviderInstanceID - let updatedAt: Date - - var body: some View { - HStack(alignment: .firstTextBaseline) { - Text(self.provider.firstPartyProvider.flatMap { ProviderDefaults.metadata[$0]?.displayName } - ?? self.provider.rawValue.capitalized) - .font(.body) - .fontWeight(.semibold) - Spacer() - Text(WidgetFormat.relativeDate(self.updatedAt)) - .font(.caption2) - .foregroundStyle(.secondary) - } - } -} - -private struct UsageBarRow: View { - @Environment(\.widgetUsageShowsUsed) private var showUsed - let title: String - let percentLeft: Double? - let color: Color - - var body: some View { - let percent = WidgetUsageDisplay.percent(fromRemaining: self.percentLeft, showUsed: self.showUsed) - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(self.title) - .font(.caption) - Spacer() - Text(WidgetFormat.percent(percent)) - .font(.caption) - .foregroundStyle(.secondary) - } - GeometryReader { proxy in - let width = max(0, min(1, (percent ?? 0) / 100)) * proxy.size.width - ZStack(alignment: .leading) { - Capsule().fill(Color.primary.opacity(0.08)) - Capsule().fill(self.color).frame(width: width) + VStack(alignment: .leading, spacing: 4) { + WidgetSeparator().padding(.bottom, 2) + MetricLine( + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode), + isProminent: true) + MetricLine( + title: WidgetFormat.tokenRowTitle( + token.last30DaysLabel, + summary: token, + entryUpdatedAt: self.entry.updatedAt), + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } - .frame(height: 6) - } - } -} - -private struct ValueLine: View { - let title: String - let value: String - - var body: some View { - HStack(spacing: 6) { - Text(self.title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text(self.value) - .font(.caption) - .monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.8) - .allowsTightening(true) - .layoutPriority(1) } + .padding(WidgetLayout.tilePadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } -private struct UsageHistoryChart: View { +/// Daily cost or token history. The chart now labels its peak and its date range, and the most +/// recent day is drawn at full strength so "where am I today" is answerable at a glance. +struct UsageHistoryChart: View { let points: [WidgetSnapshot.DailyUsagePoint] let color: Color let currencyCode: String? @@ -884,41 +586,84 @@ private struct UsageHistoryChart: View { var body: some View { let isCostMode = UsageHistoryChartMode.isCostMode(self.points) let values = self.points.map { point -> Double in - if isCostMode { - return point.costUSD ?? 0 - } - return Double(point.totalTokens ?? 0) + isCostMode ? (point.costUSD ?? 0) : Double(point.totalTokens ?? 0) } let scale = UsageChartScale(values: values) - VStack(alignment: .trailing, spacing: 2) { - if isCostMode, - let currencyCode = self.currencyCode, - scale.maximum > 0 - { - Text(UsageFormatter.compactCurrencyString(scale.maximum, currencyCode: currencyCode)) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(UsageHistoryChartCopy.title(dayCount: self.points.count)) .font(.caption2) .foregroundStyle(.secondary) - .monospacedDigit() .lineLimit(1) - .allowsTightening(true) + Spacer(minLength: 4) + if let peak = UsageHistoryChartCopy.peak( + maximum: scale.maximum, + isCostMode: isCostMode, + currencyCode: self.currencyCode) + { + Text(peak) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + .allowsTightening(true) + } } GeometryReader { geometry in - HStack(alignment: .bottom, spacing: 2) { + HStack(alignment: .bottom, spacing: 3) { ForEach(values.indices, id: \.self) { index in let fraction = scale.fraction(for: values[index]) - RoundedRectangle(cornerRadius: 2) - .fill(self.color.opacity(0.85)) + RoundedRectangle(cornerRadius: 2.5, style: .continuous) + .fill(self.color.opacity(index == values.indices.last ? 1 : 0.55)) .frame(maxWidth: .infinity) .frame(height: max(fraction > 0 ? 2 : 0, CGFloat(fraction) * geometry.size.height)) - .animation(.easeOut(duration: 0.2), value: fraction) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } + .frame(maxHeight: .infinity) + Rectangle() + .fill(Color.primary.opacity(WidgetLayout.separatorOpacity)) + .frame(height: 1) + if let range = UsageHistoryChartCopy.range(points: self.points) { + HStack(spacing: 4) { + Text(range.start) + Spacer(minLength: 4) + Text(range.end) + } + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } } } } +enum UsageHistoryChartCopy { + static func title(dayCount: Int) -> String { + dayCount > 0 ? "Last \(dayCount) days" : "Daily usage" + } + + static func peak(maximum: Double, isCostMode: Bool, currencyCode: String?) -> String? { + guard maximum > 0 else { return nil } + if isCostMode, let currencyCode { + return "Peak \(UsageFormatter.compactCurrencyString(maximum, currencyCode: currencyCode))" + } + guard !isCostMode else { return nil } + return "Peak \(UsageFormatter.tokenCountString(Int(maximum)))" + } + + static func range(points: [WidgetSnapshot.DailyUsagePoint]) -> (start: String, end: String)? { + guard let first = points.first?.dayKey, + let last = points.last?.dayKey, + let start = WidgetFormat.dayLabel(first), + let end = WidgetFormat.dayLabel(last), + start != end + else { return nil } + return (start, end) + } +} + enum UsageHistoryChartMode { static func isCostMode(_ points: [WidgetSnapshot.DailyUsagePoint]) -> Bool { !points.isEmpty && points.allSatisfy { $0.costUSD != nil } @@ -959,14 +704,12 @@ enum WidgetBalanceFormatter { } } -private func extraUsageBalanceLine(for entry: WidgetSnapshot.ProviderEntry) -> ValueLine? { - guard let line = WidgetBalanceFormatter.extraUsageBalance(for: entry) else { return nil } - return ValueLine(title: line.title, value: line.value) -} - enum WidgetFormat { + /// Shown when a provider does not report a figure at all. + static let unavailable = "—" + static func percent(_ value: Double?) -> String { - guard let value else { return "—" } + guard let value else { return self.unavailable } return String(format: "%.0f%%", value) } @@ -979,7 +722,7 @@ enum WidgetFormat { } static func costAndTokens(cost: Double?, tokens: Int?, currencyCode: String = "USD") -> String { - let costText = cost.map { self.currency($0, code: currencyCode) } ?? "—" + let costText = cost.map { self.currency($0, code: currencyCode) } ?? self.unavailable if let tokens { return "\(costText) · \(self.tokenCount(tokens))" } @@ -1006,6 +749,41 @@ enum WidgetFormat { return formatter.localizedString(for: date, relativeTo: Date()) } + /// Freshness for the tile header. Abbreviated units keep it to a fixed short string so it can + /// never crowd out the provider name the way "5 days ago" did. + /// + /// Anything under a minute collapses to "Now": gallery placeholders are stamped with the render + /// time, and the formatter renders that instant as "in 0s", which reads as a future time. + static func shortRelativeDate(_ date: Date, relativeTo now: Date = Date()) -> String { + guard now.timeIntervalSince(date) >= 60 else { return "Now" } + let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: now) + } + + private static let dayKeyFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let dayLabelFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.setLocalizedDateFormatFromTemplate("MMMd") + return formatter + }() + + /// "2026-08-12" to "Aug 12". Returns nil for keys the chart cannot date. + static func dayLabel(_ dayKey: String) -> String? { + guard let date = self.dayKeyFormatter.date(from: dayKey) else { return nil } + return self.dayLabelFormatter.string(from: date) + } + /// Suffixes the title with the token snapshot's own age once it lags the entry's /// freshness signal past `TokenUsageSummary.staleLagThreshold`. static func tokenRowTitle( diff --git a/Sources/CodexBarWidget/ProviderPager.swift b/Sources/CodexBarWidget/ProviderPager.swift new file mode 100644 index 0000000000..e98077ccd4 --- /dev/null +++ b/Sources/CodexBarWidget/ProviderPager.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation + +/// Where the selected provider sits in the switcher's rotation, and which providers the previous +/// and next controls move to. +/// +/// The switcher used to lay every provider out as a chip in a single row. That never fit: names +/// wrapped mid-word on large tiles, truncated to indistinguishable stems on medium ("Co…" is both +/// Codex and Copilot), and collapsed to a single letter on small, where four of five common +/// providers are a "C". Widgets cannot host a menu or picker, so the control that replaces it pages +/// through providers one at a time — which costs one line instead of a whole row and lets the +/// selected provider always be spelled out in full. +struct ProviderPager: Equatable { + let selected: UsageProvider + let previous: UsageProvider + let next: UsageProvider + /// 1-based, for the "2/5" position readout. + let position: Int + let total: Int + + var isPageable: Bool { + self.total > 1 + } + + var positionText: String { + "\(self.position)/\(self.total)" + } + + /// Wraps around at both ends so either control always leads somewhere. + static func make(providers: [UsageProvider], selected: UsageProvider) -> ProviderPager? { + guard !providers.isEmpty else { return nil } + let index = providers.firstIndex(of: selected) ?? 0 + let resolved = providers[index] + let count = providers.count + return ProviderPager( + selected: resolved, + previous: providers[(index - 1 + count) % count], + next: providers[(index + 1) % count], + position: index + 1, + total: count) + } +} diff --git a/Sources/CodexBarWidget/WidgetDesignSystem.swift b/Sources/CodexBarWidget/WidgetDesignSystem.swift new file mode 100644 index 0000000000..73f4a88ffe --- /dev/null +++ b/Sources/CodexBarWidget/WidgetDesignSystem.swift @@ -0,0 +1,448 @@ +import CodexBarCore +import SwiftUI +import WidgetKit + +extension EnvironmentValues { + /// Mirrors the app's "show used instead of remaining" preference into the tiles. + @Entry var widgetUsageShowsUsed: Bool = false + + /// Snapshot-rendering seam. WidgetKit owns `widgetRenderingMode` and nothing outside a widget + /// host can set it, so the tinted and clear appearances are unreachable from a preview without + /// this. Always nil in the shipping widget. + @Entry var widgetRenderingModeOverride: WidgetRenderingMode? +} + +// MARK: - Layout metrics + +/// Shared spacing and sizing for the usage and switcher tiles. Widget tiles have a fixed point +/// budget, so every value here is chosen against the macOS tile sizes (155×155, 329×155, 329×345) +/// rather than tuned by eye. +enum WidgetLayout { + static let tilePadding: CGFloat = 14 + static let sectionSpacing: CGFloat = 10 + static let laneSpacing: CGFloat = 7 + static let barHeight: CGFloat = 7 + static let heroBarHeight: CGFloat = 9 + static let markSizeSmall: CGFloat = 20 + static let markSizeRegular: CGFloat = 23 + static let separatorOpacity: Double = 0.12 + + /// The single-metric tile has no lanes to fit around, so its figure runs larger. + static let compactMetricNumberSize: CGFloat = 36 + + /// Remaining percentage at or below which a lane is called out in red. + static let lowQuotaThreshold: Double = 10 +} + +// MARK: - Provider monograms + +/// Two-character provider marks. +/// +/// Widget tiles are far too narrow for provider display names — the previous chips wrapped +/// mid-word on large and truncated to a single ambiguous glyph on small — so the switcher renders +/// a fixed-width monogram that can never wrap or truncate. +enum ProviderMonogram { + /// Provider-specific by design: the widget-selectable providers get curated marks because a + /// mechanical abbreviation collides (Codex/Copilot/Cursor/Claude all start with "C"). + private static let curated: [UsageProvider: String] = [ + .alibaba: "AL", + .alibabatokenplan: "AT", + .antigravity: "AG", + .claude: "CL", + .codex: "CX", + .copilot: "CP", + .cursor: "CU", + .devin: "DV", + .gemini: "GE", + .kilo: "KL", + .kimi: "KM", + .minimax: "MX", + .mistral: "MS", + .opencode: "OC", + .opencodego: "OG", + .qwencloud: "QW", + .zai: "ZA", + ] + + static func text(for provider: UsageProvider) -> String { + if let curated = self.curated[provider] { return curated } + let name = ProviderDefaults.metadata[provider]?.shortDisplayName ?? provider.rawValue + return self.derived(from: name) + } + + /// Initials for a two-word name, otherwise the first two letters. Never empty. + static func derived(from name: String) -> String { + let words = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + if words.count >= 2, let first = words[0].first, let second = words[1].first { + return String([first, second]).uppercased() + } + let letters = Array(words.first ?? "") + guard let first = letters.first else { return "?" } + return String([first, letters.dropFirst().first ?? first]).uppercased() + } +} + +// MARK: - Contrast + +enum WidgetContrast { + /// Black-vs-white crossover for WCAG relative luminance: below this a white label wins, above + /// it a black label wins. Solving 1.05/(L+0.05) = (L+0.05)/0.05 gives L ≈ 0.1791. + private static let crossoverLuminance: Double = 0.1791 + + /// Highest-contrast label colour for text drawn on `color`. Brand palettes span very light + /// (Gemini lilac) to mid (Claude terracotta); picking per colour keeps every mark legible. + static func label(on color: ProviderColor) -> Color { + self.relativeLuminance(of: color) > self.crossoverLuminance ? .black : .white + } + + static func relativeLuminance(of color: ProviderColor) -> Double { + func linear(_ value: Double) -> Double { + let clamped = min(1, max(0, value)) + return clamped <= 0.03928 ? clamped / 12.92 : pow((clamped + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(color.red) + 0.7152 * linear(color.green) + 0.0722 * linear(color.blue) + } +} + +// MARK: - Provider mark + +struct ProviderMark: View { + let provider: UsageProvider + let isSelected: Bool + var size: CGFloat = WidgetLayout.markSizeRegular + + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.widgetRenderingModeOverride) private var renderingModeOverride + + var body: some View { + let style = ProviderMarkStyle.resolve( + renderingMode: self.renderingModeOverride ?? self.renderingMode, + provider: self.provider, + isSelected: self.isSelected) + Text(ProviderMonogram.text(for: self.provider)) + .font(.system(size: self.size * 0.44, weight: .bold, design: .rounded)) + .kerning(-0.3) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(style.foreground) + .frame(width: self.size, height: self.size) + .background( + RoundedRectangle(cornerRadius: self.size * 0.3, style: .continuous) + .fill(style.background)) + .accessibilityLabel(Text(ProviderMarkLabel.text(for: self.provider, isSelected: self.isSelected))) + } +} + +struct ProviderMarkStyle { + let foreground: Color + let background: Color + + /// Desktop widgets in tinted and clear appearances render through a luminance mask, so a brand + /// fill carrying a dark label collapses to an empty white square — the monogram disappears + /// entirely. Outside full colour the mark inverts: a faint plate with a bright label, which + /// survives the mask. + static func resolve( + renderingMode: WidgetRenderingMode, + provider: UsageProvider, + isSelected: Bool) -> ProviderMarkStyle + { + guard renderingMode == .fullColor else { + return ProviderMarkStyle( + foreground: isSelected ? .primary : .primary.opacity(0.7), + background: .primary.opacity(isSelected ? 0.22 : 0.1)) + } + guard isSelected else { + return ProviderMarkStyle( + foreground: .primary.opacity(0.62), + background: .primary.opacity(0.08)) + } + let brand = ProviderBrandColor.resolve(for: provider.instanceID) + return ProviderMarkStyle( + foreground: brand.map(WidgetContrast.label(on:)) ?? .white, + background: WidgetColors.color(for: provider.instanceID)) + } +} + +enum ProviderTitle { + /// Roughly what fits beside the mark and the pager control on a 155pt tile at subheadline size. + static let compactCharacterBudget = 13 + + /// The provider name a tile of this size can print in full. Small tiles fall back to the + /// provider's own short name rather than truncating — a clipped identifier is exactly what this + /// header exists to avoid. + static func text(for provider: UsageProvider, size: WidgetTileSize) -> String { + let metadata = ProviderDefaults.metadata[provider] + let name = metadata?.displayName ?? provider.rawValue.capitalized + guard size == .small, name.count > self.compactCharacterBudget else { return name } + let short = metadata?.shortDisplayName ?? name + return short.count < name.count ? short : name + } +} + +enum ProviderMarkLabel { + static func text(for provider: UsageProvider, isSelected: Bool) -> String { + let name = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized + return isSelected ? "\(name), selected" : name + } +} + +enum ProviderBrandColor { + /// The raw brand colour behind `WidgetColors.color(for:)`, needed for contrast maths. + static func resolve(for instanceID: ProviderInstanceID) -> ProviderColor? { + guard let provider = instanceID.firstPartyProvider else { return nil } + return ProviderAccentColors.sharedOverride(for: instanceID) + ?? ProviderDescriptorRegistry.descriptor(for: provider).branding.widgetColor + } +} + +// MARK: - Quota bar + +struct QuotaBar: View { + let percent: Double? + let color: Color + var height: CGFloat = WidgetLayout.barHeight + + var body: some View { + GeometryReader { proxy in + let fraction = QuotaBar.fillFraction(self.percent) + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.12)) + if fraction > 0 { + // Floor the fill at the bar's own height so a nearly-exhausted lane reads as a + // visible nub instead of a sliver indistinguishable from an empty track. + Capsule() + .fill(self.color) + .frame(width: max(self.height, fraction * proxy.size.width)) + } + } + } + .frame(height: self.height) + } + + static func fillFraction(_ percent: Double?) -> Double { + guard let percent else { return 0 } + return min(1, max(0, percent / 100)) + } +} + +// MARK: - Quota lanes + +/// One quota lane rendered as label + value + bar. The value carries the visual weight; the +/// previous design had it dimmer than its own label. +struct QuotaLaneView: View { + let title: String + let percent: Double? + let remainingPercent: Double? + let color: Color + var showsBar: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(self.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(WidgetFormat.percent(self.percent)) + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(QuotaSeverity.isLow(remaining: self.remainingPercent) ? Color.red : Color.primary) + .lineLimit(1) + } + if self.showsBar { + QuotaBar(percent: self.percent, color: self.color) + } + } + } +} + +enum QuotaSeverity { + static func isLow(remaining: Double?) -> Bool { + guard let remaining else { return false } + return remaining <= WidgetLayout.lowQuotaThreshold + } +} + +/// The tile's headline: one large figure, what it measures, and when it frees up. +/// +/// Tiles lead with the *binding* quota — the lane with the least left — because that is the number +/// that decides whether the user can keep working. The old tiles gave every lane identical weight, +/// so a lane at 3% and a lane at 97% looked equally routine. +struct HeroBlock: View { + let value: String + var caption: String? + var detail: String? + /// Percentage for the accompanying bar. `nil` draws no bar. + var barPercent: Double? + var isLow: Bool = false + var color: Color = .secondary + var numberSize: CGFloat = 34 + /// Pushes the bar to the bottom of the available height so a column tile has no dead space + /// under the headline. + var spreads: Bool = false + + var isUnavailable: Bool { + self.value == WidgetFormat.unavailable + } + + private var unavailableAwareColor: Color { + if self.isUnavailable { return .secondary } + return self.isLow ? .red : .primary + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + // A missing figure is drawn small and muted: at headline size the em-dash placeholder + // reads as a heavy black bar, which looks like a broken tile rather than "no data". + Text(self.value) + .font(.system( + size: self.isUnavailable ? self.numberSize * 0.5 : self.numberSize, + weight: .semibold, + design: .rounded)) + .monospacedDigit() + .foregroundStyle(self.unavailableAwareColor) + .lineLimit(1) + .minimumScaleFactor(0.5) + if let caption = self.caption { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let detail = self.detail { + Text(detail) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let barPercent = self.barPercent { + if self.spreads { + Spacer(minLength: 5) + } + QuotaBar(percent: barPercent, color: self.color, height: WidgetLayout.heroBarHeight) + .padding(.top, self.spreads ? 0 : 5) + } + } + } +} + +// MARK: - Value rows + +/// A right-aligned figure with a muted label, used for cost and credit rows. +struct MetricLine: View { + let title: String + let value: String + var isProminent: Bool = false + + var body: some View { + HStack(spacing: 6) { + Text(self.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + Text(self.value) + .font(self.isProminent ? .caption.weight(.semibold) : .caption) + .monospacedDigit() + .foregroundStyle(self.isProminent ? Color.primary : Color.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + .allowsTightening(true) + .layoutPriority(1) + } + } +} + +struct WidgetSeparator: View { + var body: some View { + Rectangle() + .fill(Color.primary.opacity(WidgetLayout.separatorOpacity)) + .frame(height: 1) + } +} + +// MARK: - Header + +/// Mark + provider name + freshness. The name is the only text on its line so it never has to +/// compete with the timestamp for space. +struct TileHeader: View { + let provider: ProviderInstanceID + let updatedAt: Date + var size: WidgetTileSize = .medium + + var body: some View { + HStack(spacing: 7) { + if let provider = self.provider.firstPartyProvider { + ProviderMark(provider: provider, isSelected: true, size: WidgetLayout.markSizeSmall) + } + Text(self.displayName) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .layoutPriority(1) + Spacer(minLength: 4) + if self.showsFreshness { + FreshnessLabel(updatedAt: self.updatedAt) + } + } + } + + /// A small tile cannot fit the provider name and the timestamp side by side — the name used to + /// lose, leaving "C…". The name always wins; on small the timestamp appears only once the data + /// is stale enough to matter. + private var showsFreshness: Bool { + self.size != .small || WidgetFreshness.isStale(self.updatedAt) + } + + private var displayName: String { + guard let provider = self.provider.firstPartyProvider else { + return self.provider.rawValue.capitalized + } + return ProviderTitle.text(for: provider, size: self.size) + } +} + +/// Snapshot age. Turns amber past a day so a silently stale tile cannot be mistaken for live data. +struct FreshnessLabel: View { + let updatedAt: Date + + var body: some View { + Text(WidgetFormat.shortRelativeDate(self.updatedAt)) + .font(.caption2) + .foregroundStyle(WidgetFreshness + .isStale(self.updatedAt) ? AnyShapeStyle(Color.orange) : AnyShapeStyle(.secondary)) + .lineLimit(1) + .fixedSize() + } +} + +enum WidgetFreshness { + static let staleThreshold: TimeInterval = 24 * 60 * 60 + + static func isStale(_ updatedAt: Date, now: Date = Date()) -> Bool { + now.timeIntervalSince(updatedAt) > self.staleThreshold + } +} + +// MARK: - Empty state + +struct WidgetEmptyState: View { + let message: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text("Open CodexBar") + .font(.subheadline.weight(.semibold)) + Text(self.message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/Sources/CodexBarWidget/WidgetTilePlan.swift b/Sources/CodexBarWidget/WidgetTilePlan.swift new file mode 100644 index 0000000000..325c74d613 --- /dev/null +++ b/Sources/CodexBarWidget/WidgetTilePlan.swift @@ -0,0 +1,144 @@ +import CodexBarCore +import Foundation + +/// One quota lane after the tile has decided how to present it. +struct WidgetTileLane: Identifiable, Equatable { + let id: String + let title: String + /// Always the remaining share, regardless of the user's "show used" preference. Severity and + /// hero selection are defined on what is left, not on what is displayed. + let remainingPercent: Double? + var resetsAt: Date? + var resetDescription: String? +} + +/// How a tile splits its quota lanes: one headline lane plus the rest. +/// +/// The headline is the *binding* lane — the one with the least left — because that is the number +/// that decides whether the user can keep working. The old tiles gave every lane identical weight, +/// so a lane at 3% and a lane at 97% looked equally routine. +struct WidgetTilePlan: Equatable { + let hero: WidgetTileLane? + let lanes: [WidgetTileLane] + let overflowCount: Int + + static let empty = WidgetTilePlan(hero: nil, lanes: [], overflowCount: 0) + + /// - Parameters: + /// - lanes: every lane the provider reports. The headline is chosen from all of them, so a + /// provider's compact row cap can never hide the lane that is actually binding. + /// - displayCandidates: the lanes a compact tile is allowed to list, already curated by the + /// provider (row caps, Antigravity's one-row-per-model-family selection). Defaults to + /// `lanes` when the tile lists everything. + /// - reservesOverflowRow: when the tile is too tight to absorb the "+N more" line on top of a + /// full lane list, the line takes one of the lane slots instead of overflowing the tile. + /// Measured against `lanes`, since a lane dropped by curation overflows just the same. + static func make( + lanes: [WidgetTileLane], + displayCandidates: [WidgetTileLane]? = nil, + maxSecondaryLanes: Int, + reservesOverflowRow: Bool = false) -> WidgetTilePlan + { + guard let hero = self.bindingLane(in: lanes) else { return .empty } + let candidates = displayCandidates ?? lanes + let remainder = candidates.filter { $0.id != hero.id } + // Honour the provider's intended row count: the headline occupies one of those rows. + var capacity = min(max(0, maxSecondaryLanes), max(0, candidates.count - 1)) + // Give up a lane slot only when the "+N more" line would actually push the tile past its + // budget. Whether that line appears is decided against every lane the provider reports, + // not the curated subset: curation drops lanes that never reach `remainder`, so a curated + // remainder that fits its budget exactly can still overflow. + let listed = min(remainder.count, capacity) + let overflows = (lanes.count - 1) - listed > 0 + if reservesOverflowRow, listed + (overflows ? 1 : 0) > maxSecondaryLanes { + capacity = max(1, capacity - 1) + } + let shown = Array(remainder.prefix(capacity)) + // Counted against every lane the provider reports, not just the curated subset, so a lane + // dropped by the cap is still surfaced. + return WidgetTilePlan( + hero: hero, + lanes: shown, + overflowCount: max(0, lanes.count - 1 - shown.count)) + } + + /// Lowest remaining share wins; ties keep the provider's own ordering. Lanes without a + /// percentage can only become the headline when nothing else can. + private static func bindingLane(in lanes: [WidgetTileLane]) -> WidgetTileLane? { + let measured = lanes.filter { $0.remainingPercent != nil } + guard !measured.isEmpty else { return lanes.first } + return measured.min { lhs, rhs in + (lhs.remainingPercent ?? 0) < (rhs.remainingPercent ?? 0) + } + } +} + +extension WidgetTileLane { + init(row: WidgetUsageRow) { + self.init( + id: row.id, + title: row.title, + remainingPercent: row.percentLeft, + resetsAt: row.resetsAt, + resetDescription: row.resetDescription) + } + + /// Every quota lane a tile can draw for this entry: the provider's usage rows plus the + /// separately-tracked code review lane. + static func lanes(for entry: WidgetSnapshot.ProviderEntry, limit: Int? = nil) -> [WidgetTileLane] { + var lanes = WidgetUsageRow.rows(for: entry, limit: limit).map(WidgetTileLane.init(row:)) + if let codeReview = entry.codeReviewRemainingPercent { + lanes.append(WidgetTileLane( + id: "code-review", + title: "Code review", + remainingPercent: codeReview)) + } + return lanes + } +} + +// MARK: - Lane copy + +enum WidgetLaneCopy { + /// "Weekly left" / "Weekly used" — the bare percentages the tiles used to show never said + /// which of the two the user was looking at, and the preference silently flips it. + static func caption(title: String, showUsed: Bool) -> String { + "\(title) \(showUsed ? "used" : "left")" + } + + /// Compact, human reset text. Counts down from the reset date when there is one — that form is + /// short and predictable — and falls back to the provider's own wording. Returns nil once the + /// reset is in the past or unknown. + static func resetText( + resetsAt: Date?, + resetDescription: String?, + now: Date = Date()) -> String? + { + if let resetsAt { + // A known reset that has already passed means the snapshot is stale. Say nothing rather + // than falling through to cached wording that would still read "Resets in 4h". + let interval = resetsAt.timeIntervalSince(now) + guard interval > 0 else { return nil } + return "Resets in \(self.duration(interval))" + } + // Provider wording is the fallback. Some providers emit a bare timestamp + // ("tomorrow, 12:28 PM") that says nothing about what the time refers to, so label it. + guard let text = resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + else { return nil } + return text.lowercased().hasPrefix("reset") ? text : "Resets \(text)" + } + + /// Rounds to the nearest whole unit rather than truncating: 47h59m is "2d" to a reader, not + /// the "1d" that flooring produces. + static func duration(_ interval: TimeInterval) -> String { + if interval < 3600 { + return "\(max(1, Int((interval / 60).rounded())))m" + } + if interval < 86400 { + let hours = Int((interval / 3600).rounded()) + return hours >= 24 ? "1d" : "\(max(1, hours))h" + } + return "\(max(1, Int((interval / 86400).rounded())))d" + } +} diff --git a/Sources/CodexBarWidget/WidgetTiles.swift b/Sources/CodexBarWidget/WidgetTiles.swift new file mode 100644 index 0000000000..0a610eba03 --- /dev/null +++ b/Sources/CodexBarWidget/WidgetTiles.swift @@ -0,0 +1,356 @@ +import CodexBarCore +import SwiftUI + +/// The three macOS tile shapes, decoupled from `WidgetFamily` so layouts can be rendered and +/// reasoned about outside a widget host. +enum WidgetTileSize { + case small + case medium + case large + + var heroNumberSize: CGFloat { + switch self { + case .small: 30 + case .medium: 34 + case .large: 42 + } + } + + var markSize: CGFloat { + self == .small ? WidgetLayout.markSizeSmall : WidgetLayout.markSizeRegular + } + + /// How many lanes fit below the headline before the tile starts clipping. + /// + /// A cost block costs about one lane of height and the history chart about two, so the budget + /// has to know what else the tile is drawing — a fixed cap either clips a busy provider or + /// pages a sparse one for no reason. + func secondaryLaneCapacity(hasMetrics: Bool, hasChart: Bool) -> Int { + switch self { + case .small: + return 2 + case .medium: + return hasMetrics ? 2 : 3 + case .large: + var capacity = 6 + if hasChart { capacity -= 2 } + if hasMetrics { capacity -= 2 } + return max(1, capacity) + } + } + + /// Small tiles list the remaining lanes as figures only: at 127pt of usable height a 7pt bar + /// costs as much room as the number it duplicates. + var showsSecondaryBars: Bool { + self != .small + } + + /// Large tiles carry the history chart, but only while the quota lanes leave it enough room to + /// be a chart: squeezed to a few points it is noise, and the dedicated History widget covers + /// providers with more lanes than that. + func showsChart(laneCount: Int, hasHistory: Bool) -> Bool { + self == .large && hasHistory && laneCount <= 3 + } + + var laneSpacing: CGFloat { + self == .small ? 5 : WidgetLayout.laneSpacing + } +} + +// MARK: - Tile + +/// Shared body for the usage and switcher widgets across all three families. +/// +/// One layout means the two widgets cannot drift apart, and every size difference is a value in +/// `WidgetTileSize` rather than a separate near-duplicate view. +struct UsageTile: View { + @Environment(\.widgetUsageShowsUsed) private var showsUsed + let entry: WidgetSnapshot.ProviderEntry + let size: WidgetTileSize + @ViewBuilder let header: () -> Header + + var body: some View { + let color = WidgetColors.color(for: self.entry.provider) + // The provider's compact row cap curates what a tile may LIST; it must not decide which + // lane is binding. Headline and overflow are computed against the full set. + let allLanes = WidgetTileLane.lanes(for: self.entry) + let displayLanes = self.laneLimit == nil + ? allLanes + : WidgetTileLane.lanes(for: self.entry, limit: self.laneLimit) + let hasChart = self.size.showsChart( + laneCount: allLanes.count, + hasHistory: !self.entry.dailyUsage.isEmpty) + let fallback = allLanes.isEmpty ? WidgetFallbackHero.make(for: self.entry) : nil + let metrics = WidgetMetricRows.rows( + for: self.entry, + size: self.size, + secondaryLaneCount: max(0, allLanes.count - 1), + excluding: fallback?.consumedMetricID) + let plan = WidgetTilePlan.make( + lanes: allLanes, + displayCandidates: displayLanes, + maxSecondaryLanes: self.size.secondaryLaneCapacity( + hasMetrics: !metrics.isEmpty, + hasChart: hasChart), + reservesOverflowRow: self.size != .large) + + VStack(alignment: .leading, spacing: WidgetLayout.sectionSpacing) { + self.header() + // Medium splits into columns only when there are lanes to fill the right one. A + // single-lane provider in two columns leaves a void beside the headline; down one + // column it gets the full width for its bar instead. + if self.size == .medium, !plan.lanes.isEmpty { + HStack(alignment: .top, spacing: 14) { + self.hero(plan: plan, fallback: fallback, color: color, spreads: !metrics.isEmpty) + .frame(width: 104, alignment: .leading) + VStack(alignment: .leading, spacing: WidgetLayout.laneSpacing) { + self.lanes(plan: plan, color: color) + if !metrics.isEmpty { + Spacer(minLength: 2) + self.metrics(metrics) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame(maxHeight: .infinity) + } else { + self.hero(plan: plan, fallback: fallback, color: color, spreads: false) + VStack(alignment: .leading, spacing: self.size.laneSpacing) { + self.lanes(plan: plan, color: color) + } + self.metrics(metrics) + if hasChart { + UsageHistoryChart( + points: self.entry.dailyUsage, + color: color, + currencyCode: self.entry.tokenUsage?.currencyCode) + .frame(minHeight: 66, maxHeight: .infinity) + } else { + Spacer(minLength: 0) + } + } + } + .padding(WidgetLayout.tilePadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Providers cap how many lanes a compact tile may show; large tiles take everything and page + /// the excess behind a "+N more" line. + private var laneLimit: Int? { + switch self.size { + case .small: WidgetUsageRow.smallWidgetRowLimit(for: self.entry) + case .medium: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry) + case .large: nil + } + } + + // MARK: Headline + + @ViewBuilder + private func hero( + plan: WidgetTilePlan, + fallback: WidgetFallbackHeroContent?, + color: Color, + spreads: Bool) -> some View + { + if let lane = plan.hero { + let displayed = WidgetUsageDisplay.percent( + fromRemaining: lane.remainingPercent, + showUsed: self.showsUsed) + HeroBlock( + value: WidgetFormat.percent(displayed), + caption: WidgetLaneCopy.caption(title: lane.title, showUsed: self.showsUsed), + detail: WidgetLaneCopy.resetText( + resetsAt: lane.resetsAt, + resetDescription: lane.resetDescription), + barPercent: displayed ?? 0, + isLow: QuotaSeverity.isLow(remaining: lane.remainingPercent), + color: color, + numberSize: self.size.heroNumberSize, + spreads: spreads) + } else if let fallback { + HeroBlock( + value: fallback.value, + caption: fallback.caption, + detail: fallback.detail, + numberSize: self.size.heroNumberSize) + } + } + + // MARK: Lanes + + @ViewBuilder + private func lanes(plan: WidgetTilePlan, color: Color) -> some View { + ForEach(plan.lanes) { lane in + QuotaLaneView( + title: lane.title, + percent: WidgetUsageDisplay.percent( + fromRemaining: lane.remainingPercent, + showUsed: self.showsUsed), + remainingPercent: lane.remainingPercent, + color: color, + showsBar: self.size.showsSecondaryBars) + } + if plan.overflowCount > 0 { + Text("+\(plan.overflowCount) more") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + // MARK: Cost and credit rows + + @ViewBuilder + private func metrics(_ lines: [WidgetMetricRow]) -> some View { + if !lines.isEmpty { + VStack(alignment: .leading, spacing: 4) { + if self.size != .small { + WidgetSeparator().padding(.bottom, 2) + } + ForEach(lines) { line in + MetricLine(title: line.title, value: line.value, isProminent: line.isProminent) + } + } + } + } +} + +// MARK: - Metric rows + +struct WidgetMetricRow: Identifiable, Equatable { + let id: String + let title: String + let value: String + var isProminent: Bool = false +} + +enum WidgetMetricRows { + /// Cost, credit and balance lines, trimmed to what each tile can show without clipping. + static func rows( + for entry: WidgetSnapshot.ProviderEntry, + size: WidgetTileSize, + secondaryLaneCount: Int = 0, + excluding excludedID: String? = nil) -> [WidgetMetricRow] + { + let rows = self.allRows(for: entry, size: size, secondaryLaneCount: secondaryLaneCount) + .filter { $0.id != excludedID } + return Array(rows.prefix(self.rowCapacity(for: size))) + } + + /// Hard ceiling per tile so an unusual provider (credits *and* costs *and* a balance) cannot + /// push the lanes off the tile. + private static func rowCapacity(for size: WidgetTileSize) -> Int { + switch size { + case .small: 1 + case .medium: 2 + case .large: 3 + } + } + + private static func allRows( + for entry: WidgetSnapshot.ProviderEntry, + size: WidgetTileSize, + secondaryLaneCount: Int) -> [WidgetMetricRow] + { + var rows: [WidgetMetricRow] = [] + if size == .small { + // A small tile spends its height on quota lanes. With none below the headline the room + // is there, so a single-lane provider gets its cost line instead of empty space. + guard secondaryLaneCount == 0, + let token = entry.tokenUsage ?? WidgetUsageRow.compactTokenUsage(for: entry) + else { + return self.balanceRows(for: entry) + } + rows.append(self.sessionRow(token: token, entry: entry, isProminent: true, compact: true)) + return rows + self.balanceRows(for: entry) + } + + if let credits = entry.creditsRemaining { + rows.append(WidgetMetricRow(id: "credits", title: "Credits", value: WidgetFormat.credits(credits))) + } + if let token = entry.tokenUsage { + rows.append(self.sessionRow(token: token, entry: entry, isProminent: true)) + if size == .large { + rows.append(WidgetMetricRow( + id: "last30", + title: WidgetFormat.tokenRowTitle( + token.last30DaysLabel, + summary: token, + entryUpdatedAt: entry.updatedAt), + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode))) + } + } + return rows + self.balanceRows(for: entry) + } + + private static func sessionRow( + token: WidgetSnapshot.TokenUsageSummary, + entry: WidgetSnapshot.ProviderEntry, + isProminent: Bool, + compact: Bool = false) -> WidgetMetricRow + { + // "$1.56 · 1.1M tokens" does not fit beside its label on a 127pt tile — the label loses and + // renders as "T…". Small tiles carry the money only. + let value = compact + ? (token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } + ?? WidgetFormat.unavailable) + : WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode) + return WidgetMetricRow( + id: "session-cost", + title: WidgetFormat.tokenRowTitle( + token.sessionLabel, + summary: token, + entryUpdatedAt: entry.updatedAt), + value: value, + isProminent: isProminent) + } + + private static func balanceRows(for entry: WidgetSnapshot.ProviderEntry) -> [WidgetMetricRow] { + guard let balance = WidgetBalanceFormatter.extraUsageBalance(for: entry) else { return [] } + return [WidgetMetricRow(id: "extra-usage", title: balance.title, value: balance.value)] + } +} + +// MARK: - Headline fallback + +struct WidgetFallbackHeroContent: Equatable { + let value: String + let caption: String + let detail: String? + /// The metric row this headline was built from. The tile drops it from the list below so the + /// same figure is not printed twice. + let consumedMetricID: String +} + +enum WidgetFallbackHero { + /// Balance-only and credit-only providers report no quota lanes, so the headline falls back to + /// the figure those tiles do have instead of leaving the tile blank. + static func make(for entry: WidgetSnapshot.ProviderEntry) -> WidgetFallbackHeroContent? { + if let cost = WidgetBalanceFormatter.extraUsageCost(for: entry) { + return WidgetFallbackHeroContent( + value: WidgetFormat.currency(cost.used, code: cost.currencyCode), + caption: "Extra usage balance", + detail: nil, + consumedMetricID: "extra-usage") + } + if let credits = entry.creditsRemaining { + return WidgetFallbackHeroContent( + value: WidgetFormat.credits(credits), + caption: "Credits left", + detail: nil, + consumedMetricID: "credits") + } + guard let token = entry.tokenUsage, let cost = token.sessionCostUSD else { return nil } + return WidgetFallbackHeroContent( + value: WidgetFormat.currency(cost, code: token.currencyCode), + caption: token.sessionLabel, + detail: token.sessionTokens.map(WidgetFormat.tokenCount), + consumedMetricID: "session-cost") + } +} diff --git a/Tests/CodexBarTests/WidgetProviderPagerTests.swift b/Tests/CodexBarTests/WidgetProviderPagerTests.swift new file mode 100644 index 0000000000..3d9e971832 --- /dev/null +++ b/Tests/CodexBarTests/WidgetProviderPagerTests.swift @@ -0,0 +1,209 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +import WidgetKit +@testable import CodexBarWidget + +@Suite("Widget provider pager") +struct WidgetProviderPagerTests { + private let providers: [UsageProvider] = [.codex, .claude, .cursor, .gemini, .copilot] + + // MARK: - Paging + + @Test + func `paging reports the position and both neighbours`() { + let pager = ProviderPager.make(providers: self.providers, selected: .cursor) + + #expect(pager?.previous == .claude) + #expect(pager?.next == .gemini) + #expect(pager?.positionText == "3/5") + #expect(pager?.isPageable == true) + } + + @Test + func `paging wraps around at both ends`() { + let first = ProviderPager.make(providers: self.providers, selected: .codex) + let last = ProviderPager.make(providers: self.providers, selected: .copilot) + + #expect(first?.previous == .copilot) + #expect(last?.next == .codex) + } + + @Test + func `a single provider is not pageable`() { + let pager = ProviderPager.make(providers: [.codex], selected: .codex) + + #expect(pager?.isPageable == false) + #expect(pager?.positionText == "1/1") + } + + @Test + func `an unknown selection falls back to the first provider`() { + let pager = ProviderPager.make(providers: self.providers, selected: .devin) + + #expect(pager?.selected == .codex) + #expect(pager?.position == 1) + } + + @Test + func `an empty provider list has no pager`() { + #expect(ProviderPager.make(providers: [], selected: .codex) == nil) + } + + @Test + func `paging visits every provider exactly once before returning`() { + var seen: [UsageProvider] = [] + var current = self.providers[0] + for _ in self.providers.indices { + seen.append(current) + current = ProviderPager.make(providers: self.providers, selected: current)?.next ?? current + } + + #expect(seen == self.providers) + #expect(current == self.providers[0]) + } + + // MARK: - Marks + + @Test + func `the providers that used to collide under truncation no longer do`() { + // "Co…", "Cl…", "Cu…" and a bare "C" were all the old chips could show. + #expect(ProviderMonogram.text(for: .codex) == "CX") + #expect(ProviderMonogram.text(for: .claude) == "CL") + #expect(ProviderMonogram.text(for: .cursor) == "CU") + #expect(ProviderMonogram.text(for: .copilot) == "CP") + } + + @Test + func `the widget selectable providers all get distinct marks`() { + let providers: [UsageProvider] = [ + .codex, .claude, .cursor, .copilot, .gemini, .antigravity, .devin, .kilo, .kimi, + .minimax, .mistral, .opencode, .opencodego, .qwencloud, .zai, .alibaba, .alibabatokenplan, + ] + + let marks = providers.map(ProviderMonogram.text(for:)) + + #expect(Set(marks).count == marks.count) + #expect(marks.allSatisfy { $0.count == 2 }) + } + + @Test + func `uncurated providers fall back to derived initials`() { + #expect(ProviderMonogram.derived(from: "Token Plan") == "TP") + #expect(ProviderMonogram.derived(from: "Warp") == "WA") + #expect(ProviderMonogram.derived(from: "z.ai / GLM") == "ZA") + #expect(ProviderMonogram.derived(from: "X") == "XX") + #expect(ProviderMonogram.derived(from: "") == "?") + } + + @Test + func `every provider has a mark`() { + for provider in UsageProvider.allCases { + #expect(!ProviderMonogram.text(for: provider).isEmpty) + } + } + + // MARK: - Titles + + @Test + func `small tiles swap a long provider name for its short form instead of truncating`() { + #expect(ProviderTitle.text(for: .alibabatokenplan, size: .medium) == "Alibaba Token Plan") + #expect(ProviderTitle.text(for: .alibabatokenplan, size: .small) == "Token Plan") + } + + @Test + func `names that already fit are never shortened`() { + for size in [WidgetTileSize.small, .medium, .large] { + #expect(ProviderTitle.text(for: .claude, size: size) == "Claude") + #expect(ProviderTitle.text(for: .antigravity, size: size) == "Antigravity") + } + } + + @Test + func `every provider title fits the compact budget on a small tile`() { + for provider in UsageProvider.allCases where ProviderChoice(provider: provider) != nil { + let title = ProviderTitle.text(for: provider, size: .small) + #expect( + title.count <= ProviderTitle.compactCharacterBudget, + "\(provider.rawValue) renders as \"\(title)\"") + } + } + + // MARK: - Unavailable metrics + + @Test + func `a metric a provider never reports explains itself instead of showing a bare dash`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let display = CompactMetricFormatter.display(for: entry, metric: .credits) + + #expect(display.value == WidgetFormat.unavailable) + #expect(display.detail == "Not reported by Claude") + } + + @Test + func `a reported metric carries no unavailable note`() { + #expect(CompactMetricFormatter.unavailableDetail( + value: "1,243.4", + entry: WidgetSnapshot.ProviderEntry( + provider: .codex, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + primary: nil, + secondary: nil, + tertiary: nil, + creditsRemaining: 1243.4, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: [])) == nil) + } + + // MARK: - Tinted and clear appearances + + @Test + func `outside full colour the mark inverts so the monogram survives the luminance mask`() { + // Desktop widgets in tinted/clear render through a luminance mask. A brand fill carrying a + // dark label collapses to an empty white square, which is what shipped first. + for mode in [WidgetRenderingMode.vibrant, .accented] { + let selected = ProviderMarkStyle.resolve(renderingMode: mode, provider: .claude, isSelected: true) + let unselected = ProviderMarkStyle.resolve(renderingMode: mode, provider: .claude, isSelected: false) + + #expect(selected.foreground == Color.primary) + #expect(selected.background != Color.primary) + #expect(unselected.foreground != unselected.background) + } + } + + @Test + func `full colour keeps the brand fill and its contrast picked label`() { + let style = ProviderMarkStyle.resolve(renderingMode: .fullColor, provider: .claude, isSelected: true) + + #expect(style.background == WidgetColors.color(for: ProviderInstanceID.claude)) + #expect(style.foreground == .black) + } + + // MARK: - Contrast + + @Test + func `every provider mark label is the higher contrast choice`() { + for provider in UsageProvider.allCases { + guard let color = ProviderBrandColor.resolve(for: provider.instanceID) else { continue } + let luminance = WidgetContrast.relativeLuminance(of: color) + let whiteContrast = 1.05 / (luminance + 0.05) + let blackContrast = (luminance + 0.05) / 0.05 + let expected = blackContrast >= whiteContrast ? "black" : "white" + let actual = WidgetContrast.label(on: color) == .black ? "black" : "white" + + #expect(actual == expected, "\(provider.rawValue) picked \(actual)") + } + } +} diff --git a/Tests/CodexBarTests/WidgetTileLayoutTests.swift b/Tests/CodexBarTests/WidgetTileLayoutTests.swift new file mode 100644 index 0000000000..b9ba3f09e8 --- /dev/null +++ b/Tests/CodexBarTests/WidgetTileLayoutTests.swift @@ -0,0 +1,401 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarWidget + +@Suite("Widget tile layout") +struct WidgetTileLayoutTests { + // MARK: - Headline selection + + private func lane(_ id: String, _ title: String, _ remaining: Double?) -> WidgetTileLane { + WidgetTileLane(id: id, title: title, remainingPercent: remaining) + } + + @Test + func `headline is the lane with the least left`() { + let plan = WidgetTilePlan.make( + lanes: [self.lane("a", "Session", 100), self.lane("b", "Weekly", 41), self.lane("c", "Opus", 77)], + maxSecondaryLanes: 5) + + #expect(plan.hero?.id == "b") + #expect(plan.lanes.map(\.id) == ["a", "c"]) + #expect(plan.overflowCount == 0) + } + + @Test + func `headline ties keep the provider ordering`() { + let plan = WidgetTilePlan.make( + lanes: [self.lane("a", "Session", 50), self.lane("b", "Weekly", 50)], + maxSecondaryLanes: 5) + + #expect(plan.hero?.id == "a") + } + + @Test + func `lanes without a percentage never displace a measured headline`() { + let plan = WidgetTilePlan.make( + lanes: [self.lane("a", "Unknown", nil), self.lane("b", "Weekly", 88)], + maxSecondaryLanes: 5) + + #expect(plan.hero?.id == "b") + #expect(plan.lanes.map(\.id) == ["a"]) + } + + @Test + func `an unmeasured lane still becomes the headline when nothing else can`() { + let plan = WidgetTilePlan.make(lanes: [self.lane("a", "Unknown", nil)], maxSecondaryLanes: 5) + + #expect(plan.hero?.id == "a") + } + + @Test + func `no lanes yields an empty plan`() { + #expect(WidgetTilePlan.make(lanes: [], maxSecondaryLanes: 5) == .empty) + } + + @Test + func `lanes beyond the cap are counted rather than dropped silently`() { + let lanes = (0..<8).map { self.lane("lane-\($0)", "Lane \($0)", Double(50 + $0)) } + + let plan = WidgetTilePlan.make(lanes: lanes, maxSecondaryLanes: 3) + + #expect(plan.hero?.id == "lane-0") + #expect(plan.lanes.count == 3) + #expect(plan.overflowCount == 4) + } + + @Test + func `reserving a slot for the overflow row keeps compact tiles inside their budget`() { + let lanes = (0..<6).map { self.lane("lane-\($0)", "Lane \($0)", Double(50 + $0)) } + + let reserved = WidgetTilePlan.make(lanes: lanes, maxSecondaryLanes: 2, reservesOverflowRow: true) + let unreserved = WidgetTilePlan.make(lanes: lanes, maxSecondaryLanes: 2) + + #expect(reserved.lanes.count == 1) + #expect(reserved.overflowCount == 4) + #expect(unreserved.lanes.count == 2) + #expect(unreserved.overflowCount == 3) + } + + @Test + func `reserving a slot accounts for lanes the curation already dropped`() { + // Kimi reports four lanes but caps compact tiles at three. When the binding lane is one of + // those three, the curated remainder fits the budget exactly, so no slot was reserved — + // yet a fourth lane still overflows and "+N more" was drawn as an extra row on top of a + // full lane list. Reservation has to count every omitted lane, not just the curated ones. + let all = [ + self.lane("primary", "Session", 4), + self.lane("secondary", "Weekly", 61), + self.lane("kimi-monthly", "Monthly", 72), + self.lane("kimi-code-7d", "Code 7d", 88), + ] + let curated = Array(all.prefix(3)) + + let plan = WidgetTilePlan.make( + lanes: all, + displayCandidates: curated, + maxSecondaryLanes: 2, + reservesOverflowRow: true) + + #expect(plan.hero?.id == "primary") + #expect(plan.overflowCount > 0) + // The headline sits above the budget; the listed lanes plus the "+N more" line share it. + #expect(plan.lanes.count + 1 <= 2) + } + + @Test + func `reserving a slot does nothing when every lane already fits`() { + let lanes = (0..<3).map { self.lane("lane-\($0)", "Lane \($0)", Double(50 + $0)) } + + let plan = WidgetTilePlan.make(lanes: lanes, maxSecondaryLanes: 2, reservesOverflowRow: true) + + #expect(plan.lanes.count == 2) + #expect(plan.overflowCount == 0) + } + + @Test + func `reservation costs a lane only when the overflow line would not fit`() { + // Same four-lane curated shape as above, one row of budget wider. Here the two listed + // lanes and the "+N more" line already fit, so reserving would drop a lane the tile has + // room for. Reservation is about staying inside the budget, not about the mere presence + // of an overflow line. + let all = [ + self.lane("primary", "Session", 4), + self.lane("secondary", "Weekly", 61), + self.lane("kimi-monthly", "Monthly", 72), + self.lane("kimi-code-7d", "Code 7d", 88), + ] + + let plan = WidgetTilePlan.make( + lanes: all, + displayCandidates: Array(all.prefix(3)), + maxSecondaryLanes: 3, + reservesOverflowRow: true) + + #expect(plan.lanes.count == 2) + #expect(plan.overflowCount == 1) + } + + @Test + func `no reserving budget ever renders more rows than it has`() { + // Curation, reservation and headline selection interact, and the focused tests cover them + // one at a time — which is exactly how the curated-remainder case slipped through. Sweeping + // the combinations is what catches the intersections. Budgets start at two because that is + // the smallest a reserving tile asks for (`WidgetTileSize.secondaryLaneCapacity` returns 2 + // or 3 for small and medium); large tiles take everything and do not reserve. + for laneCount in 1...6 { + for curatedCount in 1...laneCount { + for budget in 2...4 { + for constrainedIndex in 0.. 0 ? 1 : 0) + #expect(rows <= budget, "\(shape) rendered \(rows) rows") + // Curation must never bury the lane that is actually binding. + #expect(plan.hero?.id == "lane-\(constrainedIndex)", "\(shape)") + } + } + } + } + } + + @Test + func `a provider row cap never hides the binding lane`() { + // Kimi writes primary/secondary plus kimi-monthly and kimi-code-7d, but caps compact tiles + // at three rows. Truncating before the headline was chosen dropped a 1%-left lane and left + // overflowCount at zero, so the tile gave no sign the binding quota was missing. + let all = [ + self.lane("primary", "Session", 88), + self.lane("secondary", "Weekly", 74), + self.lane("kimi-monthly", "Monthly", 60), + self.lane("kimi-code-7d", "Code 7d", 1), + ] + let curated = Array(all.prefix(3)) + + let plan = WidgetTilePlan.make(lanes: all, displayCandidates: curated, maxSecondaryLanes: 2) + + #expect(plan.hero?.id == "kimi-code-7d") + #expect(plan.overflowCount == all.count - 1 - plan.lanes.count) + #expect(plan.overflowCount > 0) + } + + @Test + func `the curated row count is honoured once the headline takes a row`() { + let all = (0..<6).map { self.lane("lane-\($0)", "Lane \($0)", Double(50 + $0)) } + let curated = Array(all.prefix(3)) + + let plan = WidgetTilePlan.make(lanes: all, displayCandidates: curated, maxSecondaryLanes: 5) + + // Headline plus listed lanes must not exceed what the provider curated. + #expect(1 + plan.lanes.count <= curated.count) + #expect(plan.overflowCount == all.count - 1 - plan.lanes.count) + } + + @Test + func `without a provider cap every lane is still accounted for`() { + let all = (0..<4).map { self.lane("lane-\($0)", "Lane \($0)", Double(50 + $0)) } + + let plan = WidgetTilePlan.make(lanes: all, maxSecondaryLanes: 5) + + #expect(plan.hero?.id == "lane-0") + #expect(plan.lanes.count == 3) + #expect(plan.overflowCount == 0) + } + + // MARK: - Lane capacity + + @Test + func `large tiles trade lane slots for the cost block and the chart`() { + #expect(WidgetTileSize.large.secondaryLaneCapacity(hasMetrics: false, hasChart: false) == 6) + #expect(WidgetTileSize.large.secondaryLaneCapacity(hasMetrics: true, hasChart: false) == 4) + #expect(WidgetTileSize.large.secondaryLaneCapacity(hasMetrics: true, hasChart: true) == 2) + #expect(WidgetTileSize.medium.secondaryLaneCapacity(hasMetrics: true, hasChart: false) == 2) + #expect(WidgetTileSize.medium.secondaryLaneCapacity(hasMetrics: false, hasChart: false) == 3) + } + + @Test + func `the chart yields to the lanes once a provider reports more than three`() { + #expect(WidgetTileSize.large.showsChart(laneCount: 3, hasHistory: true)) + #expect(!WidgetTileSize.large.showsChart(laneCount: 4, hasHistory: true)) + #expect(!WidgetTileSize.large.showsChart(laneCount: 2, hasHistory: false)) + #expect(!WidgetTileSize.medium.showsChart(laneCount: 1, hasHistory: true)) + } + + // MARK: - Lane copy + + @Test + func `lane captions state whether the figure is remaining or used`() { + #expect(WidgetLaneCopy.caption(title: "Weekly", showUsed: false) == "Weekly left") + #expect(WidgetLaneCopy.caption(title: "Weekly", showUsed: true) == "Weekly used") + } + + @Test + func `reset text rounds to the nearest whole unit`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + func text(_ seconds: TimeInterval) -> String? { + WidgetLaneCopy.resetText(resetsAt: now.addingTimeInterval(seconds), resetDescription: nil, now: now) + } + + #expect(text(45 * 60) == "Resets in 45m") + #expect(text(3 * 3600) == "Resets in 3h") + // 47h59m is two days to a reader; flooring used to render it as "1d". + #expect(text(48 * 3600 - 60) == "Resets in 2d") + #expect(text(9 * 86400) == "Resets in 9d") + } + + @Test + func `a known reset date wins over provider wording`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + // The compact countdown is predictable in width; provider wording is not. + #expect(WidgetLaneCopy.resetText( + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets Monday", + now: now) == "Resets in 1h") + } + + @Test + func `bare provider timestamps get labelled so they are not mistaken for data`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + // Codex emits "tomorrow, 12:28 PM", which says nothing on its own. + #expect(WidgetLaneCopy.resetText( + resetsAt: nil, + resetDescription: "tomorrow, 12:28 PM", + now: now) == "Resets tomorrow, 12:28 PM") + // Wording that already says "Resets" is passed through untouched. + #expect(WidgetLaneCopy.resetText( + resetsAt: nil, + resetDescription: "Resets Monday", + now: now) == "Resets Monday") + } + + @Test + func `a known reset that already passed renders nothing, even with cached wording`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + // A stale snapshot would otherwise keep showing "Resets in 4h" after the reset happened. + #expect(WidgetLaneCopy.resetText( + resetsAt: now.addingTimeInterval(-60), + resetDescription: "Resets in 4h", + now: now) == nil) + } + + @Test + func `expired and unknown resets render nothing`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + #expect(WidgetLaneCopy.resetText( + resetsAt: now.addingTimeInterval(-60), + resetDescription: nil, + now: now) == nil) + #expect(WidgetLaneCopy.resetText(resetsAt: nil, resetDescription: " ", now: now) == nil) + } + + // MARK: - Freshness + + @Test + func `a snapshot from moments ago reads as Now, never as a future time`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + // Gallery placeholders stamp updatedAt with the render time; the formatter turns that + // instant into "in 0s". + #expect(WidgetFormat.shortRelativeDate(now, relativeTo: now) == "Now") + #expect(WidgetFormat.shortRelativeDate(now.addingTimeInterval(30), relativeTo: now) == "Now") + #expect(WidgetFormat.shortRelativeDate(now.addingTimeInterval(-30), relativeTo: now) == "Now") + #expect(WidgetFormat.shortRelativeDate(now.addingTimeInterval(-3600), relativeTo: now) != "Now") + } + + @Test + func `staleness only trips past a day`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + #expect(!WidgetFreshness.isStale(now.addingTimeInterval(-3600), now: now)) + #expect(WidgetFreshness.isStale(now.addingTimeInterval(-5 * 86400), now: now)) + } + + // MARK: - Reset metadata recovery + + @Test + func `generic provider rows recover their reset from the entry windows`() { + // The generic snapshot writer stores only percentLeft for primary/secondary/tertiary and + // leaves the reset on the entry's own windows, so the caption used to appear for Codex and + // silently vanish for Claude, Gemini and Alibaba. + let reset = Date(timeIntervalSince1970: 1_800_003_600) + let entry = WidgetSnapshot.ProviderEntry( + provider: .claude, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: reset, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: 10080, resetsAt: reset, resetDescription: nil), + tertiary: nil, + usageRows: [ + .init(id: "primary", title: "Session", percentLeft: 90), + .init(id: "secondary", title: "Weekly", percentLeft: 60), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let rows = WidgetUsageRow.rows(for: entry) + + #expect(rows.map(\.resetsAt) == [reset, reset]) + // The row's own percentage stays authoritative. + #expect(rows.map(\.percentLeft) == [90, 60]) + } + + @Test + func `slot recovery does not invent resets for provider specific rows`() { + let entry = WidgetSnapshot.ProviderEntry( + provider: .kimi, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: Date(timeIntervalSince1970: 1_800_003_600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + usageRows: nil, + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + #expect(WidgetUsageRow.slotWindow(for: "kimi-code-7d", entry: entry) == nil) + #expect(WidgetUsageRow.slotWindow(for: "primary", entry: entry) != nil) + } + + // MARK: - Severity + + @Test + func `severity tracks what is left, not what is displayed`() { + #expect(QuotaSeverity.isLow(remaining: 10)) + #expect(QuotaSeverity.isLow(remaining: 0)) + #expect(!QuotaSeverity.isLow(remaining: 10.1)) + #expect(!QuotaSeverity.isLow(remaining: nil)) + } + + @Test + func `a nearly exhausted bar keeps a visible fill`() { + #expect(QuotaBar.fillFraction(0) == 0) + #expect(QuotaBar.fillFraction(nil) == 0) + #expect(QuotaBar.fillFraction(50) == 0.5) + #expect(QuotaBar.fillFraction(140) == 1) + #expect(QuotaBar.fillFraction(-5) == 0) + } +} diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index ae91927b36..3402738fb4 100644 --- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */; }; + 175A7C99A91EF7F8A1CE1D1D /* ProviderPager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC67679EC16B9F4CFA4F655E /* ProviderPager.swift */; }; + 2ECED44F30B8E37A810D9E85 /* WidgetTiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E08AC49025047573DE3C02D /* WidgetTiles.swift */; }; + 309CF206BD2410BDE81AA129 /* WidgetTilePlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2927E99B587AD27CAD8C4F62 /* WidgetTilePlan.swift */; }; 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */; }; 6AE8A91F50B5CE058EC3F7C3 /* BurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */; }; 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E430B27E4F28973A5E77EA3F /* WidgetKit.framework */; }; @@ -16,16 +19,21 @@ 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F15F392AF9D502F0503F8F /* SwiftUI.framework */; }; 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */; }; D1B3B06F03A2F05251AC0B42 /* BurnDownWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */; }; + D77F3517B304CA761FD1C3D5 /* WidgetDesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 868DBC9298F9D17DEB383032 /* WidgetDesignSystem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedBurnDownWidgetViews.swift; sourceTree = ""; }; + 2927E99B587AD27CAD8C4F62 /* WidgetTilePlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTilePlan.swift; sourceTree = ""; }; + 4E08AC49025047573DE3C02D /* WidgetTiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTiles.swift; sourceTree = ""; }; 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; + 868DBC9298F9D17DEB383032 /* WidgetDesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDesignSystem.swift; sourceTree = ""; }; A57F0B3D6EEDEFD75EE3F3A0 /* BurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetViews.swift; sourceTree = ""; }; B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetProvider.swift; sourceTree = ""; }; + CC67679EC16B9F4CFA4F655E /* ProviderPager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderPager.swift; sourceTree = ""; }; E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -70,6 +78,10 @@ 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */, 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */, 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */, + CC67679EC16B9F4CFA4F655E /* ProviderPager.swift */, + 868DBC9298F9D17DEB383032 /* WidgetDesignSystem.swift */, + 2927E99B587AD27CAD8C4F62 /* WidgetTilePlan.swift */, + 4E08AC49025047573DE3C02D /* WidgetTiles.swift */, ); name = CodexBarWidget; path = ../Sources/CodexBarWidget; @@ -158,6 +170,10 @@ 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */, 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */, 795FB218DC9B1C0909B4D202 /* CombinedBurnDownWidgetViews.swift in Sources */, + 175A7C99A91EF7F8A1CE1D1D /* ProviderPager.swift in Sources */, + D77F3517B304CA761FD1C3D5 /* WidgetDesignSystem.swift in Sources */, + 309CF206BD2410BDE81AA129 /* WidgetTilePlan.swift in Sources */, + 2ECED44F30B8E37A810D9E85 /* WidgetTiles.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/docs/screenshots/widget-redesign-before-after.png b/docs/screenshots/widget-redesign-before-after.png new file mode 100644 index 0000000000..75afa22e3b Binary files /dev/null and b/docs/screenshots/widget-redesign-before-after.png differ diff --git a/docs/screenshots/widget-redesign-clear.png b/docs/screenshots/widget-redesign-clear.png new file mode 100644 index 0000000000..669b301857 Binary files /dev/null and b/docs/screenshots/widget-redesign-clear.png differ diff --git a/docs/screenshots/widget-redesign-dark.png b/docs/screenshots/widget-redesign-dark.png new file mode 100644 index 0000000000..c976d89692 Binary files /dev/null and b/docs/screenshots/widget-redesign-dark.png differ diff --git a/docs/screenshots/widget-redesign-light.png b/docs/screenshots/widget-redesign-light.png new file mode 100644 index 0000000000..00b0a55997 Binary files /dev/null and b/docs/screenshots/widget-redesign-light.png differ diff --git a/docs/screenshots/widget-redesign-runtime-modes.png b/docs/screenshots/widget-redesign-runtime-modes.png new file mode 100644 index 0000000000..2475be855b Binary files /dev/null and b/docs/screenshots/widget-redesign-runtime-modes.png differ diff --git a/docs/screenshots/widget-redesign-runtime-pager.png b/docs/screenshots/widget-redesign-runtime-pager.png new file mode 100644 index 0000000000..d2f255d9dc Binary files /dev/null and b/docs/screenshots/widget-redesign-runtime-pager.png differ