diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index efdc81fe36..b3271130c4 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -86,6 +86,7 @@ extension UsageMenuCardView.Model.Metric { MenuCardHeightFingerprint.field("detail", self.detailText), MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), MenuCardHeightFingerprint.field("detailRight", self.detailRightText), + self.detailRightSecondaryText == nil ? "detailRightSecondary=0" : "detailRightSecondary=1", MenuCardHeightFingerprint.field( "sessionEquivalentVerdict", self.sessionEquivalentDetail?.verdictText), @@ -129,10 +130,7 @@ extension UsageMenuCardView.Model.TokenUsageSection { extension CodexResetCreditsPresentation { fileprivate var heightFingerprint: String { - MenuCardHeightFingerprint.join([ - MenuCardHeightFingerprint.field("text", self.text), - MenuCardHeightFingerprint.field("expirySummary", self.expirySummaryText), - ]) + "items=\(self.items.count)" } } diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift index fa6b2d4da3..ab0e14b1b7 100644 --- a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -10,11 +10,8 @@ struct CodexResetCreditsPresentation: Equatable { let text: String let items: [CodexResetCreditPresentationItem] - var expirySummaryText: String { - let visibleItems = self.items.prefix(4).map(\.compactExpiryText) - let hiddenCount = self.items.count - visibleItems.count - let suffix = hiddenCount > 0 ? ["+\(hiddenCount)"] : [] - return (visibleItems + suffix).joined(separator: " · ") + var compactExpiryTexts: [String] { + self.items.map(\.compactExpiryText) } var helpText: String { @@ -57,14 +54,37 @@ struct CodexResetCreditsPresentation: Equatable { return CodexResetCreditPresentationItem(expiryText: L("No expiry"), compactExpiryText: L("No expiry")) } let formattedTime = Self.formattedTime(expiresAt, resetStyle: resetStyle, now: now) - let compactExpiryText = resetStyle == .countdown && formattedTime.hasPrefix("in ") - ? String(formattedTime.dropFirst(3)) - : formattedTime + let compactExpiryText = Self.compactExpiryText( + expiresAt, + resetStyle: resetStyle, + formattedTime: formattedTime) return CodexResetCreditPresentationItem( expiryText: String(format: L("Expires %@"), formattedTime), compactExpiryText: compactExpiryText) } + private static func compactExpiryText( + _ expiresAt: Date, + resetStyle: ResetTimeDisplayStyle, + formattedTime: String) -> String + { + switch resetStyle { + case .absolute: + self.fixedCompactDateText(expiresAt) + case .countdown: + formattedTime.hasPrefix("in ") ? String(formattedTime.dropFirst(3)) : formattedTime + } + } + + private static func fixedCompactDateText(_ date: Date) -> String { + let components = Calendar.current.dateComponents([.month, .day, .hour, .minute], from: date) + let month = components.month ?? 0 + let day = components.day ?? 0 + let hour = components.hour ?? 0 + let minute = components.minute ?? 0 + return String(format: "%02d/%02d %02d:%02d", month, day, hour, minute) + } + private static func formattedTime( _ expiresAt: Date, resetStyle: ResetTimeDisplayStyle, @@ -90,21 +110,25 @@ struct CodexResetCreditsContent: View { .font(.body) .fontWeight(.medium) .lineLimit(1) - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .top, spacing: 8) { Text(self.presentation.text) .font(.footnote.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) .lineLimit(1) .layoutPriority(1) Spacer(minLength: 8) - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .top, spacing: 4) { Image(systemName: "clock") .font(.caption2) - Text(self.presentation.expirySummaryText) - .font(.caption) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - .minimumScaleFactor(0.8) + VStack(alignment: .leading, spacing: 2) { + ForEach(self.presentation.items.indices, id: \.self) { index in + Text(self.presentation.items[index].compactExpiryText) + .font(.caption) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + } } .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .accessibilityHidden(true) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 638cb8b4a7..dc9fd2f4db 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -5,6 +5,7 @@ extension UsageMenuCardView.Model { struct PaceDetail { let leftLabel: String let rightLabel: String? + var riskLabel: String? let pacePercent: Double? let paceOnTop: Bool } @@ -41,6 +42,9 @@ extension UsageMenuCardView.Model { metricID: metric.id), detailLeftText: PersonalInfoRedactor.redactEmails(in: metric.detailLeftText, isEnabled: true), detailRightText: PersonalInfoRedactor.redactEmails(in: metric.detailRightText, isEnabled: true), + detailRightSecondaryText: PersonalInfoRedactor.redactEmails( + in: metric.detailRightSecondaryText, + isEnabled: true), pacePercent: metric.pacePercent, paceOnTop: metric.paceOnTop, warningMarkerPercents: metric.warningMarkerPercents, @@ -174,7 +178,13 @@ extension UsageMenuCardView.Model { } private static func hasCompatibleMetricLayout(_ current: Metric, _ candidate: Metric) -> Bool { - current.id == candidate.id && + // Numeric substitutions retain the same text shape; wording changes can cross the one-row/two-row boundary. + let hasCompatibleAdaptiveDetailText = current.detailRightSecondaryText != nil || + (Self.adaptiveDetailTextShape(current.detailLeftText) == + Self.adaptiveDetailTextShape(candidate.detailLeftText) && + Self.adaptiveDetailTextShape(current.detailRightText) == + Self.adaptiveDetailTextShape(candidate.detailRightText)) + return current.id == candidate.id && current.title == candidate.title && current.percentStyle == candidate.percentStyle && (current.statusText == nil) == (candidate.statusText == nil) && @@ -182,9 +192,15 @@ extension UsageMenuCardView.Model { (current.detailText == nil) == (candidate.detailText == nil) && (current.detailLeftText == nil) == (candidate.detailLeftText == nil) && (current.detailRightText == nil) == (candidate.detailRightText == nil) && + (current.detailRightSecondaryText == nil) == (candidate.detailRightSecondaryText == nil) && + hasCompatibleAdaptiveDetailText && current.cardStyle == candidate.cardStyle } + private static func adaptiveDetailTextShape(_ text: String?) -> String? { + text.map { String($0.map { character in character.isNumber ? "#" : character }) } + } + private static func hasCompatibleCreditsLayout( currentText: String?, currentRemaining: Double?, @@ -464,6 +480,7 @@ extension UsageMenuCardView.Model { return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, + riskLabel: detail.riskLabel, pacePercent: pacePercent, paceOnTop: paceOnTop) } @@ -493,6 +510,7 @@ extension UsageMenuCardView.Model { return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, + riskLabel: detail.riskLabel, pacePercent: pacePercent, paceOnTop: paceOnTop) } @@ -695,6 +713,7 @@ extension UsageMenuCardView.Model { detailText: usageKnown ? detailText : nil, detailLeftText: usageKnown ? paceDetail?.leftLabel : nil, detailRightText: usageKnown ? paceDetail?.rightLabel : nil, + detailRightSecondaryText: usageKnown ? paceDetail?.riskLabel : nil, pacePercent: usageKnown ? paceDetail?.pacePercent : nil, paceOnTop: paceDetail?.paceOnTop ?? true, sessionEquivalentDetail: usageKnown @@ -852,6 +871,7 @@ extension UsageMenuCardView.Model { detailText: nil, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, + detailRightSecondaryText: paceDetail?.riskLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true) } diff --git a/Sources/CodexBar/MenuCardView+SessionEquivalent.swift b/Sources/CodexBar/MenuCardView+SessionEquivalent.swift index 18a57c3e6b..a0cad6904e 100644 --- a/Sources/CodexBar/MenuCardView+SessionEquivalent.swift +++ b/Sources/CodexBar/MenuCardView+SessionEquivalent.swift @@ -54,6 +54,7 @@ extension UsageMenuCardView.Model { detailText: nil, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, + detailRightSecondaryText: paceDetail?.riskLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index f0b01e1cef..2386f227fa 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -34,6 +34,7 @@ struct UsageMenuCardView: View { let detailText: String? let detailLeftText: String? let detailRightText: String? + let detailRightSecondaryText: String? let pacePercent: Double? let paceOnTop: Bool let warningMarkerPercents: [Double] @@ -51,6 +52,7 @@ struct UsageMenuCardView: View { detailText: String?, detailLeftText: String?, detailRightText: String?, + detailRightSecondaryText: String? = nil, pacePercent: Double?, paceOnTop: Bool, warningMarkerPercents: [Double] = [], @@ -67,6 +69,7 @@ struct UsageMenuCardView: View { self.detailText = detailText self.detailLeftText = detailLeftText self.detailRightText = detailRightText + self.detailRightSecondaryText = detailRightSecondaryText self.pacePercent = pacePercent self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents @@ -534,22 +537,14 @@ private struct MetricRow: View { .lineLimit(1) } } - if self.metric.detailLeftText != nil || self.metric.detailRightText != nil { - HStack(alignment: .firstTextBaseline) { - if let detailLeft = self.metric.detailLeftText { - Text(detailLeft) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - .lineLimit(1) - } - Spacer() - if let detailRight = self.metric.detailRightText { - Text(detailRight) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - } - } + if self.metric.detailLeftText != nil || + self.metric.detailRightText != nil || + self.metric.detailRightSecondaryText != nil + { + MetricDetailRow( + leftText: self.metric.detailLeftText, + rightText: self.metric.detailRightText, + secondaryRightText: self.metric.detailRightSecondaryText) } if let sessionEquivalentDetail = self.metric.sessionEquivalentDetail { Text(sessionEquivalentDetail.verdictText) @@ -1260,6 +1255,7 @@ extension UsageMenuCardView.Model { detailText: tertiaryDetailText, detailLeftText: tertiaryPaceDetail?.leftLabel, detailRightText: tertiaryPaceDetail?.rightLabel, + detailRightSecondaryText: tertiaryPaceDetail?.riskLabel, pacePercent: tertiaryPaceDetail?.pacePercent, paceOnTop: tertiaryPaceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( @@ -1318,6 +1314,7 @@ extension UsageMenuCardView.Model { var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) var primaryDetailLeft: String? var primaryDetailRight: String? + var primaryDetailRightSecondary: String? if input.provider == .crof, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty @@ -1382,6 +1379,7 @@ extension UsageMenuCardView.Model { { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel + primaryDetailRightSecondary = paceDetail.riskLabel primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } @@ -1404,6 +1402,7 @@ extension UsageMenuCardView.Model { if let paceDetail { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel + primaryDetailRightSecondary = paceDetail.riskLabel primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } @@ -1411,6 +1410,7 @@ extension UsageMenuCardView.Model { } else if let paceDetail = Self.resetWindowPaceDetail(window: primary, input: input) { primaryDetailLeft = paceDetail.leftLabel primaryDetailRight = paceDetail.rightLabel + primaryDetailRightSecondary = paceDetail.riskLabel primaryPacePercent = paceDetail.pacePercent primaryPaceOnTop = paceDetail.paceOnTop } @@ -1431,6 +1431,7 @@ extension UsageMenuCardView.Model { primaryResetText = regen.resetText primaryDetailLeft = regen.pace.leftLabel primaryDetailRight = regen.pace.rightLabel + primaryDetailRightSecondary = regen.pace.riskLabel primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } @@ -1450,6 +1451,7 @@ extension UsageMenuCardView.Model { detailText: primaryDetailText, detailLeftText: primaryDetailLeft, detailRightText: primaryDetailRight, + detailRightSecondaryText: primaryDetailRightSecondary, pacePercent: primaryPacePercent, paceOnTop: primaryPaceOnTop, warningMarkerPercents: Self.warningMarkerPercents( @@ -1569,6 +1571,7 @@ extension UsageMenuCardView.Model { detailText: weeklyDetailText, detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, + detailRightSecondaryText: paceDetail?.riskLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 8e91210dd3..cc93494ed4 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -507,20 +507,31 @@ private struct ProviderMetricInlineRow: View { Text(leftDetail) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } Spacer(minLength: 8) if let rightDetail = self.metric.detailRightText, !rightDetail.isEmpty { Text(rightDetail) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } else if !resetText.isEmpty { Text(resetText) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(1) } } } + if let secondaryRightDetail = self.metric.detailRightSecondaryText, !secondaryRightDetail.isEmpty { + Text(secondaryRightDetail) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + } + if hasRightDetail, !resetText.isEmpty { Text(resetText) .font(.footnote) @@ -552,19 +563,24 @@ private struct ProviderCodexResetCreditsInlineRow: View { .font(.footnote) .foregroundStyle(.secondary) } - HStack(alignment: .firstTextBaseline, spacing: 4) { + HStack(alignment: .top, spacing: 4) { Image(systemName: "clock") .font(.caption2) - Text(self.presentation.expirySummaryText) - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) - .minimumScaleFactor(0.8) + VStack(alignment: .leading, spacing: 2) { + ForEach(self.presentation.items.indices, id: \.self) { index in + Text(self.presentation.items[index].compactExpiryText) + .font(.caption) + .foregroundStyle(.tertiary) + .monospacedDigit() + .lineLimit(1) + } + } } .frame(maxWidth: .infinity, alignment: .trailing) .accessibilityHidden(true) } .padding(.vertical, 2) + .help(self.presentation.helpText) .accessibilityElement(children: .combine) .accessibilityLabel(self.presentation.accessibilityLabel) } diff --git a/Sources/CodexBar/UsageMenuCardLayout.swift b/Sources/CodexBar/UsageMenuCardLayout.swift index 8814a37cc6..447609a131 100644 --- a/Sources/CodexBar/UsageMenuCardLayout.swift +++ b/Sources/CodexBar/UsageMenuCardLayout.swift @@ -1,4 +1,5 @@ import CoreGraphics +import SwiftUI enum UsageMenuCardLayout { static let horizontalPadding: CGFloat = 20 @@ -15,3 +16,69 @@ enum UsageMenuCardLayout { sectionBottomPadding + usageSectionTopPadding } } + +struct MetricDetailRow: View { + let leftText: String? + let rightText: String? + let secondaryRightText: String? + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + if let secondaryRightText { + self.fixedTwoRow(secondaryRightText: secondaryRightText) + } else { + ViewThatFits(in: .horizontal) { + self.oneRow + self.twoRow + } + } + } + + private func fixedTwoRow(secondaryRightText: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let leftText { + self.detailText(leftText, color: MenuHighlightStyle.primary(self.isHighlighted)) + } + Spacer(minLength: 8) + if let rightText { + self.detailText(rightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .layoutPriority(1) + } + } + self.detailText(secondaryRightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var oneRow: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let leftText { + self.detailText(leftText, color: MenuHighlightStyle.primary(self.isHighlighted)) + .fixedSize(horizontal: true, vertical: false) + } + Spacer(minLength: 8) + if let rightText { + self.detailText(rightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .fixedSize(horizontal: true, vertical: false) + } + } + } + + private var twoRow: some View { + VStack(alignment: .leading, spacing: 0) { + if let leftText { + self.detailText(leftText, color: MenuHighlightStyle.primary(self.isHighlighted)) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let rightText { + self.detailText(rightText, color: MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + private func detailText(_ text: String, color: Color) -> some View { + Text(text).font(.footnote).foregroundStyle(color).lineLimit(1) + } +} diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 32823b6d22..dd8f5634a3 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -5,6 +5,7 @@ enum UsagePaceText { struct WeeklyDetail { let leftLabel: String let rightLabel: String? + let riskLabel: String? let expectedUsedPercent: Double let stage: UsagePace.Stage } @@ -23,16 +24,31 @@ enum UsagePaceText { static func weeklySummary(provider: UsageProvider, pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(provider: provider, pace: pace, now: now) - if let rightLabel = detail.rightLabel { - return L("Pace: %@ · %@", detail.leftLabel, rightLabel) + if let combined = self.joinedRightPortion(right: detail.rightLabel, risk: detail.riskLabel) { + return L("Pace: %@ · %@", detail.leftLabel, combined) } return L("Pace: %@", detail.leftLabel) } + private static func joinedRightPortion(right: String?, risk: String?) -> String? { + switch (right, risk) { + case let (right?, risk?): + L("%@ · %@", right, risk) + case let (right?, nil): + right + case let (nil, risk?): + risk + case (nil, nil): + nil + } + } + static func weeklyDetail(provider: UsageProvider, pace: UsagePace, now: Date = .init()) -> WeeklyDetail { - WeeklyDetail( + let (right, risk) = self.detailRightAndRisk(for: pace, provider: provider, context: .weekly, now: now) + return WeeklyDetail( leftLabel: self.detailLeftLabel(for: pace), - rightLabel: self.detailRightLabel(for: pace, provider: provider, context: .weekly, now: now), + rightLabel: right, + riskLabel: risk, expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } @@ -85,11 +101,11 @@ enum UsagePaceText { } } - private static func detailRightLabel( + private static func detailRightAndRisk( for pace: UsagePace, provider: UsageProvider, context: DetailContext, - now: Date) -> String? + now: Date) -> (right: String?, risk: String?) { let etaLabel: String? if pace.willLastToReset { @@ -105,16 +121,13 @@ enum UsagePaceText { etaLabel = nil } - guard let runOutProbability = pace.runOutProbability else { return etaLabel } + guard let runOutProbability = pace.runOutProbability else { return (etaLabel, nil) } let roundedRisk = self.roundedRiskPercent(runOutProbability) let riskLabel = L("≈ %d%% run-out risk", roundedRisk) if pace.willLastToReset, roundedRisk > 0 { - return riskLabel - } - if let etaLabel { - return L("%@ · %@", etaLabel, riskLabel) + return (nil, riskLabel) } - return riskLabel + return (etaLabel, riskLabel) } private static func combinedLastsLabel(for pace: UsagePace, provider: UsageProvider) -> String { @@ -168,17 +181,19 @@ enum UsagePaceText { static func sessionDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { guard let pace = sessionPace(provider: provider, window: window, now: now) else { return nil } + let (right, risk) = Self.detailRightAndRisk(for: pace, provider: provider, context: .session, now: now) return WeeklyDetail( leftLabel: Self.detailLeftLabel(for: pace), - rightLabel: Self.detailRightLabel(for: pace, provider: provider, context: .session, now: now), + rightLabel: right, + riskLabel: risk, expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } static func sessionSummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { guard let detail = sessionDetail(provider: provider, window: window, now: now) else { return nil } - if let rightLabel = detail.rightLabel { - return L("Pace: %@ · %@", detail.leftLabel, rightLabel) + if let combined = self.joinedRightPortion(right: detail.rightLabel, risk: detail.riskLabel) { + return L("Pace: %@ · %@", detail.leftLabel, combined) } return L("Pace: %@", detail.leftLabel) } diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift index c288149d44..a2170cf0de 100644 --- a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift @@ -23,7 +23,7 @@ struct CodexResetCreditsMenuCardTests { #expect(presentation.text == "3 available") #expect(presentation.items.map(\.expiryText) == ["Expires in 1d", "Expires in 2d", "No expiry"]) - #expect(presentation.expirySummaryText == "1d · 2d · No expiry") + #expect(presentation.compactExpiryTexts == ["1d", "2d", "No expiry"]) #expect(presentation.helpText == "1. Expires in 1d\n2. Expires in 2d\n3. No expiry") #expect(presentation.accessibilityLabel.contains(presentation.helpText)) } @@ -40,14 +40,21 @@ struct CodexResetCreditsMenuCardTests { #expect(presentation.text == "1 available") #expect(presentation.items.map(\.expiryText) == ["No expiry"]) - #expect(presentation.expirySummaryText == "No expiry") + #expect(presentation.compactExpiryTexts == ["No expiry"]) #expect(model.hasUsageContent) } @Test func `inventory respects absolute reset-time style`() throws { - let now = Date(timeIntervalSince1970: 1_781_726_400) - let expiresAt = now.addingTimeInterval(86400) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let expiresAt = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 18, + hour: 8, + minute: 36))) + let now = expiresAt.addingTimeInterval(-86400) let model = try Self.model( snapshot: Self.snapshot( now: now, @@ -58,7 +65,7 @@ struct CodexResetCreditsMenuCardTests { let formatted = UsageFormatter.resetDescription(from: expiresAt, now: now) #expect(presentation.items.map(\.expiryText) == ["Expires \(formatted)"]) - #expect(presentation.expirySummaryText == formatted) + #expect(presentation.compactExpiryTexts == ["07/18 08:36"]) } @Test @@ -72,11 +79,11 @@ struct CodexResetCreditsMenuCardTests { now: now) #expect(model.codexResetCredits?.text == "1 available") - #expect(model.codexResetCredits?.expirySummaryText == "1d") + #expect(model.codexResetCredits?.compactExpiryTexts == ["1d"]) } @Test - func `compact expiry summary caps visible dates`() throws { + func `compact expiry texts preserve full inventory`() throws { let now = Date(timeIntervalSince1970: 1_781_726_400) let credits = (1...6).map { day in Self.credit(id: "day-\(day)", status: .available, now: now, expiresIn: Double(day * 86400)) @@ -84,7 +91,7 @@ struct CodexResetCreditsMenuCardTests { let model = try Self.model(snapshot: Self.snapshot(now: now, credits: credits), now: now) let presentation = try #require(model.codexResetCredits) - #expect(presentation.expirySummaryText == "1d · 2d · 3d · 4d · +2") + #expect(presentation.compactExpiryTexts == ["1d", "2d", "3d", "4d", "5d", "6d"]) #expect(presentation.helpText.split(separator: "\n").count == 6) } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index e294baa6e7..5f6f1c29b7 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -817,7 +817,8 @@ struct HistoricalUsagePaceTests { #expect(pace.runOutProbability == 1) let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "Runs out now · ≈ 100% run-out risk") + #expect(detail.rightLabel == "Runs out now") + #expect(detail.riskLabel == "≈ 100% run-out risk") } } } diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift index 7e2ec20c12..d3bf1add7b 100644 --- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -48,10 +48,42 @@ struct MenuCardHeightFingerprintTests { #expect(one.heightFingerprint(section: "card") != two.heightFingerprint(section: "card")) } + @Test + func `height fingerprint ignores reset-credit text when item count is unchanged`() { + func items(fifthCompactExpiryText: String) -> [CodexResetCreditPresentationItem] { + (1...4).map { day in + CodexResetCreditPresentationItem(expiryText: "Expires in \(day)d", compactExpiryText: "\(day)d") + } + [CodexResetCreditPresentationItem( + expiryText: "Expires in 5d", + compactExpiryText: fifthCompactExpiryText)] + } + let one = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "5 available", + items: items(fifthCompactExpiryText: "5d"))) + let two = Self.model(resetCredits: CodexResetCreditsPresentation( + text: "5 available", + items: items(fifthCompactExpiryText: "6d"))) + + #expect(one.heightFingerprint(section: "card") == two.heightFingerprint(section: "card")) + } + + @Test + func `height fingerprint changes when detail right secondary text changes`() { + let withoutRisk = Self.model(detailRightSecondaryText: nil).heightFingerprint(section: "card") + let withRisk = Self.model(detailRightSecondaryText: "≈ 45% run-out risk").heightFingerprint(section: "card") + let withChangedRisk = Self.model(detailRightSecondaryText: "≈ 70% run-out risk") + .heightFingerprint(section: "card") + + #expect(withoutRisk != withRisk) + #expect(withRisk == withChangedRisk) + #expect(!withRisk.contains("45% run-out risk")) + } + private static func model( percent: Double = 42, percentStyle: UsageMenuCardView.Model.PercentStyle = .left, - resetCredits: CodexResetCreditsPresentation? = nil) -> UsageMenuCardView.Model + resetCredits: CodexResetCreditsPresentation? = nil, + detailRightSecondaryText: String? = nil) -> UsageMenuCardView.Model { UsageMenuCardView.Model( provider: .codex, @@ -71,6 +103,7 @@ struct MenuCardHeightFingerprintTests { detailText: nil, detailLeftText: nil, detailRightText: nil, + detailRightSecondaryText: detailRightSecondaryText, pacePercent: nil, paceOnTop: true), ], diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index 1893cff4e5..299bfe16cf 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -85,6 +85,101 @@ struct UsageMenuCardLayoutTests { UsageMenuCardLayout.sectionBottomPadding) } + @Test + func `metric detail uses one row when it fits and at most two when it does not`() { + let width: CGFloat = 296 + func metricModel(detailLeftText: String, detailRightText: String) -> UsageMenuCardView.Model { + Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: detailLeftText, + detailRightText: detailRightText, + pacePercent: nil, + paceOnTop: true), + ]) + } + let shortModel = metricModel(detailLeftText: "5% ahead", detailRightText: "Done in 1d") + let longModel = metricModel( + detailLeftText: "5% more than current pace", + detailRightText: "Done in 1d 36m · about 75% likely to finish") + let veryLongModel = metricModel( + detailLeftText: "5% more than the current projected pace with additional context", + detailRightText: "Done in 1d 36m · about 75% likely to finish before the weekly reset window") + + let shortHeight = NSHostingController(rootView: UsageMenuCardView(model: shortModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + let longHeight = NSHostingController(rootView: UsageMenuCardView(model: longModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + let veryLongHeight = NSHostingController(rootView: UsageMenuCardView(model: veryLongModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + + #expect(longHeight > shortHeight) + #expect(abs(veryLongHeight - longHeight) < Self.heightTolerance) + } + + @Test + func `tracked metric layout distinguishes adaptive detail text shapes`() { + func metricModel(detailLeftText: String, detailRightText: String) -> UsageMenuCardView.Model { + Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: detailLeftText, + detailRightText: detailRightText, + pacePercent: nil, + paceOnTop: true), + ]) + } + let shortModel = metricModel(detailLeftText: "5% ahead", detailRightText: "Done in 1d") + let sameShapeModel = metricModel(detailLeftText: "6% ahead", detailRightText: "Done in 2d") + let longModel = metricModel( + detailLeftText: "5% more than current pace", + detailRightText: "Done in 1d 36m · about 75% likely to finish") + + #expect(shortModel.hasCompatibleTrackedLayout(with: sameShapeModel)) + #expect(!shortModel.hasCompatibleTrackedLayout(with: longModel)) + } + + @Test + func `metric detail with risk stays at most two rows regardless of secondary length`() { + let width: CGFloat = 296 + func metricModel(detailRightSecondaryText: String) -> UsageMenuCardView.Model { + Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: "5% ahead", + detailRightText: "Runs out in 2d", + detailRightSecondaryText: detailRightSecondaryText, + pacePercent: nil, + paceOnTop: true), + ]) + } + let shortRiskModel = metricModel(detailRightSecondaryText: "≈ 45% run-out risk") + let longRiskModel = metricModel( + detailRightSecondaryText: "≈ 45% run-out risk with a lot of additional descriptive context appended") + + let shortRiskHeight = NSHostingController(rootView: UsageMenuCardView(model: shortRiskModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + let longRiskHeight = NSHostingController(rootView: UsageMenuCardView(model: longRiskModel, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)).height + + #expect(abs(longRiskHeight - shortRiskHeight) < Self.heightTolerance) + } + private static func model( metrics: [UsageMenuCardView.Model.Metric] = [], usageNotes: [String] = [], diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 9fc7974881..d7e746f532 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -165,7 +165,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") + #expect(detail.rightLabel == "Runs out in 2d") + #expect(detail.riskLabel == "≈ 70% run-out risk") } @Test @@ -183,7 +184,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) #expect(detail.leftLabel == "9% in reserve") - #expect(detail.rightLabel == "≈ 45% run-out risk") + #expect(detail.rightLabel == nil) + #expect(detail.riskLabel == "≈ 45% run-out risk") } @Test @@ -201,7 +203,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom · ≈ 0% run-out risk") + #expect(detail.rightLabel == "Lasts until reset · 1.5× headroom") + #expect(detail.riskLabel == "≈ 0% run-out risk") } @Test @@ -218,7 +221,8 @@ struct UsagePaceTextTests { let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now) - #expect(detail.rightLabel == "≈ 5% run-out risk") + #expect(detail.rightLabel == nil) + #expect(detail.riskLabel == "≈ 5% run-out risk") } // MARK: - Session pace (5-hour window)