diff --git a/Sources/CodexBar/CompactOverviewRow.swift b/Sources/CodexBar/CompactOverviewRow.swift new file mode 100644 index 0000000000..8f8ba5b544 --- /dev/null +++ b/Sources/CodexBar/CompactOverviewRow.swift @@ -0,0 +1,537 @@ +import CodexBarCore +import SwiftUI + +struct CompactOverviewProjection { + struct Lane: Identifiable { + let id: String + let title: String + let percent: Double + let percentStyle: UsageMenuCardView.Model.PercentStyle + let tint: Color + let pacePercent: Double? + let paceOnTop: Bool + let warningMarkerPercents: [Double] + let workdayMarkerPercents: [Double] + + var barAccessibilityLabel: String { + self.percentStyle.accessibilityLabel + } + + var accessibilitySummary: String { + "\(self.title), \(UsageFormatter.percentText(self.percent, suffix: self.percentStyle.labelSuffix))" + } + } + + enum Fallback { + case loading(text: String) + case status(metricID: String, title: String, text: String) + case generic(text: String) + + var text: String { + switch self { + case let .loading(text), let .generic(text): text + case let .status(_, _, text): text + } + } + + var metricTitle: String? { + guard case let .status(_, title, _) = self else { return nil } + return title + } + + var accessibilityLabel: String { + self.metricTitle ?? self.text + } + + var accessibilityValue: String? { + guard case let .status(_, _, text) = self else { return nil } + return text + } + + var accessibilitySummary: String { + switch self { + case let .status(_, title, text): "\(title): \(text)" + case let .loading(text), let .generic(text): text + } + } + + fileprivate var layoutSignature: String { + switch self { + case .loading: + "fallback:loading" + case let .status(metricID, title, _): + Self.joinSignature([ + "fallback:status", + UsageMenuCardView.Model.heightFingerprintField("id", metricID), + UsageMenuCardView.Model.heightFingerprintField("title", title), + ]) + case .generic: + "fallback:generic" + } + } + + private static func joinSignature(_ fields: [String]) -> String { + fields.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") + } + } + + let providerName: String + let lanes: [Lane] + let fallback: Fallback? + let layoutSignature: String + + var accessibilitySummary: String { + if !self.lanes.isEmpty { + return self.lanes.map(\.accessibilitySummary).joined(separator: ". ") + } + return self.fallback?.accessibilitySummary ?? "" + } + + var accessibilityLabel: String { + let summary = self.accessibilitySummary + return summary.isEmpty ? self.providerName : "\(self.providerName). \(summary)" + } + + init( + model: UsageMenuCardView.Model, + loadingText: String = L("Loading…"), + noBarsText: String = L("overview_compact_no_bars")) + { + let disclosesDoubaoPlanFamily = model.provider == .doubao + && model.metrics.contains { $0.id.hasPrefix("doubao-agent-") } + self.providerName = model.providerName + self.lanes = model.metrics.compactMap { metric in + guard metric.statusText == nil else { return nil } + return Lane( + id: metric.id, + title: Self.metricTitle( + provider: model.provider, + metric: metric, + disclosesDoubaoPlanFamily: disclosesDoubaoPlanFamily), + percent: metric.percent, + percentStyle: metric.percentStyle, + tint: model.progressColor, + pacePercent: metric.pacePercent, + paceOnTop: metric.paceOnTop, + warningMarkerPercents: metric.warningMarkerPercents, + workdayMarkerPercents: metric.workdayMarkerPercents) + } + + if self.lanes.isEmpty { + self.fallback = Self.makeFallback( + model: model, + loadingText: loadingText, + noBarsText: noBarsText, + disclosesDoubaoPlanFamily: disclosesDoubaoPlanFamily) + } else { + self.fallback = nil + } + self.layoutSignature = Self.makeLayoutSignature(lanes: self.lanes, fallback: self.fallback) + } + + private static func makeFallback( + model: UsageMenuCardView.Model, + loadingText: String, + noBarsText: String, + disclosesDoubaoPlanFamily: Bool) -> Fallback + { + if case .loading = model.subtitleStyle { + return .loading(text: loadingText) + } + + for metric in model.metrics { + guard let statusText = metric.statusText? + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusText.isEmpty + else { continue } + return .status( + metricID: metric.id, + title: Self.metricTitle( + provider: model.provider, + metric: metric, + disclosesDoubaoPlanFamily: disclosesDoubaoPlanFamily), + text: statusText) + } + return .generic(text: noBarsText) + } + + private static func metricTitle( + provider: UsageProvider, + metric: UsageMenuCardView.Model.Metric, + disclosesDoubaoPlanFamily: Bool) -> String + { + let title = UsageMenuCardView.popupMetricTitle(provider: provider, metric: metric) + guard disclosesDoubaoPlanFamily else { return title } + let planTitle = metric.id.hasPrefix("doubao-agent-") ? L("Agent Plan") : L("Coding Plan") + return "\(planTitle) — \(title)" + } + + private static func makeLayoutSignature(lanes: [Lane], fallback: Fallback?) -> String { + guard !lanes.isEmpty else { + return fallback?.layoutSignature ?? "fallback:generic" + } + let fields = lanes.flatMap { lane in + [ + UsageMenuCardView.Model.heightFingerprintField("id", lane.id), + UsageMenuCardView.Model.heightFingerprintField("title", lane.title), + "percentStyle=\(lane.percentStyle.rawValue)", + ] + } + return Self.joinSignature(["drawable:count=\(lanes.count)"] + fields) + } + + func heightFingerprint(section: String, layoutSignature: String) -> String { + Self.joinSignature([ + "section=\(section)", + "projection=\(self.layoutSignature)", + "layout=\(layoutSignature)", + ]) + } + + private static func joinSignature(_ fields: [String]) -> String { + fields.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") + } +} + +struct CompactOverviewLayout { + static let minimumMenuWidth: CGFloat = 310 + static let horizontalPadding: CGFloat = UsageMenuCardLayout.horizontalPadding + static let chevronGutterWidth: CGFloat = 20 + static let barHeight = UsageProgressBar.defaultHeight + static let labeledHeaderVerticalPadding: CGFloat = UsageMenuCardLayout.headerOnlyVerticalPadding + static let labeledUsageTopPadding: CGFloat = UsageMenuCardLayout.usageSectionTopPadding + static let labeledBottomPadding: CGFloat = UsageMenuCardLayout.sectionBottomPadding + static let labeledMetricSpacing: CGFloat = UsageMenuCardLayout.metricSpacing + static let labeledMetricContentSpacing: CGFloat = 6 + static let providerBarsLaneSpacing: CGFloat = UsageMenuCardLayout.metricSpacing + static let barsOnlyLaneSpacing: CGFloat = UsageMenuCardLayout.metricSpacing + static let barsOnlyInterProviderSpacing: CGFloat = 24 + static let barsOnlySectionOuterSpacing: CGFloat = 15 + static var barsOnlyVerticalPadding: CGFloat { + (self.barsOnlyInterProviderSpacing - MenuCardItemSizing.measuredHeightPadding) / 2 + } + + static var barsOnlySectionSpacerHeight: CGFloat { + self.barsOnlySectionOuterSpacing + - MenuCardItemSizing.measuredHeightPadding / 2 + - self.barsOnlyVerticalPadding + } + + let menuWidth: CGFloat + let layoutDirection: LayoutDirection + let signature: String + + var contentWidth: CGFloat { + self.menuWidth - Self.horizontalPadding * 2 + } + + var labeledBarWidth: CGFloat { + self.contentWidth + } + + var providerHeaderWidth: CGFloat { + self.contentWidth - Self.chevronGutterWidth + } + + var providerBarsBarWidth: CGFloat { + self.contentWidth + } + + var barsOnlyBarWidth: CGFloat { + self.contentWidth + } + + static func resolveForMenu( + menuWidth: CGFloat, + layoutDirection: LayoutDirection) -> Self + { + let resolvedMenuWidth = max(menuWidth, self.minimumMenuWidth) + let direction = switch layoutDirection { + case .leftToRight: "ltr" + case .rightToLeft: "rtl" + @unknown default: "unknown" + } + let signature = Self.signature(fields: [ + "menu=\(Self.geometryToken(resolvedMenuWidth))", + "gutter=\(Self.geometryToken(Self.chevronGutterWidth))", + "padding=\(Self.geometryToken(Self.horizontalPadding))", + "barHeight=\(Self.geometryToken(Self.barHeight))", + "labeledHeaderPadding=\(Self.geometryToken(Self.labeledHeaderVerticalPadding))", + "labeledUsageTop=\(Self.geometryToken(Self.labeledUsageTopPadding))", + "labeledBottom=\(Self.geometryToken(Self.labeledBottomPadding))", + "labeledMetricSpacing=\(Self.geometryToken(Self.labeledMetricSpacing))", + "labeledContentSpacing=\(Self.geometryToken(Self.labeledMetricContentSpacing))", + "providerBarsSpacing=\(Self.geometryToken(Self.providerBarsLaneSpacing))", + "barsOnlyPadding=\(Self.geometryToken(Self.barsOnlyVerticalPadding))", + "barsOnlySpacing=\(Self.geometryToken(Self.barsOnlyLaneSpacing))", + "barsOnlyInterProvider=\(Self.geometryToken(Self.barsOnlyInterProviderSpacing))", + "barsOnlySectionOuter=\(Self.geometryToken(Self.barsOnlySectionOuterSpacing))", + "barsOnlySectionSpacer=\(Self.geometryToken(Self.barsOnlySectionSpacerHeight))", + "direction=\(direction)", + ]) + return Self( + menuWidth: resolvedMenuWidth, + layoutDirection: layoutDirection, + signature: signature) + } + + private static func geometryToken(_ value: CGFloat) -> Int { + Int((value * 100).rounded()) + } + + private static func signature(fields: [String]) -> String { + fields.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") + } +} + +struct CompactOverviewLabeledContent: View { + let projection: CompactOverviewProjection + let layout: CompactOverviewLayout + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + CompactOverviewProviderHeader(projection: self.projection, layout: self.layout) + + self.content + } + .frame(width: self.layout.menuWidth, alignment: .leading) + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var content: some View { + if self.projection.lanes.isEmpty, let fallback = self.projection.fallback { + CompactOverviewLabeledFallback(fallback: fallback, layout: self.layout) + } else { + VStack(alignment: .leading, spacing: CompactOverviewLayout.labeledMetricSpacing) { + ForEach(self.projection.lanes) { lane in + CompactOverviewLabeledMetric(lane: lane, layout: self.layout) + } + } + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.top, CompactOverviewLayout.labeledUsageTopPadding) + .padding(.bottom, CompactOverviewLayout.labeledBottomPadding) + .accessibilityElement(children: .contain) + } + } +} + +private struct CompactOverviewProviderHeader: View { + let projection: CompactOverviewProjection + let layout: CompactOverviewLayout + + var body: some View { + HStack(spacing: 0) { + Text(self.projection.providerName) + .usageMenuCardProviderTitleStyle() + .lineLimit(1) + .truncationMode(.tail) + .frame(width: self.layout.providerHeaderWidth, alignment: .leading) + .help(self.projection.providerName) + .accessibilityHidden(true) + + Color.clear + .frame(width: CompactOverviewLayout.chevronGutterWidth, height: 0) + .accessibilityHidden(true) + } + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.vertical, CompactOverviewLayout.labeledHeaderVerticalPadding) + } +} + +private struct CompactOverviewLabeledMetric: View { + let lane: CompactOverviewProjection.Lane + let layout: CompactOverviewLayout + + var body: some View { + VStack(alignment: .leading, spacing: CompactOverviewLayout.labeledMetricContentSpacing) { + Text(self.lane.title) + .usageMenuCardMetricTitleStyle() + .lineLimit(1) + .truncationMode(.tail) + .frame(width: self.layout.contentWidth, alignment: .leading) + .help(self.lane.title) + .accessibilityHidden(true) + + UsageProgressBar( + percent: self.lane.percent, + tint: self.lane.tint, + accessibilityLabel: self.lane.barAccessibilityLabel, + pacePercent: self.lane.pacePercent, + paceOnTop: self.lane.paceOnTop, + warningMarkerPercents: self.lane.warningMarkerPercents, + workdayMarkerPercents: self.lane.workdayMarkerPercents, + height: CompactOverviewLayout.barHeight) + .frame(width: self.layout.labeledBarWidth) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(self.lane.title) + } +} + +struct CompactOverviewProviderBarsContent: View { + let projection: CompactOverviewProjection + let layout: CompactOverviewLayout + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + CompactOverviewProviderHeader(projection: self.projection, layout: self.layout) + + self.content + } + .frame(width: self.layout.menuWidth, alignment: .leading) + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var content: some View { + if self.projection.lanes.isEmpty, let fallback = self.projection.fallback { + CompactOverviewUnavailableRail( + fallback: fallback, + barWidth: self.layout.providerBarsBarWidth) + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.top, CompactOverviewLayout.labeledUsageTopPadding) + .padding(.bottom, CompactOverviewLayout.labeledBottomPadding) + } else { + VStack(alignment: .leading, spacing: CompactOverviewLayout.providerBarsLaneSpacing) { + ForEach(self.projection.lanes) { lane in + CompactOverviewBareBarLane( + lane: lane, + barWidth: self.layout.providerBarsBarWidth) + } + } + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.top, CompactOverviewLayout.labeledUsageTopPadding) + .padding(.bottom, CompactOverviewLayout.labeledBottomPadding) + .accessibilityElement(children: .contain) + } + } +} + +private struct CompactOverviewLabeledFallback: View { + let fallback: CompactOverviewProjection.Fallback + let layout: CompactOverviewLayout + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + Group { + switch self.fallback { + case let .status(_, title, text): + VStack(alignment: .leading, spacing: CompactOverviewLayout.labeledMetricContentSpacing) { + Text(title) + .font(.body) + .fontWeight(.medium) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .help(title) + .accessibilityHidden(true) + self.fallbackText(text, font: .footnote) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityValue(text) + case let .loading(text), let .generic(text): + self.fallbackText(text, font: .body) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + } + } + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.top, CompactOverviewLayout.labeledUsageTopPadding) + .padding(.bottom, CompactOverviewLayout.labeledBottomPadding) + .frame(width: self.layout.menuWidth, alignment: .leading) + } + + private func fallbackText(_ text: String, font: Font) -> some View { + Text(text) + .font(font) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: self.layout.contentWidth, alignment: .leading) + .help(text) + } +} + +struct CompactOverviewBarsOnlyContent: View { + let projection: CompactOverviewProjection + let layout: CompactOverviewLayout + + var body: some View { + VStack(alignment: .leading, spacing: CompactOverviewLayout.barsOnlyLaneSpacing) { + if self.projection.lanes.isEmpty, let fallback = self.projection.fallback { + CompactOverviewUnavailableRail( + fallback: fallback, + barWidth: self.layout.barsOnlyBarWidth) + } else { + ForEach(self.projection.lanes) { lane in + CompactOverviewBareBarLane( + lane: lane, + barWidth: self.layout.barsOnlyBarWidth) + } + } + } + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.vertical, CompactOverviewLayout.barsOnlyVerticalPadding) + .frame(width: self.layout.menuWidth, alignment: .leading) + .accessibilityElement(children: .contain) + } +} + +private struct CompactOverviewBareBarLane: View { + let lane: CompactOverviewProjection.Lane + let barWidth: CGFloat + + var body: some View { + UsageProgressBar( + percent: self.lane.percent, + tint: self.lane.tint, + accessibilityLabel: self.lane.barAccessibilityLabel, + pacePercent: self.lane.pacePercent, + paceOnTop: self.lane.paceOnTop, + warningMarkerPercents: self.lane.warningMarkerPercents, + workdayMarkerPercents: self.lane.workdayMarkerPercents, + height: CompactOverviewLayout.barHeight) + .frame(width: self.barWidth) + .help(self.lane.title) + .accessibilityElement(children: .contain) + .accessibilityLabel(self.lane.title) + } +} + +private struct CompactOverviewUnavailableRail: View { + let fallback: CompactOverviewProjection.Fallback + let barWidth: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + self.accessibleRail + } + + @ViewBuilder + private var accessibleRail: some View { + switch self.fallback { + case let .status(_, title, text): + self.rail + .accessibilityLabel(title) + .accessibilityValue(text) + case let .loading(text), let .generic(text): + self.rail + .accessibilityLabel(text) + } + } + + private var rail: some View { + Capsule() + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted).opacity(0.55)) + .overlay { + Capsule() + .strokeBorder( + MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.55), + style: StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + .frame(width: self.barWidth, height: CompactOverviewLayout.barHeight) + .help(self.fallback.text) + .accessibilityElement(children: .ignore) + } +} diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index d9fc9a3352..d78dee8596 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -229,6 +229,10 @@ func codexBarLocalizedResourceLocale() -> Locale { return codexBarLocale(forLanguage: bundleURL.deletingPathExtension().lastPathComponent) } +func codexBarUsesRightToLeftLayout() -> Bool { + Locale.Language(identifier: codexBarLocalizedResourceLocale().identifier).characterDirection == .rightToLeft +} + private func codexBarLocale(forLanguage language: String) -> Locale { guard !language.isEmpty else { return .current } let normalized = language.lowercased() diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 6d0f575f6d..c478660e6f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -2,6 +2,20 @@ import AppKit import CodexBarCore import SwiftUI +extension View { + func usageMenuCardProviderTitleStyle() -> some View { + self + .font(.headline) + .fontWeight(.semibold) + } + + func usageMenuCardMetricTitleStyle() -> some View { + self + .font(.body) + .fontWeight(.medium) + } +} + /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { @@ -187,7 +201,7 @@ struct UsageMenuCardView: View { @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.menuCardRefreshMonitor) private var refreshMonitor - static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { + nonisolated static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { return L("API key limit") } @@ -311,8 +325,8 @@ private struct UsageMenuCardHeaderView: View { var body: some View { VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerLineSpacing) { HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) { - Text(self.model.providerName).font(.headline) - .fontWeight(.semibold) + Text(self.model.providerName) + .usageMenuCardProviderTitleStyle() .lineLimit(1).truncationMode(.tail).layoutPriority(1) Spacer() Text(self.model.email).font(.subheadline) @@ -656,7 +670,7 @@ private struct UsageMenuCardUsageContentView: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: UsageMenuCardLayout.metricSpacing) { if let split = self.doubaoSplitMetrics { if !split.coding.isEmpty { self.groupHeader("Coding Plan") diff --git a/Sources/CodexBar/PreferencesMenuPane.swift b/Sources/CodexBar/PreferencesMenuPane.swift index b0624477ad..6703c92b59 100644 --- a/Sources/CodexBar/PreferencesMenuPane.swift +++ b/Sources/CodexBar/PreferencesMenuPane.swift @@ -6,6 +6,10 @@ struct MenuPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore + static func compactOverviewAvailable(mergeIcons: Bool) -> Bool { + mergeIcons + } + var body: some View { Form { Section { @@ -45,6 +49,19 @@ struct MenuPane: View { } Section { + SettingsMenuPicker( + selection: self.$settings.mergedOverviewLayout, + options: MenuSettingsMenuOptions.mergedOverviewLayouts, + label: { + SettingsRowLabel( + L("overview_layout_title"), + subtitle: L("overview_layout_subtitle")) + }, + optionLabel: { layout in + Text(layout.label) + }) + .disabled(!Self.compactOverviewAvailable(mergeIcons: self.settings.mergeIcons)) + Toggle(L("show_provider_changelog_links_title"), isOn: self.$settings.providerChangelogLinksEnabled) Toggle(isOn: self.$settings.showOptionalCreditsAndExtraUsage) { diff --git a/Sources/CodexBar/PreferencesMenuPicker.swift b/Sources/CodexBar/PreferencesMenuPicker.swift index e072bc0d2d..ead94e8514 100644 --- a/Sources/CodexBar/PreferencesMenuPicker.swift +++ b/Sources/CodexBar/PreferencesMenuPicker.swift @@ -70,6 +70,7 @@ enum MenuBarSettingsMenuOptions { } enum MenuSettingsMenuOptions { + static let mergedOverviewLayouts = MergedOverviewLayout.allCases static let weeklyProgressWorkDays: [Int?] = [nil, 4, 5, 7] static let multiAccountLayouts = MultiAccountMenuLayout.allCases static let usageBarsFill = UsageBarsFillOption.allCases diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index c59e9261ea..6c898ca36c 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -622,6 +622,13 @@ "multi_account_layout_segmented" = "مقسم"; "multi_account_layout_stacked" = "مكدس"; "overview_tab_providers_title" = "نظرة عامة على مزودي تبويب"; +"overview_layout_title" = "تخطيط النظرة العامة"; +"overview_layout_subtitle" = "اختر مقدار المعلومات التي تعرضها النظرة العامة."; +"overview_layout_detailed" = "مفصل"; +"overview_layout_compact" = "المزوّدون والمقاييس والأشرطة"; +"overview_layout_provider_bars" = "المزوّدون والأشرطة"; +"overview_layout_bars_only" = "الأشرطة فقط"; +"overview_compact_no_bars" = "لا توجد أشرطة استخدام"; "configure" = "تكوين..."; "overview_enable_merge_icons_hint" = "تفعيل أيقونات الدمج لتكوين مزودي تبويب النظرة العامة."; "overview_no_providers_hint" = "لا يوجد مزودون مفعلون متاحون للنظرة العامة."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a9c28dc98e..a4afd99472 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -600,6 +600,13 @@ "multi_account_layout_segmented" = "Segmentat"; "multi_account_layout_stacked" = "Apilat"; "overview_tab_providers_title" = "Proveïdors de la pestanya Resum"; +"overview_layout_title" = "Disseny del Resum"; +"overview_layout_subtitle" = "Trieu quanta informació mostra el Resum."; +"overview_layout_detailed" = "Detallat"; +"overview_layout_compact" = "Proveïdors, mètriques i barres"; +"overview_layout_provider_bars" = "Proveïdors i barres"; +"overview_layout_bars_only" = "Només barres"; +"overview_compact_no_bars" = "No hi ha barres d’ús"; "configure" = "Configureu…"; "overview_enable_merge_icons_hint" = "Activeu Combina les icones per configurar els proveïdors de la pestanya Resum."; "overview_no_providers_hint" = "No hi ha proveïdors activats disponibles per al Resum."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 70d0d26c7f..9c055d7040 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -614,6 +614,13 @@ "multi_account_layout_segmented" = "Segmentiert"; "multi_account_layout_stacked" = "Gestapelt"; "overview_tab_providers_title" = "Anbieter von Übersichtsregisterkarten"; +"overview_layout_title" = "Übersichtslayout"; +"overview_layout_subtitle" = "Wählen Sie aus, wie viele Informationen die Übersicht anzeigt."; +"overview_layout_detailed" = "Detailliert"; +"overview_layout_compact" = "Anbieter, Metriken und Balken"; +"overview_layout_provider_bars" = "Anbieter und Balken"; +"overview_layout_bars_only" = "Nur Balken"; +"overview_compact_no_bars" = "Keine Nutzungsbalken"; "configure" = "Konfigurieren…"; "overview_enable_merge_icons_hint" = "Aktivieren Sie \"Symbole zusammenführen\", um Anbieter für die Registerkarte \"Übersicht\" zu konfigurieren."; "overview_no_providers_hint" = "Für die Übersicht sind keine aktivierten Anbieter verfügbar."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ca4f093501..681099e5ce 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -599,6 +599,13 @@ "multi_account_layout_segmented" = "Segmented"; "multi_account_layout_stacked" = "Stacked"; "overview_tab_providers_title" = "Overview providers"; +"overview_layout_title" = "Overview layout"; +"overview_layout_subtitle" = "Choose how much information Overview shows."; +"overview_layout_detailed" = "Detailed"; +"overview_layout_compact" = "Providers, metrics & bars"; +"overview_layout_provider_bars" = "Providers & bars"; +"overview_layout_bars_only" = "Bars only"; +"overview_compact_no_bars" = "No usage bars"; "configure" = "Configure…"; "overview_enable_merge_icons_hint" = "Turn on Merge icons to configure Overview providers."; "overview_no_providers_hint" = "No enabled providers available for Overview."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 1c87172cb6..28f07c4fa2 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -608,6 +608,13 @@ "multi_account_layout_segmented" = "Segmentado"; "multi_account_layout_stacked" = "Apilado"; "overview_tab_providers_title" = "Proveedores de la pestaña Resumen"; +"overview_layout_title" = "Diseño de Resumen"; +"overview_layout_subtitle" = "Elige cuánta información muestra Resumen."; +"overview_layout_detailed" = "Detallado"; +"overview_layout_compact" = "Proveedores, métricas y barras"; +"overview_layout_provider_bars" = "Proveedores y barras"; +"overview_layout_bars_only" = "Solo barras"; +"overview_compact_no_bars" = "No hay barras de uso"; "configure" = "Configurar…"; "overview_enable_merge_icons_hint" = "Activa Combinar iconos para configurar los proveedores de la pestaña Resumen."; "overview_no_providers_hint" = "No hay proveedores activados disponibles para Resumen."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 8c5d961d18..a3dd95bf64 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -622,6 +622,13 @@ "multi_account_layout_segmented" = "بخش بندی شده"; "multi_account_layout_stacked" = "انباشته شده"; "overview_tab_providers_title" = "ارائه دهندگان تب مرور کلی"; +"overview_layout_title" = "چیدمان نمای کلی"; +"overview_layout_subtitle" = "میزان اطلاعات نمایش‌داده‌شده در نمای کلی را انتخاب کنید."; +"overview_layout_detailed" = "با جزئیات"; +"overview_layout_compact" = "ارائه‌دهندگان، معیارها و نوارها"; +"overview_layout_provider_bars" = "ارائه‌دهندگان و نوارها"; +"overview_layout_bars_only" = "فقط نوارها"; +"overview_compact_no_bars" = "نوار مصرفی وجود ندارد"; "configure" = "پیکربندی کن..."; "overview_enable_merge_icons_hint" = "فعال سازی Merge Icons برای پیکربندی ارائه دهندگان تب نمای کلی."; "overview_no_providers_hint" = "هیچ ارائه دهنده فعالی برای مرور کلی در دسترس نیست."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 4ea93f7e26..83b41f803e 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -616,6 +616,13 @@ "multi_account_layout_segmented" = "Segmenté"; "multi_account_layout_stacked" = "Empilé"; "overview_tab_providers_title" = "Fournisseurs d'onglets de présentation"; +"overview_layout_title" = "Disposition de la vue d’ensemble"; +"overview_layout_subtitle" = "Choisissez la quantité d’informations affichée dans la vue d’ensemble."; +"overview_layout_detailed" = "Détaillée"; +"overview_layout_compact" = "Fournisseurs, métriques et barres"; +"overview_layout_provider_bars" = "Fournisseurs et barres"; +"overview_layout_bars_only" = "Barres uniquement"; +"overview_compact_no_bars" = "Aucune barre d’utilisation"; "configure" = "Configurer…"; "overview_enable_merge_icons_hint" = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; "overview_no_providers_hint" = "Aucun fournisseur activé disponible pour la présentation."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index bfc0874c80..2573480f7a 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -595,6 +595,13 @@ "multi_account_layout_segmented" = "Segmentado"; "multi_account_layout_stacked" = "Apilado"; "overview_tab_providers_title" = "Provedores da lapela Resumo"; +"overview_layout_title" = "Deseño do Resumo"; +"overview_layout_subtitle" = "Escolle canta información mostra o Resumo."; +"overview_layout_detailed" = "Detallado"; +"overview_layout_compact" = "Provedores, métricas e barras"; +"overview_layout_provider_bars" = "Provedores e barras"; +"overview_layout_bars_only" = "Só barras"; +"overview_compact_no_bars" = "Non hai barras de uso"; "configure" = "Configurar…"; "overview_enable_merge_icons_hint" = "Activa Combinar as iconas para configurar os provedores da lapela Resumo."; "overview_no_providers_hint" = "Non hai provedores activados dispoñibles para o Resumo."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 44fc9b9242..2a5692c744 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -624,6 +624,13 @@ "multi_account_layout_segmented" = "Tersegmentasi"; "multi_account_layout_stacked" = "Bertumpuk"; "overview_tab_providers_title" = "Penyedia tab ikhtisar"; +"overview_layout_title" = "Tata letak Ikhtisar"; +"overview_layout_subtitle" = "Pilih seberapa banyak informasi yang ditampilkan Ikhtisar."; +"overview_layout_detailed" = "Terperinci"; +"overview_layout_compact" = "Penyedia, metrik, dan bilah"; +"overview_layout_provider_bars" = "Penyedia dan bilah"; +"overview_layout_bars_only" = "Hanya bilah"; +"overview_compact_no_bars" = "Tidak ada bilah penggunaan"; "configure" = "Konfigurasi…"; "overview_enable_merge_icons_hint" = "Aktifkan Gabung Ikon untuk mengonfigurasi penyedia tab Ikhtisar."; "overview_no_providers_hint" = "Tidak ada penyedia aktif untuk Ikhtisar."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 43e9260a1d..70cddcc969 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -624,6 +624,13 @@ "multi_account_layout_segmented" = "Segmentato"; "multi_account_layout_stacked" = "Impilato"; "overview_tab_providers_title" = "Provider della scheda Panoramica"; +"overview_layout_title" = "Layout Panoramica"; +"overview_layout_subtitle" = "Scegli quante informazioni mostrare in Panoramica."; +"overview_layout_detailed" = "Dettagliato"; +"overview_layout_compact" = "Provider, metriche e barre"; +"overview_layout_provider_bars" = "Provider e barre"; +"overview_layout_bars_only" = "Solo barre"; +"overview_compact_no_bars" = "Nessuna barra di utilizzo"; "configure" = "Configura…"; "overview_enable_merge_icons_hint" = "Abilita Unisci icone per configurare i provider della scheda Panoramica."; "overview_no_providers_hint" = "Nessun provider attivo disponibile per Panoramica."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 66c8f11455..3fbf621cda 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -613,6 +613,13 @@ "multi_account_layout_segmented" = "セグメント"; "multi_account_layout_stacked" = "スタック"; "overview_tab_providers_title" = "概要タブのプロバイダ"; +"overview_layout_title" = "概要のレイアウト"; +"overview_layout_subtitle" = "概要に表示する情報量を選択します。"; +"overview_layout_detailed" = "詳細"; +"overview_layout_compact" = "プロバイダー、指標、バー"; +"overview_layout_provider_bars" = "プロバイダーとバー"; +"overview_layout_bars_only" = "バーのみ"; +"overview_compact_no_bars" = "使用量バーはありません"; "configure" = "設定…"; "overview_enable_merge_icons_hint" = "概要タブのプロバイダを設定するには「アイコンを統合」を有効にしてください。"; "overview_no_providers_hint" = "概要に使用できる有効なプロバイダがありません。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index bf8a6aaabf..dca09ef579 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -605,6 +605,13 @@ "multi_account_layout_segmented" = "분할"; "multi_account_layout_stacked" = "쌓기"; "overview_tab_providers_title" = "개요 탭 공급자"; +"overview_layout_title" = "개요 레이아웃"; +"overview_layout_subtitle" = "개요에 표시할 정보의 양을 선택합니다."; +"overview_layout_detailed" = "자세히"; +"overview_layout_compact" = "제공자, 지표 및 막대"; +"overview_layout_provider_bars" = "제공자 및 막대"; +"overview_layout_bars_only" = "막대만"; +"overview_compact_no_bars" = "사용량 막대 없음"; "configure" = "구성…"; "overview_enable_merge_icons_hint" = "개요 탭 공급자를 구성하려면 아이콘 병합을 사용하세요."; "overview_no_providers_hint" = "개요에 사용할 수 있는 활성화된 공급자가 없습니다."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index c499928c92..fc6494a1d3 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -616,6 +616,13 @@ "multi_account_layout_segmented" = "Gesegmenteerd"; "multi_account_layout_stacked" = "Gestapeld"; "overview_tab_providers_title" = "Overzicht tabblad aanbieders"; +"overview_layout_title" = "Overzichtsindeling"; +"overview_layout_subtitle" = "Kies hoeveel informatie het overzicht toont."; +"overview_layout_detailed" = "Gedetailleerd"; +"overview_layout_compact" = "Providers, metrieken en balken"; +"overview_layout_provider_bars" = "Providers en balken"; +"overview_layout_bars_only" = "Alleen balken"; +"overview_compact_no_bars" = "Geen gebruiksbalken"; "configure" = "Configureer…"; "overview_enable_merge_icons_hint" = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; "overview_no_providers_hint" = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 359c165c2b..75ab54f2ba 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -624,6 +624,13 @@ "multi_account_layout_segmented" = "Segmentowy"; "multi_account_layout_stacked" = "Ułożony"; "overview_tab_providers_title" = "Dostawcy zakładki Przegląd"; +"overview_layout_title" = "Układ Przeglądu"; +"overview_layout_subtitle" = "Wybierz, ile informacji ma wyświetlać Przegląd."; +"overview_layout_detailed" = "Szczegółowy"; +"overview_layout_compact" = "Dostawcy, metryki i paski"; +"overview_layout_provider_bars" = "Dostawcy i paski"; +"overview_layout_bars_only" = "Tylko paski"; +"overview_compact_no_bars" = "Brak pasków użycia"; "configure" = "Skonfiguruj…"; "overview_enable_merge_icons_hint" = "Włącz Scal ikony, aby skonfigurować dostawców zakładki Przegląd."; "overview_no_providers_hint" = "Brak włączonych dostawców dla Przeglądu."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 498a44f8b1..ff563397d1 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -613,6 +613,13 @@ "multi_account_layout_segmented" = "Segmentado"; "multi_account_layout_stacked" = "Empilhado"; "overview_tab_providers_title" = "Provedores da aba Visão geral"; +"overview_layout_title" = "Layout da Visão geral"; +"overview_layout_subtitle" = "Escolha quanta informação a Visão geral mostra."; +"overview_layout_detailed" = "Detalhado"; +"overview_layout_compact" = "Provedores, métricas e barras"; +"overview_layout_provider_bars" = "Provedores e barras"; +"overview_layout_bars_only" = "Somente barras"; +"overview_compact_no_bars" = "Sem barras de uso"; "configure" = "Configurar…"; "overview_enable_merge_icons_hint" = "Ative Mesclar Ícones para configurar provedores da aba Visão geral."; "overview_no_providers_hint" = "Nenhum provedor ativado disponível para Visão geral."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index c8f81499f9..4d8ca0b7d3 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -617,6 +617,13 @@ "multi_account_layout_segmented" = "Сегментированный"; "multi_account_layout_stacked" = "Стопкой"; "overview_tab_providers_title" = "Провайдеры вкладки «Обзор»"; +"overview_layout_title" = "Макет обзора"; +"overview_layout_subtitle" = "Выберите, сколько информации показывать в обзоре."; +"overview_layout_detailed" = "Подробный"; +"overview_layout_compact" = "Провайдеры, показатели и индикаторы"; +"overview_layout_provider_bars" = "Провайдеры и индикаторы"; +"overview_layout_bars_only" = "Только индикаторы"; +"overview_compact_no_bars" = "Нет индикаторов использования"; "configure" = "Настроить…"; "overview_enable_merge_icons_hint" = "Включите «Объединять значки», чтобы настроить провайдеров вкладки «Обзор»."; "overview_no_providers_hint" = "Нет включённых провайдеров для обзора."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 18e05568ce..2e9d05ca56 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -615,6 +615,13 @@ "multi_account_layout_segmented" = "Segmenterad"; "multi_account_layout_stacked" = "Staplad"; "overview_tab_providers_title" = "Leverantörer på översiktsfliken"; +"overview_layout_title" = "Översiktslayout"; +"overview_layout_subtitle" = "Välj hur mycket information som visas i översikten."; +"overview_layout_detailed" = "Detaljerad"; +"overview_layout_compact" = "Leverantörer, mätvärden och staplar"; +"overview_layout_provider_bars" = "Leverantörer och staplar"; +"overview_layout_bars_only" = "Endast staplar"; +"overview_compact_no_bars" = "Inga användningsstaplar"; "configure" = "Konfigurera…"; "overview_enable_merge_icons_hint" = "Aktivera Slå ihop ikoner för att konfigurera leverantörer på översiktsfliken."; "overview_no_providers_hint" = "Inga aktiverade leverantörer är tillgängliga för översikten."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index e66f0bd1b5..f007930a90 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -622,6 +622,13 @@ "multi_account_layout_segmented" = "แบ่งกลุ่ม"; "multi_account_layout_stacked" = "ซ้อนกัน"; "overview_tab_providers_title" = "ผู้ให้บริการแท็บภาพรวม"; +"overview_layout_title" = "เค้าโครงภาพรวม"; +"overview_layout_subtitle" = "เลือกปริมาณข้อมูลที่จะแสดงในภาพรวม"; +"overview_layout_detailed" = "แบบละเอียด"; +"overview_layout_compact" = "ผู้ให้บริการ เมตริก และแถบ"; +"overview_layout_provider_bars" = "ผู้ให้บริการและแถบ"; +"overview_layout_bars_only" = "เฉพาะแถบ"; +"overview_compact_no_bars" = "ไม่มีแถบการใช้งาน"; "configure" = "กําหนดค่า..."; "overview_enable_merge_icons_hint" = "เปิดใช้งานไอคอนผสานเพื่อกําหนดค่าผู้ให้บริการแท็บภาพรวม"; "overview_no_providers_hint" = "ไม่มีผู้ให้บริการที่เปิดใช้งานสําหรับภาพรวม"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index f5f134d5a4..93b03e5e2b 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -622,6 +622,13 @@ "multi_account_layout_segmented" = "Bölümlü"; "multi_account_layout_stacked" = "Yığınlı"; "overview_tab_providers_title" = "Genel Bakış sekmesi sağlayıcıları"; +"overview_layout_title" = "Genel Bakış düzeni"; +"overview_layout_subtitle" = "Genel Bakış’ın ne kadar bilgi göstereceğini seçin."; +"overview_layout_detailed" = "Ayrıntılı"; +"overview_layout_compact" = "Sağlayıcılar, metrikler ve çubuklar"; +"overview_layout_provider_bars" = "Sağlayıcılar ve çubuklar"; +"overview_layout_bars_only" = "Yalnızca çubuklar"; +"overview_compact_no_bars" = "Kullanım çubuğu yok"; "configure" = "Yapılandır…"; "overview_enable_merge_icons_hint" = "Genel Bakış sekmesi sağlayıcılarını yapılandırmak için Simgeleri Birleştir'i etkinleştirin."; "overview_no_providers_hint" = "Genel Bakış için kullanılabilir etkin sağlayıcı yok."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 6dd0467cc8..1664c7eed0 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -616,6 +616,13 @@ "multi_account_layout_segmented" = "Сегментований"; "multi_account_layout_stacked" = "складені"; "overview_tab_providers_title" = "Постачальники вкладок огляду"; +"overview_layout_title" = "Компонування огляду"; +"overview_layout_subtitle" = "Виберіть, скільки інформації показувати в огляді."; +"overview_layout_detailed" = "Докладно"; +"overview_layout_compact" = "Провайдери, показники й смуги"; +"overview_layout_provider_bars" = "Провайдери й смуги"; +"overview_layout_bars_only" = "Лише смуги"; +"overview_compact_no_bars" = "Немає смуг використання"; "configure" = "Налаштувати…"; "overview_enable_merge_icons_hint" = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; "overview_no_providers_hint" = "Немає активованих постачальників, доступних для огляду."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index da7c7b8225..0c116d433e 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -612,6 +612,13 @@ "multi_account_layout_segmented" = "Được phân đoạn"; "multi_account_layout_stacked" = "Xếp chồng"; "overview_tab_providers_title" = "Nhà cung cấp tab tổng quan"; +"overview_layout_title" = "Bố cục Tổng quan"; +"overview_layout_subtitle" = "Chọn lượng thông tin hiển thị trong Tổng quan."; +"overview_layout_detailed" = "Chi tiết"; +"overview_layout_compact" = "Nhà cung cấp, chỉ số và thanh"; +"overview_layout_provider_bars" = "Nhà cung cấp và thanh"; +"overview_layout_bars_only" = "Chỉ thanh"; +"overview_compact_no_bars" = "Không có thanh mức sử dụng"; "configure" = "Định cấu hình…"; "overview_enable_merge_icons_hint" = "Bật Hợp nhất Biểu tượng để định cấu hình nhà cung cấp tab Tổng quan."; "overview_no_providers_hint" = "Không có nhà cung cấp nào được bật cho phần Tổng quan."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 86f2f29e30..8c041df437 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -595,6 +595,13 @@ "multi_account_layout_segmented" = "分段"; "multi_account_layout_stacked" = "堆叠"; "overview_tab_providers_title" = "概览标签提供商"; +"overview_layout_title" = "概览布局"; +"overview_layout_subtitle" = "选择“概览”显示的信息量。"; +"overview_layout_detailed" = "详细"; +"overview_layout_compact" = "提供商、指标和使用量条"; +"overview_layout_provider_bars" = "提供商和使用量条"; +"overview_layout_bars_only" = "仅使用量条"; +"overview_compact_no_bars" = "无使用量条"; "configure" = "配置…"; "overview_enable_merge_icons_hint" = "启用“合并图标”以配置“概览”标签中的提供商。"; "overview_no_providers_hint" = "“概览”中没有可用的已启用提供商。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index ef5520f2df..83a0f9fc5e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -615,6 +615,13 @@ "multi_account_layout_segmented" = "分段"; "multi_account_layout_stacked" = "堆疊"; "overview_tab_providers_title" = "概覽標籤提供者"; +"overview_layout_title" = "概覽版面"; +"overview_layout_subtitle" = "選擇「概覽」顯示的資訊量。"; +"overview_layout_detailed" = "詳細"; +"overview_layout_compact" = "供應商、指標與用量列"; +"overview_layout_provider_bars" = "供應商與用量列"; +"overview_layout_bars_only" = "僅用量列"; +"overview_compact_no_bars" = "沒有用量列"; "configure" = "設定…"; "overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; "overview_no_providers_hint" = "「概覽」中沒有可用的已啟用提供者。"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 78f887fc1e..ff2ba0ab59 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -748,6 +748,17 @@ extension SettingsStore { } } + var mergedOverviewLayout: MergedOverviewLayout { + get { + MergedOverviewLayout(rawValue: self.defaultsState.mergedOverviewLayoutRaw) ?? .detailed + } + set { + self.defaultsState.mergedOverviewLayoutRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "mergedOverviewLayout") + self.userDefaults.set(newValue.usesReducedContent, forKey: "mergedOverviewUsesCompactLayout") + } + } + var switcherShowsIcons: Bool { get { self.defaultsState.switcherShowsIcons } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index b4659773cd..dada7eb142 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -88,6 +88,7 @@ extension SettingsStore { _ = self.zoomMateCookieSource _ = self.ollamaCookieSource _ = self.mergeIcons + _ = self.mergedOverviewLayout _ = self.switcherShowsIcons _ = self.mergedOverviewSelectedProviders _ = self.zaiAPIToken diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 0fb31a2717..d655d48c30 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -39,6 +39,30 @@ enum UsageBarsFillOption: String, CaseIterable { } } +enum MergedOverviewLayout: String, CaseIterable, Identifiable { + case detailed + case compact + case providerBars + case barsOnly + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .detailed: L("overview_layout_detailed") + case .compact: L("overview_layout_compact") + case .providerBars: L("overview_layout_provider_bars") + case .barsOnly: L("overview_layout_bars_only") + } + } + + var usesReducedContent: Bool { + self != .detailed + } +} + enum ResetTimesOption: String, CaseIterable { case countdown case clock diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6970f1a131..f273da4a53 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -524,6 +524,18 @@ extension SettingsStore { } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true + let mergedOverviewLayoutRaw: String = if let stored = userDefaults.string(forKey: "mergedOverviewLayout") { + stored + } else if let legacyCompact = userDefaults.object(forKey: "mergedOverviewUsesCompactLayout") as? Bool { + legacyCompact ? MergedOverviewLayout.compact.rawValue : MergedOverviewLayout.detailed.rawValue + } else { + MergedOverviewLayout.detailed.rawValue + } + if userDefaults.string(forKey: "mergedOverviewLayout") == nil, + userDefaults.object(forKey: "mergedOverviewUsesCompactLayout") != nil + { + userDefaults.set(mergedOverviewLayoutRaw, forKey: "mergedOverviewLayout") + } let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let mergedMenuLastSelectedWasOverview = userDefaults.object( forKey: "mergedMenuLastSelectedWasOverview") as? Bool ?? false @@ -611,6 +623,7 @@ extension SettingsStore { providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, + mergedOverviewLayoutRaw: mergedOverviewLayoutRaw, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 5e49bc9a62..5948df359d 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -64,6 +64,7 @@ struct SettingsDefaultsState { var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool + var mergedOverviewLayoutRaw: String var switcherShowsIcons: Bool var mergedMenuLastSelectedWasOverview: Bool var mergedOverviewSelectedProvidersRaw: [String] diff --git a/Sources/CodexBar/StatusItemController+CompactOverview.swift b/Sources/CodexBar/StatusItemController+CompactOverview.swift new file mode 100644 index 0000000000..6273997eed --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CompactOverview.swift @@ -0,0 +1,154 @@ +import AppKit +import CodexBarCore +import QuartzCore +import SwiftUI + +extension StatusItemController { + static let overviewBarsOnlySpacerIdentifierPrefix = "overviewBarsOnlySpacer-" + + @discardableResult + func addOverviewRows( + to menu: NSMenu, + enabledProviders: [UsageProvider], + menuWidth: CGFloat, + captureMenu: NSMenu? = nil) -> Bool + { + // Rows may be built into a detached scratch menu for in-place reconciliation; + // interaction closures must always reference the live menu they end up serving. + let interactionMenu = captureMenu ?? menu + let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( + activeProviders: enabledProviders) + let overviewLayout = self.settings.mergedOverviewLayout + let usesReducedContent = overviewLayout.usesReducedContent + let rows: [( + provider: UsageProvider, + model: UsageMenuCardView.Model, + layoutModel: UsageMenuCardView.Model, + projection: CompactOverviewProjection?)] = overviewProviders + .compactMap { provider in + guard let model = self.menuCardModel(for: provider) else { return nil } + guard !model.isOverviewErrorOnly else { return nil } + let layoutModel = if usesReducedContent, model.usesLiveSubtitle { + self.menuCardRefreshMonitor.model(for: provider, fallback: model) + } else { + model + } + return ( + provider: provider, + model: model, + layoutModel: layoutModel, + projection: usesReducedContent ? CompactOverviewProjection(model: layoutModel) : nil) + } + guard !rows.isEmpty else { return false } + + let compactLayout: CompactOverviewLayout? = if usesReducedContent { + CompactOverviewLayout.resolveForMenu( + menuWidth: menuWidth, + layoutDirection: codexBarUsesRightToLeftLayout() + ? .rightToLeft + : .leftToRight) + } else { + nil + } + let rowStyle = OverviewMenuRowStyle(layout: overviewLayout) + + let t0 = CACurrentMediaTime() + defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + + if rowStyle == .barsOnly { + menu.addItem(self.makeBarsOnlySectionSpacer(width: menuWidth, edge: "leading")) + } + for (index, row) in rows.enumerated() { + let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" + let storageText = rowStyle == .detailed + ? self.store.storageFootprintText(for: row.provider) + : nil + let submenu = self.makeOverviewRowSubmenu( + provider: row.provider, + model: row.model, + width: menuWidth) + let heightFingerprint: String = switch rowStyle { + case .detailed: + row.model.heightFingerprint( + section: "overviewDetailed", + additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]) + case .compact: + row.projection?.heightFingerprint( + section: "overviewCompact", + layoutSignature: compactLayout?.signature ?? "missing") ?? "overviewCompact:missing" + case .providerBars: + row.projection?.heightFingerprint( + section: "overviewProviderBars", + layoutSignature: compactLayout?.signature ?? "missing") ?? "overviewProviderBars:missing" + case .barsOnly: + row.projection?.heightFingerprint( + section: "overviewBarsOnly", + layoutSignature: compactLayout?.signature ?? "missing") + ?? "overviewBarsOnly:missing" + } + let accessibilityLabel = rowStyle.usesReducedContent + ? row.projection?.accessibilityLabel ?? row.layoutModel.providerName + : row.model.providerName + let item = self.makeMenuCardItem( + OverviewMenuCardRowView( + model: row.model, + layoutModel: row.layoutModel, + storageText: storageText, + width: menuWidth, + style: rowStyle, + compactLayout: compactLayout), + id: identifier, + width: menuWidth, + heightCacheScope: row.provider.rawValue, + heightCacheFingerprint: heightFingerprint, + submenu: submenu, + showsSubmenuIndicator: rowStyle != .barsOnly, + submenuIndicatorAlignment: rowStyle == .barsOnly ? .trailing : .topTrailing, + submenuIndicatorTopPadding: rowStyle == .barsOnly ? 0 : 8, + containsInteractiveControls: OverviewMenuRowInteractionPolicy.containsInteractiveControls( + style: rowStyle, + model: row.model), + // Reduced Overview rows publish their live accessibility label through the row host's store. + usesGPUSelection: true, + layoutDirection: rowStyle.usesReducedContent ? compactLayout?.layoutDirection : nil, + accessibilityLabel: accessibilityLabel, + accessibilityUserInputLabels: rowStyle.usesReducedContent + ? [row.layoutModel.providerName] + : nil, + accessibilityHelp: rowStyle.usesReducedContent ? L("Show details") : nil, + onClick: { [weak self, weak interactionMenu] in + guard let self, let interactionMenu else { return } + self.selectOverviewProvider(row.provider, menu: interactionMenu) + }) + if submenu == nil { + // Keep plain rows wired for keyboard activation and accessibility action paths. + item.target = self + item.action = #selector(self.selectOverviewProvider(_:)) + } + menu.addItem(item) + if rowStyle != .barsOnly, index < rows.count - 1 { + menu.addItem(.separator()) + } + } + if rowStyle == .barsOnly { + menu.addItem(self.makeBarsOnlySectionSpacer(width: menuWidth, edge: "trailing")) + } + return true + } + + private func makeBarsOnlySectionSpacer(width: CGFloat, edge: String) -> NSMenuItem { + let view = NSView(frame: NSRect( + x: 0, + y: 0, + width: width, + height: CompactOverviewLayout.barsOnlySectionSpacerHeight)) + view.autoresizingMask = [.width] + view.setAccessibilityElement(false) + + let item = NSMenuItem() + item.view = view + item.isEnabled = false + item.representedObject = "\(Self.overviewBarsOnlySpacerIdentifierPrefix)\(edge)" + return item + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index d4b74dcef3..1791dc08d4 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -269,7 +269,8 @@ extension StatusItemController { let menuWidth = self.menuCardWidth( for: enabledProviders, selectedProvider: selectedProvider, - descriptor: descriptor) + descriptor: descriptor, + overviewLayout: isOverviewSelected ? self.settings.mergedOverviewLayout : .detailed) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } @@ -546,64 +547,6 @@ extension StatusItemController { menu.addItem(.separator()) } - @discardableResult - private func addOverviewRows( - to menu: NSMenu, - enabledProviders: [UsageProvider], - menuWidth: CGFloat, - captureMenu: NSMenu? = nil) -> Bool - { - // Rows may be built into a detached scratch menu for in-place reconciliation; - // interaction closures must always reference the live menu they end up serving. - let interactionMenu = captureMenu ?? menu - let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( - activeProviders: enabledProviders) - let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders - .compactMap { provider in - guard let model = self.menuCardModel(for: provider) else { return nil } - guard !model.isOverviewErrorOnly else { return nil } - return (provider: provider, model: model) - } - guard !rows.isEmpty else { return false } - - let t0 = CACurrentMediaTime() - defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } - - for (index, row) in rows.enumerated() { - let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" - let storageText = self.store.storageFootprintText(for: row.provider) - let submenu = self.makeOverviewRowSubmenu( - provider: row.provider, - model: row.model, - width: menuWidth) - let item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), - id: identifier, - width: menuWidth, - heightCacheScope: row.provider.rawValue, - heightCacheFingerprint: row.model.heightFingerprint( - section: "overview", - additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), - submenu: submenu, - containsInteractiveControls: row.model.subtitleStyle == .error || row.model.usesLiveSubtitle, - usesGPUSelection: true, - onClick: { [weak self, weak interactionMenu] in - guard let self, let interactionMenu else { return } - self.selectOverviewProvider(row.provider, menu: interactionMenu) - }) - if submenu == nil { - // Keep plain rows wired for keyboard activation and accessibility action paths. - item.target = self - item.action = #selector(self.selectOverviewProvider(_:)) - } - menu.addItem(item) - if index < rows.count - 1 { - menu.addItem(.separator()) - } - } - return true - } - private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 51da0b8319..f0bca4eee6 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -1,6 +1,15 @@ import AppKit import SwiftUI +enum MenuCardItemSizing { + static let baseHeightPadding: CGFloat = 6 + static let descenderSafety: CGFloat = 1 + + static var measuredHeightPadding: CGFloat { + self.baseHeightPadding + self.descenderSafety + } +} + extension StatusItemController { func refreshMenuCardHeights(in menu: NSMenu) { let width = self.renderedMenuWidth(for: menu) @@ -30,10 +39,15 @@ extension StatusItemController { heightCacheScope: String? = nil, heightCacheFingerprint: String? = nil, submenu: NSMenu? = nil, + showsSubmenuIndicator: Bool = true, submenuIndicatorAlignment: Alignment = .topTrailing, submenuIndicatorTopPadding: CGFloat = 8, containsInteractiveControls: Bool = false, usesGPUSelection: Bool = false, + layoutDirection: LayoutDirection? = nil, + accessibilityLabel: String? = nil, + accessibilityUserInputLabels: [String]? = nil, + accessibilityHelp: String? = nil, onClick: (() -> Void)? = nil) -> NSMenuItem { let allowsMenuHighlight = submenu != nil || onClick != nil @@ -54,12 +68,16 @@ extension StatusItemController { // standard and GPU-selection payloads in place instead of detaching `item.view`. let payload = MenuCardRowPayload( content: AnyView(view), - showsSubmenuIndicator: submenu != nil, + showsSubmenuIndicator: showsSubmenuIndicator && submenu != nil, submenuIndicatorAlignment: submenuIndicatorAlignment, submenuIndicatorTopPadding: submenuIndicatorTopPadding, allowsMenuHighlight: allowsMenuHighlight, containsInteractiveControls: containsInteractiveControls, usesGPUSelection: usesGPUSelection, + layoutDirection: layoutDirection, + accessibilityLabel: accessibilityLabel, + accessibilityUserInputLabels: accessibilityUserInputLabels, + accessibilityHelp: accessibilityHelp, onClick: onClick) let hosting: ErasedMenuCardHostingView if let recycled = self.takeRecyclableMenuCardView( @@ -113,15 +131,12 @@ extension StatusItemController { } private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat { - let basePadding: CGFloat = 6 - let descenderSafety: CGFloat = 1 - if let measured = view as? MenuCardMeasuring { - return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety)) + return max(1, ceil(measured.measuredHeight(width: width) + MenuCardItemSizing.measuredHeightPadding)) } view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) let fitted = view.fittingSize - return max(1, ceil(fitted.height + basePadding + descenderSafety)) + return max(1, ceil(fitted.height + MenuCardItemSizing.measuredHeightPadding)) } } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 748c6e4864..7a9fbf80af 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -142,7 +142,39 @@ struct MenuCardRowPayload { let allowsMenuHighlight: Bool let containsInteractiveControls: Bool let usesGPUSelection: Bool + let layoutDirection: LayoutDirection? + let accessibilityLabel: String? + let accessibilityUserInputLabels: [String]? + let accessibilityHelp: String? let onClick: (() -> Void)? + + init( + content: AnyView, + showsSubmenuIndicator: Bool, + submenuIndicatorAlignment: Alignment, + submenuIndicatorTopPadding: CGFloat, + allowsMenuHighlight: Bool, + containsInteractiveControls: Bool, + usesGPUSelection: Bool, + layoutDirection: LayoutDirection? = nil, + accessibilityLabel: String? = nil, + accessibilityUserInputLabels: [String]? = nil, + accessibilityHelp: String? = nil, + onClick: (() -> Void)?) + { + self.content = content + self.showsSubmenuIndicator = showsSubmenuIndicator + self.submenuIndicatorAlignment = submenuIndicatorAlignment + self.submenuIndicatorTopPadding = submenuIndicatorTopPadding + self.allowsMenuHighlight = allowsMenuHighlight + self.containsInteractiveControls = containsInteractiveControls + self.usesGPUSelection = usesGPUSelection + self.layoutDirection = layoutDirection + self.accessibilityLabel = accessibilityLabel + self.accessibilityUserInputLabels = accessibilityUserInputLabels + self.accessibilityHelp = accessibilityHelp + self.onClick = onClick + } } /// Inner SwiftUI host used by every card row. The outer container owns AppKit event handling and, @@ -159,6 +191,7 @@ private final class MenuRowContentHostingView: NSHostingView String? { + self.accessibilityLabelStore.label ?? super.accessibilityLabel() + } + override func accessibilityPerformPress() -> Bool { guard let onClick = self.onClick else { return super.accessibilityPerformPress() @@ -484,20 +527,29 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin payload: MenuCardRowPayload, highlightState: MenuCardHighlightState, refreshMonitor: MenuCardRefreshMonitor?, - interactiveRegionStore: MenuCardInteractiveRegionStore) -> MenuCardSectionContainerView + interactiveRegionStore: MenuCardInteractiveRegionStore, + accessibilityLabelStore: MenuCardAccessibilityLabelStore) -> MenuCardSectionContainerView { MenuCardSectionContainerView( highlightState: highlightState, showsSubmenuIndicator: payload.showsSubmenuIndicator, submenuIndicatorAlignment: payload.submenuIndicatorAlignment, submenuIndicatorTopPadding: payload.submenuIndicatorTopPadding, + layoutDirection: payload.layoutDirection, refreshMonitor: refreshMonitor, - interactiveRegionStore: interactiveRegionStore) + interactiveRegionStore: interactiveRegionStore, + accessibilityLabelStore: accessibilityLabelStore) { payload.content } } + private func applyAccessibilityPayload() { + self.accessibilityLabelStore.label = self.rowPayload.accessibilityLabel + self.setAccessibilityUserInputLabels(self.rowPayload.accessibilityUserInputLabels) + self.setAccessibilityHelp(self.rowPayload.accessibilityHelp) + } + private func primaryPressDecision(for event: NSEvent) -> Bool? { guard event.type == .leftMouseUp else { return nil } return self.bounds.contains(self.locationInView(for: event)) @@ -806,6 +858,10 @@ extension MenuRowContainerView { var hasGPUSelectionLayerForTesting: Bool { self.selectionView != nil } + + var showsSubmenuIndicatorForTesting: Bool { + self.rowPayload.showsSubmenuIndicator + } } #endif @@ -814,25 +870,32 @@ struct MenuCardSectionContainerView: View { let showsSubmenuIndicator: Bool let submenuIndicatorAlignment: Alignment let submenuIndicatorTopPadding: CGFloat + let layoutDirectionOverride: LayoutDirection? var refreshMonitor: MenuCardRefreshMonitor? var interactiveRegionStore: MenuCardInteractiveRegionStore? + var accessibilityLabelStore: MenuCardAccessibilityLabelStore? @ViewBuilder let content: () -> Content + @Environment(\.layoutDirection) private var inheritedLayoutDirection init( highlightState: MenuCardHighlightState, showsSubmenuIndicator: Bool, submenuIndicatorAlignment: Alignment, submenuIndicatorTopPadding: CGFloat, + layoutDirection: LayoutDirection? = nil, refreshMonitor: MenuCardRefreshMonitor?, interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, + accessibilityLabelStore: MenuCardAccessibilityLabelStore? = nil, @ViewBuilder content: @escaping () -> Content) { self.highlightState = highlightState self.showsSubmenuIndicator = showsSubmenuIndicator self.submenuIndicatorAlignment = submenuIndicatorAlignment self.submenuIndicatorTopPadding = submenuIndicatorTopPadding + self.layoutDirectionOverride = layoutDirection self.refreshMonitor = refreshMonitor self.interactiveRegionStore = interactiveRegionStore + self.accessibilityLabelStore = accessibilityLabelStore self.content = content } @@ -844,6 +907,10 @@ struct MenuCardSectionContainerView: View { .onPreferenceChange(MenuCardInteractiveRegionPreferenceKey.self) { regions in self.interactiveRegionStore?.regions = regions } + .onPreferenceChange(MenuCardAccessibilityLabelPreferenceKey.self) { label in + guard let label else { return } + self.accessibilityLabelStore?.label = label + } .foregroundStyle(MenuHighlightStyle.primary(self.highlightState.isHighlighted)) .background(alignment: .topLeading) { if self.highlightState.isHighlighted { @@ -855,13 +922,22 @@ struct MenuCardSectionContainerView: View { } .overlay(alignment: self.submenuIndicatorAlignment) { if self.showsSubmenuIndicator { - Image(systemName: "chevron.right") + Image(systemName: Self.submenuIndicatorSystemName(for: self.effectiveLayoutDirection)) .font(.caption2.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.secondary(self.highlightState.isHighlighted)) .padding(.top, self.submenuIndicatorTopPadding) .padding(.trailing, 10) } } + .environment(\.layoutDirection, self.effectiveLayoutDirection) + } + + private var effectiveLayoutDirection: LayoutDirection { + self.layoutDirectionOverride ?? self.inheritedLayoutDirection + } + + static func submenuIndicatorSystemName(for layoutDirection: LayoutDirection) -> String { + layoutDirection == .rightToLeft ? "chevron.left" : "chevron.right" } } @@ -881,6 +957,24 @@ final class MenuCardInteractiveRegionStore { } } +@MainActor +@Observable +final class MenuCardAccessibilityLabelStore { + var label: String? + + init(label: String?) { + self.label = label + } +} + +struct MenuCardAccessibilityLabelPreferenceKey: PreferenceKey { + static let defaultValue: String? = nil + + static func reduce(value: inout String?, nextValue: () -> String?) { + value = nextValue() ?? value + } +} + struct MenuCardInteractiveRegionPreferenceKey: PreferenceKey { static let coordinateSpaceName = "MenuCardInteractiveRegion" static let defaultValue: [CGRect] = [] diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 6f8adbcdef..23634fa996 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -122,6 +122,14 @@ extension StatusItemController { "claudeSwapRevision=\(self.store.claudeSwapRevision)", ] + if self.shouldMergeIcons, self.settings.mergedMenuLastSelectedWasOverview { + let overviewLayout = self.settings.mergedOverviewLayout + parts.append("overviewLayout=\(overviewLayout.rawValue)") + if overviewLayout.usesReducedContent { + parts.append("compactOverview=\(self.compactOverviewStructuralSignature())") + } + } + for provider in self.store.enabledProvidersForDisplay() { let tokenSignature = self.tokenSnapshotReadinessSignature(for: provider) let usageHistoryVisible = self.store.supportsPlanUtilizationHistory(for: provider) && @@ -139,6 +147,30 @@ extension StatusItemController { return parts.joined(separator: "|") } + func compactOverviewStructuralSignature() -> String { + let providers = self.settings.resolvedMergedOverviewProviders( + activeProviders: self.store.enabledProvidersForDisplay(), + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + let providerSignature = providers.map { provider in + guard let model = self.menuCardModel(for: provider) else { + return "\(provider.rawValue):missing" + } + guard !model.isOverviewErrorOnly else { + return "\(provider.rawValue):error-only" + } + let layoutModel = model.usesLiveSubtitle + ? self.menuCardRefreshMonitor.model(for: provider, fallback: model) + : model + let projection = CompactOverviewProjection(model: layoutModel) + return [ + provider.rawValue, + UsageMenuCardView.Model.heightFingerprintField("providerName", projection.providerName), + projection.layoutSignature, + ].map { "\($0.utf8.count):\($0)" }.joined(separator: "|") + }.joined(separator: ";") + return "layout=\(self.settings.mergedOverviewLayout.rawValue)|\(providerSignature)" + } + static func dashboardBreakdownReadinessSignature( _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String { diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index ada2187c6b..8649c16adc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -21,19 +21,131 @@ extension ProviderSwitcherSelection { } } +enum OverviewMenuRowStyle: Equatable { + case detailed + case compact + case providerBars + case barsOnly + + init(layout: MergedOverviewLayout) { + self = switch layout { + case .detailed: .detailed + case .compact: .compact + case .providerBars: .providerBars + case .barsOnly: .barsOnly + } + } + + var usesReducedContent: Bool { + self != .detailed + } +} + +enum OverviewMenuRowInteractionPolicy { + static func containsInteractiveControls( + style: OverviewMenuRowStyle, + model: UsageMenuCardView.Model) -> Bool + { + style == .detailed && (model.subtitleStyle == .error || model.usesLiveSubtitle) + } +} + +enum CompactOverviewProjectionResolver { + static func resolve( + fallbackModel: UsageMenuCardView.Model, + layoutModel: UsageMenuCardView.Model?, + liveModel: () -> UsageMenuCardView.Model) -> CompactOverviewProjection + { + if let layoutModel, layoutModel.provider != fallbackModel.provider { + return CompactOverviewProjection(model: fallbackModel) + } + let resolvedLayoutModel = layoutModel ?? fallbackModel + let layoutProjection = CompactOverviewProjection(model: resolvedLayoutModel) + guard fallbackModel.usesLiveSubtitle else { return layoutProjection } + + let resolvedLiveModel = liveModel() + let liveProjection = CompactOverviewProjection(model: resolvedLiveModel) + guard resolvedLiveModel.provider == fallbackModel.provider, + liveProjection.providerName == layoutProjection.providerName, + liveProjection.layoutSignature == layoutProjection.layoutSignature + else { + return layoutProjection + } + return liveProjection + } +} + struct OverviewMenuCardRowView: View { + static let showsHeaderDivider = true static let showsSectionDividers = false let model: UsageMenuCardView.Model + let layoutModel: UsageMenuCardView.Model? let storageText: String? let width: CGFloat + let style: OverviewMenuRowStyle + let compactLayout: CompactOverviewLayout? @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor + + init( + model: UsageMenuCardView.Model, + layoutModel: UsageMenuCardView.Model? = nil, + storageText: String?, + width: CGFloat, + style: OverviewMenuRowStyle = .detailed, + compactLayout: CompactOverviewLayout? = nil) + { + self.model = model + self.layoutModel = layoutModel + self.storageText = storageText + self.width = width + self.style = style + self.compactLayout = compactLayout + } var body: some View { + switch self.style { + case .detailed: + self.detailedContent + case .compact: + if let compactLayout = self.compactLayout { + let projection = self.compactProjection + CompactOverviewLabeledContent( + projection: projection, + layout: compactLayout) + .preference( + key: MenuCardAccessibilityLabelPreferenceKey.self, + value: projection.accessibilityLabel) + } + case .providerBars: + if let compactLayout = self.compactLayout { + let projection = self.compactProjection + CompactOverviewProviderBarsContent( + projection: projection, + layout: compactLayout) + .preference( + key: MenuCardAccessibilityLabelPreferenceKey.self, + value: projection.accessibilityLabel) + } + case .barsOnly: + if let compactLayout = self.compactLayout { + let projection = self.compactProjection + CompactOverviewBarsOnlyContent( + projection: projection, + layout: compactLayout) + .preference( + key: MenuCardAccessibilityLabelPreferenceKey.self, + value: projection.accessibilityLabel) + } + } + } + + private var detailedContent: some View { VStack(alignment: .leading, spacing: 0) { UsageMenuCardHeaderSectionView( model: self.model, - showDivider: Self.showsSectionDividers && self.hasUsageBlock, + showDivider: Self.showsHeaderDivider && self.hasUsageBlock, width: self.width) if self.hasUsageBlock { UsageMenuCardUsageSectionView( @@ -63,6 +175,15 @@ struct OverviewMenuCardRowView: View { .frame(width: self.width, alignment: .leading) } + private var compactProjection: CompactOverviewProjection { + CompactOverviewProjectionResolver.resolve( + fallbackModel: self.model, + layoutModel: self.layoutModel) + { + self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } + } + private var hasUsageBlock: Bool { self.model.hasUsageContent } diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index b9312b9662..6db575487f 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -7,7 +7,8 @@ extension StatusItemController { func menuCardWidth( for providers: [UsageProvider], selectedProvider: UsageProvider?, - descriptor: MenuDescriptor) -> CGFloat + descriptor: MenuDescriptor, + overviewLayout: MergedOverviewLayout = .detailed) -> CGFloat { let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { providers.map { provider in @@ -21,7 +22,9 @@ extension StatusItemController { } else { [descriptor.sections] } - return self.measuredMenuCardWidth(for: sectionSets) + let measuredWidth = self.measuredMenuCardWidth(for: sectionSets) + guard overviewLayout.usesReducedContent else { return measuredWidth } + return max(measuredWidth, CompactOverviewLayout.minimumMenuWidth) } func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 1e27a4cee8..85de6859d6 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -37,6 +37,17 @@ struct NativeHighlightDeferredMenuRebuild { let provider: UsageProvider? } +@MainActor +private struct MergedOverviewMenuObservation: Equatable { + let layout: MergedOverviewLayout + let selectedProviders: [UsageProvider] + + init(settings: SettingsStore) { + self.layout = settings.mergedOverviewLayout + self.selectedProviders = settings.mergedOverviewSelectedProviders + } +} + @MainActor final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. @@ -258,6 +269,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool private var lastObservedUsageBarsShowUsed: Bool + private var lastMergedOverviewMenuObservation: MergedOverviewMenuObservation var lastWidgetDisplaySettingsSignature = "" var lastAgentSessionsEnabled: Bool var lastAgentSessionsManualHosts: String @@ -421,6 +433,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed + self.lastMergedOverviewMenuObservation = MergedOverviewMenuObservation(settings: settings) self.lastAgentSessionsEnabled = settings.agentSessionsEnabled self.lastAgentSessionsManualHosts = settings.agentSessionsManualHosts self.lastAgentSessionsRefreshFrequency = settings.refreshFrequency @@ -654,39 +667,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { - var shouldRefresh = false - let revision = self.settings.configRevision - if revision != self.lastConfigRevision { - self.lastConfigRevision = revision - shouldRefresh = true - } - let order = self.settings.providerOrder - if order != self.lastProviderOrder { - self.lastProviderOrder = order - shouldRefresh = true - } - let mergeIcons = self.settings.mergeIcons - if mergeIcons != self.lastMergeIcons { - self.lastMergeIcons = mergeIcons - shouldRefresh = true - } - let showsIcons = self.settings.switcherShowsIcons - if showsIcons != self.lastSwitcherShowsIcons { - self.lastSwitcherShowsIcons = showsIcons - shouldRefresh = true - } - let usageBarsShowUsed = self.settings.usageBarsShowUsed - if usageBarsShowUsed != self.lastObservedUsageBarsShowUsed { - self.lastObservedUsageBarsShowUsed = usageBarsShowUsed - shouldRefresh = true - } - if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature { - shouldRefresh = true - } - return shouldRefresh - } - private func handleSettingsChange(reason: String) { #if DEBUG guard !self.isReleasedForTesting else { return } @@ -695,6 +675,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let localizationChanged = self.menuLocalizationSignature() != self.lastMenuLocalizationSignature + let overviewChanged = + MergedOverviewMenuObservation(settings: self.settings) != self.lastMergedOverviewMenuObservation let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() self.invalidateMenus() if orderChanged || configChanged { @@ -705,7 +687,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.persistWidgetSnapshotIfWidgetDisplaySettingsChanged() if shouldRefreshOpenMenus { self.refreshOpenMenusAllowingParentRebuild( - deferParentRebuildDuringTracking: !localizationChanged) + deferParentRebuildDuringTracking: !( + localizationChanged || overviewChanged)) } } @@ -944,6 +927,46 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } +extension StatusItemController { + private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { + var shouldRefresh = false + let revision = self.settings.configRevision + if revision != self.lastConfigRevision { + self.lastConfigRevision = revision + shouldRefresh = true + } + let order = self.settings.providerOrder + if order != self.lastProviderOrder { + self.lastProviderOrder = order + shouldRefresh = true + } + let mergeIcons = self.settings.mergeIcons + if mergeIcons != self.lastMergeIcons { + self.lastMergeIcons = mergeIcons + shouldRefresh = true + } + let showsIcons = self.settings.switcherShowsIcons + if showsIcons != self.lastSwitcherShowsIcons { + self.lastSwitcherShowsIcons = showsIcons + shouldRefresh = true + } + let usageBarsShowUsed = self.settings.usageBarsShowUsed + if usageBarsShowUsed != self.lastObservedUsageBarsShowUsed { + self.lastObservedUsageBarsShowUsed = usageBarsShowUsed + shouldRefresh = true + } + let overviewObservation = MergedOverviewMenuObservation(settings: self.settings) + if overviewObservation != self.lastMergedOverviewMenuObservation { + self.lastMergedOverviewMenuObservation = overviewObservation + shouldRefresh = true + } + if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature { + shouldRefresh = true + } + return shouldRefresh + } +} + #if DEBUG extension StatusItemController { var _test_manualRefreshOperation: (@MainActor () async -> Void)? { diff --git a/Sources/CodexBar/UsageMenuCardLayout.swift b/Sources/CodexBar/UsageMenuCardLayout.swift index 8814a37cc6..34e067682a 100644 --- a/Sources/CodexBar/UsageMenuCardLayout.swift +++ b/Sources/CodexBar/UsageMenuCardLayout.swift @@ -9,6 +9,7 @@ enum UsageMenuCardLayout { static let sectionBottomPadding: CGFloat = 6 static let headerLineSpacing: CGFloat = 4 static let headerColumnSpacing: CGFloat = 12 + static let metricSpacing: CGFloat = 12 static var postHeaderDividerContentSpacing: CGFloat { // Reproduces Overview's header-bottom + usage-top gap so full cards align. diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index de00f0486a..0b422316a4 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -14,6 +14,7 @@ struct UsageProgressBar: View { private static let paceStripeCount = 3 private static let stripePunchOpacity = 0.9 + nonisolated static let defaultHeight: CGFloat = 6 private nonisolated static var warningMarkerPunchWidth: CGFloat { 5 @@ -39,6 +40,7 @@ struct UsageProgressBar: View { let paceOnTop: Bool let warningMarkerPercents: [Double] let workdayMarkerPercents: [Double] + let height: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale @@ -49,7 +51,8 @@ struct UsageProgressBar: View { pacePercent: Double? = nil, paceOnTop: Bool = true, warningMarkerPercents: [Double] = [], - workdayMarkerPercents: [Double] = []) + workdayMarkerPercents: [Double] = [], + height: CGFloat = Self.defaultHeight) { self.percent = percent self.tint = tint @@ -58,6 +61,7 @@ struct UsageProgressBar: View { self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents self.workdayMarkerPercents = workdayMarkerPercents + self.height = height } private var clamped: Double { @@ -154,7 +158,7 @@ struct UsageProgressBar: View { context.fill(stripes.center.applying(shift), with: .color(stripeColor)) } } - .frame(height: 6) + .frame(height: self.height) .accessibilityLabel(self.accessibilityLabel) .accessibilityValue(self.markerAccessibilityValue) } diff --git a/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift new file mode 100644 index 0000000000..ee1a1e3e47 --- /dev/null +++ b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift @@ -0,0 +1,665 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct CompactOverviewMenuIntegrationTests { + @Test + func `all reduced overviews assemble variable lane rows with provider actions`() throws { + try self.assertReducedOverview(layout: .compact) + try self.assertReducedOverview(layout: .providerBars) + try self.assertReducedOverview(layout: .barsOnly) + } + + @Test + func `reduced overview accessibility labels follow compatible live values without rebuilding`() async throws { + try await self.assertLiveAccessibilityLabel(layout: .compact) + try await self.assertLiveAccessibilityLabel(layout: .providerBars) + try await self.assertLiveAccessibilityLabel(layout: .barsOnly) + } + + @Test + func `reduced overview accessibility labels retain the rendered lane shape until rebuilding`() async throws { + try await self.assertFrozenAccessibilityLabel(layout: .compact) + try await self.assertFrozenAccessibilityLabel(layout: .providerBars) + try await self.assertFrozenAccessibilityLabel(layout: .barsOnly) + } + + @Test + func `reduced overview accessibility label honors the manual refresh freeze`() async throws { + let fixture = self.makeFixture(layout: .compact) + defer { + fixture.controller.menuCardRefreshMonitor.resetManualRefresh() + fixture.controller.releaseStatusItemsForTesting() + } + + let layoutModel = try #require(fixture.controller.menuCardModel(for: .cursor)) + let menu = self.renderOverviewMenu(fixture.controller) + let row = try #require(Self.rowsByProvider(in: menu)[.cursor]) + let view = try #require(row.view) + let layoutLabel = try #require(row.view?.accessibilityLabel()) + + fixture.store._setSnapshotForTesting( + Self.cursorSnapshot(primaryPercent: 41), + provider: .cursor) + let compatibleLiveModel = try #require(fixture.controller.menuCardModel(for: .cursor)) + let compatibleLiveLabel = CompactOverviewProjection(model: compatibleLiveModel).accessibilityLabel + await Self.waitForAccessibilityLabel(compatibleLiveLabel, view: view) + #expect(compatibleLiveLabel != layoutLabel) + + fixture.controller.menuCardRefreshMonitor.beginManualRefresh( + frozenModels: [.cursor: layoutModel], + provider: .cursor) + await Self.waitForAccessibilityLabel(layoutLabel, view: view) + + fixture.store._setSnapshotForTesting( + Self.cursorSnapshot(primaryPercent: 81), + provider: .cursor) + + let refreshedLiveModel = try #require(fixture.controller.menuCardModel(for: .cursor)) + let refreshedLiveLabel = CompactOverviewProjection(model: refreshedLiveModel).accessibilityLabel + let frozenProjection = CompactOverviewProjectionResolver.resolve( + fallbackModel: layoutModel, + layoutModel: layoutModel) + { + fixture.controller.menuCardRefreshMonitor.model(for: .cursor, fallback: layoutModel) + } + #expect(frozenProjection.accessibilityLabel == layoutLabel) + #expect(frozenProjection.accessibilityLabel != refreshedLiveLabel) + + fixture.controller.menuCardRefreshMonitor.endManualRefresh(for: .cursor) + await Self.waitForAccessibilityLabel(refreshedLiveLabel, view: view) + } + + private func assertReducedOverview(layout: MergedOverviewLayout) throws { + let fixture = self.makeFixture(layout: layout) + defer { fixture.controller.releaseStatusItemsForTesting() } + + let cursorModel = try #require(fixture.controller.menuCardModel(for: .cursor)) + let claudeModel = try #require(fixture.controller.menuCardModel(for: .claude)) + #expect(CompactOverviewProjection(model: cursorModel).lanes.count == 1) + #expect(CompactOverviewProjection(model: claudeModel).lanes.count == 3) + + let menu = self.renderOverviewMenu(fixture.controller) + let rows = Self.overviewRows(in: menu) + #expect(rows.map { $0.representedObject as? String } == [ + "overviewRow-cursor", + "overviewRow-claude", + ]) + // Reduced rows publish live accessibility labels through the unified row host's preference store. + #expect(rows.allSatisfy { row in + guard let view = row.view as? MenuRowContainerView else { return false } + return view.usesGPUSelectionForTesting + }) + + let cursorIndex = try #require(menu.items.firstIndex(of: rows[0])) + let claudeIndex = try #require(menu.items.firstIndex(of: rows[1])) + let barsOnlySpacers = Self.barsOnlySpacers(in: menu) + if layout == .barsOnly { + #expect(claudeIndex == cursorIndex + 1) + #expect(barsOnlySpacers.count == 2) + #expect(menu.items.firstIndex(of: barsOnlySpacers[0]) == cursorIndex - 1) + #expect(menu.items.firstIndex(of: barsOnlySpacers[1]) == claudeIndex + 1) + for spacer in barsOnlySpacers { + #expect(!spacer.isEnabled) + #expect(spacer.action == nil) + #expect(spacer.view?.frame.height == CompactOverviewLayout.barsOnlySectionSpacerHeight) + #expect(spacer.view?.isAccessibilityElement() == false) + } + } else { + #expect(barsOnlySpacers.isEmpty) + #expect(claudeIndex == cursorIndex + 2) + #expect(menu.items[cursorIndex + 1].isSeparatorItem) + } + + let cursorRow = rows[0] + #expect(cursorRow.submenu == nil) + #expect(try NSStringFromSelector(#require(cursorRow.action)) == "selectOverviewProvider:") + #expect((cursorRow.target as AnyObject?) === fixture.controller) + #expect( + cursorRow.view?.accessibilityLabel() == + CompactOverviewProjection(model: cursorModel).accessibilityLabel) + #expect(cursorRow.view?.accessibilityUserInputLabels() == [cursorModel.providerName]) + #expect(cursorRow.view?.accessibilityHelp() == L("Show details")) + + let claudeRow = rows[1] + let claudeSubmenu = try #require(claudeRow.submenu) + #expect(claudeSubmenu.items.first?.representedObject as? String == StatusItemController.usageHistoryChartID) + #expect(claudeSubmenu.items.first?.toolTip == UsageProvider.claude.rawValue) + #expect(try NSStringFromSelector(#require(claudeRow.action)) == "menuCardNoOp:") + #expect((claudeRow.target as AnyObject?) === fixture.controller) + #expect( + claudeRow.view?.accessibilityLabel() == + CompactOverviewProjection(model: claudeModel).accessibilityLabel) + #expect(claudeRow.view?.accessibilityUserInputLabels() == [claudeModel.providerName]) + #expect(claudeRow.view?.accessibilityHelp() == L("Show details")) + + let cursorHeight = try #require(cursorRow.view?.frame.height) + let claudeHeight = try #require(claudeRow.view?.frame.height) + #expect(cursorHeight > 0) + #expect(cursorHeight < claudeHeight) + + let claudeView = try #require(claudeRow.view as? MenuRowContainerView) + #expect(claudeView.usesGPUSelectionForTesting) + #expect(claudeView.showsSubmenuIndicatorForTesting == (layout != .barsOnly)) + #expect(claudeView._test_simulateRuntimeClick()) + #expect(!fixture.settings.mergedMenuLastSelectedWasOverview) + #expect(fixture.settings.selectedMenuProvider == .claude) + } + + private func assertLiveAccessibilityLabel(layout: MergedOverviewLayout) async throws { + let fixture = self.makeFixture(layout: layout) + let menu = self.renderOverviewMenu(fixture.controller) + let menuKey = ObjectIdentifier(menu) + fixture.controller.mergedMenu = menu + fixture.controller.openMenus[menuKey] = menu + fixture.controller.markMenuFresh(menu) + var rebuildCount = 0 + fixture.controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { + fixture.controller._test_openMenuRebuildObserver = nil + fixture.controller.openMenus[menuKey] = nil + fixture.controller.releaseStatusItemsForTesting() + } + + let initialStructuralSignature = fixture.controller.compactOverviewStructuralSignature() + let initialContentVersion = fixture.controller.menuContentVersion + let row = try #require(Self.rowsByProvider(in: menu)[.cursor]) + let view = try #require(row.view) + let initialLabel = try #require(view.accessibilityLabel()) + + fixture.store._setSnapshotForTesting( + Self.cursorSnapshot(primaryPercent: 81), + provider: .cursor) + + for _ in 0..<20 where fixture.controller.menuContentVersion == initialContentVersion { + await Task.yield() + } + + let liveModel = try #require(fixture.controller.menuCardModel(for: .cursor)) + let expectedLabel = CompactOverviewProjection(model: liveModel).accessibilityLabel + #expect(fixture.controller.menuContentVersion != initialContentVersion) + #expect(fixture.controller.compactOverviewStructuralSignature() == initialStructuralSignature) + #expect(rebuildCount == 0) + #expect(row.view === view) + await Self.waitForAccessibilityLabel(expectedLabel, view: view) + #expect(view.accessibilityLabel() == expectedLabel) + #expect(view.accessibilityLabel() != initialLabel) + #expect(view.accessibilityUserInputLabels() == [liveModel.providerName]) + } + + private func assertFrozenAccessibilityLabel(layout: MergedOverviewLayout) async throws { + let fixture = self.makeFixture(layout: layout) + defer { fixture.controller.releaseStatusItemsForTesting() } + + let menu = self.renderOverviewMenu(fixture.controller) + let row = try #require(Self.rowsByProvider(in: menu)[.claude]) + let view = try #require(row.view) + let renderedLabel = try #require(view.accessibilityLabel()) + + fixture.store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 41, + secondaryPercent: 42, + extraPercent: 43, + extraTitle: "Peer"), + provider: .claude) + let compatibleLiveModel = try #require(fixture.controller.menuCardModel(for: .claude)) + let compatibleLiveLabel = CompactOverviewProjection(model: compatibleLiveModel).accessibilityLabel + await Self.waitForAccessibilityLabel(compatibleLiveLabel, view: view) + #expect(compatibleLiveLabel != renderedLabel) + + fixture.store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 81, + secondaryPercent: 82, + extraPercent: 83, + extraTitle: "Peer", + additionalExtraTitle: "Fourth lane"), + provider: .claude) + + let incompatibleLiveModel = try #require(fixture.controller.menuCardModel(for: .claude)) + let incompatibleLiveLabel = CompactOverviewProjection(model: incompatibleLiveModel).accessibilityLabel + await Self.waitForAccessibilityLabel(renderedLabel, view: view) + #expect(row.view === view) + #expect(view.accessibilityLabel() == renderedLabel) + #expect(view.accessibilityLabel() != incompatibleLiveLabel) + } + + @Test + func `all overview layouts use distinct cache geometry and ordered heights`() throws { + let barsOnly = self.makeFixture(layout: .barsOnly) + let providerBars = self.makeFixture(layout: .providerBars) + let compact = self.makeFixture(layout: .compact) + let detailed = self.makeFixture(layout: .detailed) + defer { + barsOnly.controller.releaseStatusItemsForTesting() + providerBars.controller.releaseStatusItemsForTesting() + compact.controller.releaseStatusItemsForTesting() + detailed.controller.releaseStatusItemsForTesting() + } + + let barsOnlyMenu = self.renderOverviewMenu(barsOnly.controller) + let providerBarsMenu = self.renderOverviewMenu(providerBars.controller) + let compactMenu = self.renderOverviewMenu(compact.controller) + let detailedMenu = self.renderOverviewMenu(detailed.controller) + let barsOnlyRows = Self.rowsByProvider(in: barsOnlyMenu) + let providerBarsRows = Self.rowsByProvider(in: providerBarsMenu) + let compactRows = Self.rowsByProvider(in: compactMenu) + let detailedRows = Self.rowsByProvider(in: detailedMenu) + let barsOnlyKeys = Self.cacheKeys(in: barsOnly.controller, section: "overviewBarsOnly") + let providerBarsKeys = Self.cacheKeys(in: providerBars.controller, section: "overviewProviderBars") + let compactKeys = Self.cacheKeys(in: compact.controller, section: "overviewCompact") + let detailedKeys = Self.cacheKeys(in: detailed.controller, section: "overviewDetailed") + + let expectedScopes = Set([UsageProvider.cursor.rawValue, UsageProvider.claude.rawValue]) + #expect(Set(barsOnlyKeys.map(\.scope)) == expectedScopes) + #expect(Set(providerBarsKeys.map(\.scope)) == expectedScopes) + #expect(Set(compactKeys.map(\.scope)) == expectedScopes) + #expect(Set(detailedKeys.map(\.scope)) == expectedScopes) + + for provider in [UsageProvider.cursor, .claude] { + let barsOnlyRow = try #require(barsOnlyRows[provider]) + let providerBarsRow = try #require(providerBarsRows[provider]) + let compactRow = try #require(compactRows[provider]) + let detailedRow = try #require(detailedRows[provider]) + let barsOnlyKey = try #require(barsOnlyKeys.first { $0.scope == provider.rawValue }) + let providerBarsKey = try #require(providerBarsKeys.first { $0.scope == provider.rawValue }) + let compactKey = try #require(compactKeys.first { $0.scope == provider.rawValue }) + let detailedKey = try #require(detailedKeys.first { $0.scope == provider.rawValue }) + let barsOnlyHeight = try #require(barsOnlyRow.view?.frame.height) + let providerBarsHeight = try #require(providerBarsRow.view?.frame.height) + let compactHeight = try #require(compactRow.view?.frame.height) + let detailedHeight = try #require(detailedRow.view?.frame.height) + + #expect(barsOnlyKey.id == providerBarsKey.id) + #expect(providerBarsKey.id == compactKey.id) + #expect(compactKey.id == detailedKey.id) + #expect(barsOnlyKey.fingerprint != providerBarsKey.fingerprint) + #expect(providerBarsKey.fingerprint != compactKey.fingerprint) + #expect(compactKey.fingerprint != detailedKey.fingerprint) + #expect(barsOnlyHeight < providerBarsHeight) + #expect(providerBarsHeight < compactHeight) + #expect(compactHeight < detailedHeight) + let expectedBarsOnlyHeight: CGFloat = provider == .cursor ? 30 : 66 + let expectedProviderBarsHeight: CGFloat = provider == .cursor ? 57 : 93 + #expect(abs(barsOnlyHeight - expectedBarsOnlyHeight) <= 1) + #expect(abs(providerBarsHeight - expectedProviderBarsHeight) <= 1) + } + + let initialCursorFingerprint = try #require( + compactKeys.first { $0.scope == UsageProvider.cursor.rawValue }?.fingerprint) + compact.store._setSnapshotForTesting( + Self.cursorSnapshot(primaryPercent: 81), + provider: .cursor) + + _ = self.renderOverviewMenu(compact.controller) + let cursorValueOnlyFingerprints = Set(Self.cacheKeys( + in: compact.controller, + section: "overviewCompact") + .filter { $0.scope == UsageProvider.cursor.rawValue } + .map(\.fingerprint)) + + #expect(cursorValueOnlyFingerprints == [initialCursorFingerprint]) + + compact.store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 10, + secondaryPercent: 20, + extraPercent: 30, + extraTitle: "A substantially longer peer quota lane title"), + provider: .claude) + + _ = self.renderOverviewMenu(compact.controller) + let cursorCompactFingerprints = Set(Self.cacheKeys( + in: compact.controller, + section: "overviewCompact") + .filter { $0.scope == UsageProvider.cursor.rawValue } + .map(\.fingerprint)) + + #expect(cursorCompactFingerprints.contains(initialCursorFingerprint)) + #expect(cursorCompactFingerprints.count == 1) + } + + @Test + func `open overview rebuilds between provider bars and bars only`() async throws { + let fixture = self.makeFixture(layout: .providerBars, menuRefreshEnabled: true) + defer { fixture.controller.releaseStatusItemsForTesting() } + + let menu = self.renderOverviewMenu(fixture.controller) + let menuKey = ObjectIdentifier(menu) + fixture.controller.mergedMenu = menu + fixture.controller.openMenus[menuKey] = menu + fixture.controller.markMenuFresh(menu) + defer { + fixture.controller.openMenus[menuKey] = nil + fixture.controller._test_openMenuRebuildObserver = nil + } + + func assertTopology(_ layout: MergedOverviewLayout) throws -> CGFloat { + let rows = Self.overviewRows(in: menu) + #expect(rows.map { $0.representedObject as? String } == [ + "overviewRow-cursor", + "overviewRow-claude", + ]) + let cursorIndex = try #require(menu.items.firstIndex(of: rows[0])) + let claudeIndex = try #require(menu.items.firstIndex(of: rows[1])) + if layout == .barsOnly { + #expect(claudeIndex == cursorIndex + 1) + #expect(Self.barsOnlySpacers(in: menu).count == 2) + } else { + #expect(Self.barsOnlySpacers(in: menu).isEmpty) + #expect(claudeIndex == cursorIndex + 2) + #expect(menu.items[cursorIndex + 1].isSeparatorItem) + } + let claudeView = try #require(rows[1].view as? MenuRowContainerView) + #expect(claudeView.usesGPUSelectionForTesting) + #expect(claudeView.showsSubmenuIndicatorForTesting == (layout != .barsOnly)) + return try #require(rows[1].view?.frame.height) + } + + let initialProviderBarsHeight = try assertTopology(.providerBars) + var rebuildCount = 0 + fixture.controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + + fixture.settings.mergedOverviewLayout = .barsOnly + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + let barsOnlyHeight = try assertTopology(.barsOnly) + #expect(barsOnlyHeight < initialProviderBarsHeight) + #expect(!Self.cacheKeys(in: fixture.controller, section: "overviewBarsOnly").isEmpty) + + fixture.settings.mergedOverviewLayout = .providerBars + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + let rebuiltProviderBarsHeight = try assertTopology(.providerBars) + #expect(rebuiltProviderBarsHeight == initialProviderBarsHeight) + #expect(!Self.cacheKeys(in: fixture.controller, section: "overviewProviderBars").isEmpty) + } + + @Test + func `bars only outer spacers keep row heights and cache keys position independent`() throws { + let fixture = self.makeFixture( + layout: .barsOnly, + providerOrder: [.cursor, .codex, .claude], + usesOneLaneSnapshots: true) + defer { fixture.controller.releaseStatusItemsForTesting() } + + func rowHeight(_ provider: UsageProvider, in menu: NSMenu) throws -> CGFloat { + let row = try #require(Self.rowsByProvider(in: menu)[provider]) + return try #require(row.view?.frame.height) + } + + func fingerprints(_ provider: UsageProvider) -> Set { + Set(Self.cacheKeys(in: fixture.controller, section: "overviewBarsOnly") + .filter { $0.scope == provider.rawValue } + .map(\.fingerprint)) + } + + let threeProviderMenu = self.renderOverviewMenu(fixture.controller) + let cursorFirstHeight = try rowHeight(.cursor, in: threeProviderMenu) + let codexInteriorHeight = try rowHeight(.codex, in: threeProviderMenu) + let claudeLastHeight = try rowHeight(.claude, in: threeProviderMenu) + #expect(abs(cursorFirstHeight - 30) <= 1) + #expect(abs(codexInteriorHeight - 30) <= 1) + #expect(abs(claudeLastHeight - 30) <= 1) + #expect(Self.barsOnlySpacers(in: threeProviderMenu).count == 2) + let interiorFingerprints = fingerprints(.codex) + #expect(interiorFingerprints.count == 1) + + let activeProviders: [UsageProvider] = [.cursor, .codex, .claude] + fixture.settings.setMergedOverviewProviderSelection( + provider: .cursor, + isSelected: false, + activeProviders: activeProviders) + let twoProviderMenu = self.renderOverviewMenu(fixture.controller) + let codexFirstHeight = try rowHeight(.codex, in: twoProviderMenu) + #expect(abs(codexFirstHeight - 30) <= 1) + #expect(Self.barsOnlySpacers(in: twoProviderMenu).count == 2) + let firstFingerprints = fingerprints(.codex) + #expect(firstFingerprints == interiorFingerprints) + + fixture.settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + let oneProviderMenu = self.renderOverviewMenu(fixture.controller) + let codexOnlyHeight = try rowHeight(.codex, in: oneProviderMenu) + #expect(abs(codexOnlyHeight - 30) <= 1) + #expect(Self.barsOnlySpacers(in: oneProviderMenu).count == 2) + let soleFingerprints = fingerprints(.codex) + #expect(soleFingerprints == firstFingerprints) + } + + @Test + func `structural signature distinguishes modes ignores values and detects lane shape`() { + let fixture = self.makeFixture(layout: .compact) + defer { fixture.controller.releaseStatusItemsForTesting() } + + let initial = fixture.controller.compactOverviewStructuralSignature() + + fixture.store._setSnapshotForTesting( + Self.cursorSnapshot(primaryPercent: 81), + provider: .cursor) + fixture.store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 82, + secondaryPercent: 83, + extraPercent: 84, + extraTitle: "Peer"), + provider: .claude) + let valueOnly = fixture.controller.compactOverviewStructuralSignature() + #expect(valueOnly == initial) + + fixture.store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 82, + secondaryPercent: 83, + extraPercent: 84, + extraTitle: "Peer", + additionalExtraTitle: "Fourth lane"), + provider: .claude) + let laneAdded = fixture.controller.compactOverviewStructuralSignature() + #expect(laneAdded != valueOnly) + + fixture.store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 82, + secondaryPercent: 83, + extraPercent: 84, + extraTitle: "Renamed peer", + additionalExtraTitle: "Fourth lane"), + provider: .claude) + let peerTitleChanged = fixture.controller.compactOverviewStructuralSignature() + #expect(peerTitleChanged != laneAdded) + + fixture.settings.mergedOverviewLayout = .providerBars + let providerBars = fixture.controller.compactOverviewStructuralSignature() + #expect(providerBars != peerTitleChanged) + #expect(providerBars.contains("layout=providerBars")) + + fixture.settings.mergedOverviewLayout = .barsOnly + let barsOnly = fixture.controller.compactOverviewStructuralSignature() + #expect(barsOnly != providerBars) + #expect(barsOnly.contains("layout=barsOnly")) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + let controller: StatusItemController + } + + private func makeFixture( + layout: MergedOverviewLayout, + providerOrder: [UsageProvider] = [.cursor, .claude], + usesOneLaneSnapshots: Bool = false, + menuRefreshEnabled: Bool = false) -> Fixture + { + let suite = "CompactOverviewMenuIntegrationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.switcherShowsIcons = false + settings.mergedMenuLastSelectedWasOverview = true + settings.mergedOverviewLayout = layout + settings.historicalTrackingEnabled = false + settings.showOptionalCreditsAndExtraUsage = true + settings.providerStorageFootprintsEnabled = false + settings.costUsageEnabled = false + settings.setProviderOrder(providerOrder) + settings.mergedOverviewSelectedProviders = providerOrder + self.enableOnly(Set(providerOrder), settings: settings) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + if usesOneLaneSnapshots { + for (index, provider) in providerOrder.enumerated() { + store._setSnapshotForTesting( + Self.cursorSnapshot(primaryPercent: Double(10 + index)), + provider: provider) + } + } else { + store._setSnapshotForTesting(Self.cursorSnapshot(primaryPercent: 10), provider: .cursor) + store._setSnapshotForTesting( + Self.claudeSnapshot( + primaryPercent: 10, + secondaryPercent: 20, + extraPercent: 30, + extraTitle: "Peer"), + provider: .claude) + } + + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system, + menuCardRenderingEnabled: true, + menuRefreshEnabled: menuRefreshEnabled, + observeProviderConfigNotifications: false) + return Fixture(settings: settings, store: store, controller: controller) + } + + private static func waitForRebuildCount( + _ expected: Int, + rebuildCount: () -> Int) async + { + for _ in 0..<100 where rebuildCount() < expected { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(rebuildCount() == expected) + } + + private static func waitForAccessibilityLabel(_ expected: String, view: NSView) async { + for _ in 0..<100 where view.accessibilityLabel() != expected { + view.layoutSubtreeIfNeeded() + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(view.accessibilityLabel() == expected) + } + + private func enableOnly(_ enabled: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabled.contains(provider)) + } + } + + private func renderOverviewMenu(_ controller: StatusItemController) -> NSMenu { + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: nil) + return menu + } + + private static func overviewRows(in menu: NSMenu) -> [NSMenuItem] { + menu.items.filter { + ($0.representedObject as? String)?.hasPrefix(StatusItemController.overviewRowIdentifierPrefix) == true + } + } + + private static func barsOnlySpacers(in menu: NSMenu) -> [NSMenuItem] { + menu.items.filter { + ($0.representedObject as? String)? + .hasPrefix(StatusItemController.overviewBarsOnlySpacerIdentifierPrefix) == true + } + } + + private static func rowsByProvider(in menu: NSMenu) -> [UsageProvider: NSMenuItem] { + Dictionary(uniqueKeysWithValues: self.overviewRows(in: menu).compactMap { item in + guard let id = item.representedObject as? String else { return nil } + let rawValue = String(id.dropFirst(StatusItemController.overviewRowIdentifierPrefix.count)) + guard let provider = UsageProvider(rawValue: rawValue) else { return nil } + return (provider, item) + }) + } + + private static func cacheKeys( + in controller: StatusItemController, + section: String) -> [StatusItemController.MenuCardHeightCacheKey] + { + controller.menuCardHeightCache.keys.filter { key in + key.id.hasPrefix(StatusItemController.overviewRowIdentifierPrefix) && + key.fingerprint.contains(section) + } + } + + private static func cursorSnapshot(primaryPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: self.window(percent: primaryPercent, minutes: 30 * 24 * 60), + secondary: nil, + updatedAt: self.snapshotDate) + } + + private static func claudeSnapshot( + primaryPercent: Double, + secondaryPercent: Double, + extraPercent: Double, + extraTitle: String, + additionalExtraTitle: String? = nil) -> UsageSnapshot + { + var extras = [NamedRateWindow( + id: "peer-quota", + title: extraTitle, + window: Self.window(percent: extraPercent, minutes: 24 * 60))] + if let additionalExtraTitle { + extras.append(NamedRateWindow( + id: "additional-quota", + title: additionalExtraTitle, + window: Self.window(percent: 45, minutes: 48 * 60))) + } + return UsageSnapshot( + primary: Self.window(percent: primaryPercent, minutes: 5 * 60), + secondary: Self.window(percent: secondaryPercent, minutes: 7 * 24 * 60), + extraRateWindows: extras, + updatedAt: Self.snapshotDate) + } + + private static func window(percent: Double, minutes: Int) -> RateWindow { + RateWindow( + usedPercent: percent, + windowMinutes: minutes, + resetsAt: self.snapshotDate.addingTimeInterval(TimeInterval(minutes * 60)), + resetDescription: nil) + } + + private static let snapshotDate = Date(timeIntervalSince1970: 1_900_000_000) +} diff --git a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift new file mode 100644 index 0000000000..98ab8fc710 --- /dev/null +++ b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift @@ -0,0 +1,578 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct CompactOverviewProjectionTests { + @Test + func `projection preserves every drawable lane for variable response sizes`() { + for count in [0, 1, 2, 3, 6, 12] { + let metrics = (0...submenuIndicatorSystemName(for: .leftToRight) == + "chevron.right") + #expect(MenuCardSectionContainerView.submenuIndicatorSystemName(for: .rightToLeft) == + "chevron.left") + } + + @Test + func `live resolver mirrors gate accepts compatible frozen shape and rejects drift`() { + let fallback = Self.model( + usesLiveSubtitle: true, + metrics: [Self.metric(id: "fallback", title: "Fallback", percent: 10)]) + let frozenLayout = Self.model( + usesLiveSubtitle: true, + metrics: [ + Self.metric(id: "one", title: "One", percent: 11), + Self.metric(id: "two", title: "Two", percent: 22), + Self.metric(id: "three", title: "Three", percent: 33), + ]) + let compatibleLive = Self.model( + usesLiveSubtitle: true, + metrics: [ + Self.metric(id: "one", title: "One", percent: 44), + Self.metric(id: "two", title: "Two", percent: 55), + Self.metric(id: "three", title: "Three", percent: 66), + ]) + var resolutionCount = 0 + let compatible = CompactOverviewProjectionResolver.resolve( + fallbackModel: fallback, + layoutModel: frozenLayout) + { + resolutionCount += 1 + return compatibleLive + } + + #expect(resolutionCount == 1) + #expect(compatible.lanes.map(\.id) == ["one", "two", "three"]) + #expect(compatible.lanes.map(\.percent) == [44, 55, 66]) + + let incompatible = CompactOverviewProjectionResolver.resolve( + fallbackModel: fallback, + layoutModel: frozenLayout) + { + Self.model( + usesLiveSubtitle: true, + metrics: [Self.metric(id: "one", title: "One", percent: 99)]) + } + #expect(incompatible.lanes.map(\.id) == ["one", "two", "three"]) + #expect(incompatible.lanes.map(\.percent) == [11, 22, 33]) + + var gatedResolutionCount = 0 + let gated = CompactOverviewProjectionResolver.resolve( + fallbackModel: Self.model(metrics: [Self.metric(id: "static", title: "Static")]), + layoutModel: nil) + { + gatedResolutionCount += 1 + return compatibleLive + } + #expect(gatedResolutionCount == 0) + #expect(gated.lanes.map(\.id) == ["static"]) + + var wrongProviderResolutionCount = 0 + let wrongProviderLayout = CompactOverviewProjectionResolver.resolve( + fallbackModel: fallback, + layoutModel: Self.model( + provider: .claude, + usesLiveSubtitle: true, + metrics: [Self.metric(id: "foreign", title: "Foreign")])) + { + wrongProviderResolutionCount += 1 + return compatibleLive + } + #expect(wrongProviderResolutionCount == 0) + #expect(wrongProviderLayout.lanes.map(\.id) == ["fallback"]) + } + + @Test + func `compact interaction policy never exposes detailed embedded controls`() { + let live = Self.model(usesLiveSubtitle: true) + let error = Self.model(subtitleStyle: .error) + + #expect(OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .detailed, model: live)) + #expect(OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .detailed, model: error)) + #expect(!OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .compact, model: live)) + #expect(!OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .compact, model: error)) + #expect(!OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .providerBars, model: live)) + #expect(!OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .providerBars, model: error)) + #expect(!OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .barsOnly, model: live)) + #expect(!OverviewMenuRowInteractionPolicy.containsInteractiveControls(style: .barsOnly, model: error)) + } + + @Test + func `hosted reduced rows preserve every lane in increasing detail order`() throws { + let layout = CompactOverviewLayout.resolveForMenu( + menuWidth: 310, + layoutDirection: .leftToRight) + var labeledHeights: [Int: CGFloat] = [:] + var providerBarsHeights: [Int: CGFloat] = [:] + var barsOnlyHeights: [Int: CGFloat] = [:] + for count in [0, 1, 2, 3, 6] { + let metrics = (0.. 0) + #expect(fallbackBarsOnlyHeight > 0) + #expect(fallbackBarsOnlyHeight < fallbackProviderBarsHeight) + #expect(fallbackProviderBarsHeight < fallbackLabeledHeight) + + for count in [1, 2, 3, 6] { + let labeledHeight = try #require(labeledHeights[count]) + let providerBarsHeight = try #require(providerBarsHeights[count]) + let barsOnlyHeight = try #require(barsOnlyHeights[count]) + #expect(barsOnlyHeight < providerBarsHeight) + #expect(providerBarsHeight < labeledHeight) + } + + for (count, target) in [(1, 23.0), (2, 41.0), (3, 59.0)] { + let barsOnlyHeight = try #require(barsOnlyHeights[count]) + #expect(abs(barsOnlyHeight - target) <= 1) + } + + for pair in zip([1, 2, 3], [2, 3, 6]) { + let labeledBefore = try #require(labeledHeights[pair.0]) + let labeledAfter = try #require(labeledHeights[pair.1]) + let providerBarsBefore = try #require(providerBarsHeights[pair.0]) + let providerBarsAfter = try #require(providerBarsHeights[pair.1]) + let barsOnlyBefore = try #require(barsOnlyHeights[pair.0]) + let barsOnlyAfter = try #require(barsOnlyHeights[pair.1]) + #expect(labeledBefore < labeledAfter) + #expect(providerBarsBefore < providerBarsAfter) + #expect(barsOnlyBefore < barsOnlyAfter) + #expect(abs((providerBarsAfter - providerBarsBefore) - CGFloat(pair.1 - pair.0) * 18) <= 1) + } + + let oneLaneBarsOnlyHeight = try #require(barsOnlyHeights[1]) + let oneLaneProviderBarsHeight = try #require(providerBarsHeights[1]) + #expect(abs(fallbackBarsOnlyHeight - oneLaneBarsOnlyHeight) <= 1) + #expect(abs(fallbackProviderBarsHeight - oneLaneProviderBarsHeight) <= 1) + } + + @Test + func `bars only rows keep symmetric padding independent of section position`() { + let layout = CompactOverviewLayout.resolveForMenu( + menuWidth: 310, + layoutDirection: .leftToRight) + let projection = CompactOverviewProjection(model: Self.model(metrics: [ + Self.metric(id: "lane", title: "Lane"), + ])) + + let host = NSHostingController(rootView: CompactOverviewBarsOnlyContent( + projection: projection, + layout: layout)) + let height = host.sizeThatFits(in: CGSize(width: layout.menuWidth, height: 10000)).height + + #expect(abs(height - 23) <= 1) + #expect(CompactOverviewLayout.barsOnlySectionSpacerHeight == 3) + } + + private static func projection( + providerName: String = "Private Provider Name", + metrics: [UsageMenuCardView.Model.Metric]) -> CompactOverviewProjection + { + CompactOverviewProjection( + model: self.model(providerName: providerName, metrics: metrics), + loadingText: "Loading fixture", + noBarsText: "No bars fixture") + } + + private static func metric( + id: String, + title: String, + percent: Double = 50, + percentStyle: UsageMenuCardView.Model.PercentStyle = .left, + statusText: String? = nil, + pacePercent: Double? = nil, + paceOnTop: Bool = true, + warningMarkerPercents: [Double] = [], + workdayMarkerPercents: [Double] = []) -> UsageMenuCardView.Model.Metric + { + UsageMenuCardView.Model.Metric( + id: id, + title: title, + percent: percent, + percentStyle: percentStyle, + statusText: statusText, + resetText: "Detailed reset sentinel", + detailText: "Detailed text sentinel", + detailLeftText: "Detailed left sentinel", + detailRightText: "Detailed right sentinel", + pacePercent: pacePercent, + paceOnTop: paceOnTop, + warningMarkerPercents: warningMarkerPercents, + workdayMarkerPercents: workdayMarkerPercents) + } + + private static func model( + provider: UsageProvider = .codex, + providerName: String = "Provider", + subtitleStyle: UsageMenuCardView.Model.SubtitleStyle = .info, + usesLiveSubtitle: Bool = false, + metrics: [UsageMenuCardView.Model.Metric] = [], + progressColor: Color = .blue) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: provider, + providerName: providerName, + email: "Private email sentinel", + subtitleText: "Detailed subtitle sentinel", + subtitleStyle: subtitleStyle, + usesLiveSubtitle: usesLiveSubtitle, + planText: "Detailed plan sentinel", + metrics: metrics, + usageNotes: ["Detailed note sentinel"], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: "Detailed credits sentinel", + creditsRemaining: 12, + creditsProgressPercent: 34, + creditsScaleText: "Detailed scale sentinel", + creditsHintText: "Detailed credits hint sentinel", + creditsHintCopyText: "Detailed copy sentinel", + providerCost: nil, + tokenUsage: nil, + placeholder: "Detailed placeholder sentinel", + progressColor: progressColor) + } +} diff --git a/Tests/CodexBarTests/CompactOverviewSettingsTests.swift b/Tests/CodexBarTests/CompactOverviewSettingsTests.swift new file mode 100644 index 0000000000..002415ee3c --- /dev/null +++ b/Tests/CodexBarTests/CompactOverviewSettingsTests.swift @@ -0,0 +1,161 @@ +import CodexBarCore +import Foundation +import Observation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct CompactOverviewSettingsTests { + private static let layoutKey = "mergedOverviewLayout" + private static let legacyCompactKey = "mergedOverviewUsesCompactLayout" + + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `overview layout defaults to detailed without writing defaults`() throws { + let suite = "CompactOverviewSettingsTests-default" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let store = Self.makeStore(defaults: defaults, suite: suite) + + #expect(store.mergedOverviewLayout == .detailed) + #expect(defaults.object(forKey: Self.layoutKey) == nil) + #expect(defaults.object(forKey: Self.legacyCompactKey) == nil) + } + + @Test + func `legacy compact boolean migrates both values to the layout key`() throws { + let scenarios: [(legacyCompact: Bool, expected: MergedOverviewLayout)] = [ + (false, .detailed), + (true, .compact), + ] + + for scenario in scenarios { + let suite = "CompactOverviewSettingsTests-legacy-\(scenario.legacyCompact)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(scenario.legacyCompact, forKey: Self.legacyCompactKey) + + let store = Self.makeStore(defaults: defaults, suite: suite) + + #expect(store.mergedOverviewLayout == scenario.expected) + #expect(defaults.string(forKey: Self.layoutKey) == scenario.expected.rawValue) + #expect(defaults.object(forKey: Self.legacyCompactKey) as? Bool == scenario.legacyCompact) + } + } + + @Test + func `new layout key takes precedence and unknown values safely remain untouched`() throws { + let validSuite = "CompactOverviewSettingsTests-new-key-precedence" + let validDefaults = try #require(UserDefaults(suiteName: validSuite)) + validDefaults.removePersistentDomain(forName: validSuite) + validDefaults.set(MergedOverviewLayout.providerBars.rawValue, forKey: Self.layoutKey) + validDefaults.set(false, forKey: Self.legacyCompactKey) + + let validStore = Self.makeStore(defaults: validDefaults, suite: validSuite) + + #expect(validStore.mergedOverviewLayout == .providerBars) + #expect(validDefaults.string(forKey: Self.layoutKey) == MergedOverviewLayout.providerBars.rawValue) + #expect(validDefaults.object(forKey: Self.legacyCompactKey) as? Bool == false) + + let unknownSuite = "CompactOverviewSettingsTests-unknown-new-key" + let unknownDefaults = try #require(UserDefaults(suiteName: unknownSuite)) + unknownDefaults.removePersistentDomain(forName: unknownSuite) + unknownDefaults.set("future-layout", forKey: Self.layoutKey) + unknownDefaults.set(true, forKey: Self.legacyCompactKey) + + let unknownStore = Self.makeStore(defaults: unknownDefaults, suite: unknownSuite) + + #expect(unknownStore.mergedOverviewLayout == .detailed) + #expect(unknownDefaults.string(forKey: Self.layoutKey) == "future-layout") + #expect(unknownDefaults.object(forKey: Self.legacyCompactKey) as? Bool == true) + } + + @Test + func `all overview layouts round trip and dual write the legacy boolean`() throws { + let layouts = MergedOverviewLayout.allCases + let expectedLegacyCompactValues = [false, true, true, true] + #expect(layouts == [.detailed, .compact, .providerBars, .barsOnly]) + + for (layout, expectedLegacyCompact) in zip(layouts, expectedLegacyCompactValues) { + let suite = "CompactOverviewSettingsTests-round-trip-\(layout.rawValue)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = Self.makeStore(defaults: defaults, configStore: configStore) + + store.mergedOverviewLayout = layout + + #expect(store.mergedOverviewLayout == layout) + #expect(defaults.string(forKey: Self.layoutKey) == layout.rawValue) + #expect(defaults.object(forKey: Self.legacyCompactKey) as? Bool == expectedLegacyCompact) + + let reloaded = Self.makeStore(defaults: defaults, configStore: configStore) + #expect(reloaded.mergedOverviewLayout == layout) + } + } + + @Test + func `changing compact to provider bars refreshes only menus`() async throws { + let suite = "CompactOverviewSettingsTests-menu-observation" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = Self.makeStore(defaults: defaults, configStore: configStore) + store.mergedOverviewLayout = .compact + + let configEncoder = JSONEncoder() + configEncoder.outputFormatting = .sortedKeys + let configData = try configEncoder.encode(store.configSnapshot) + let configRevision = store.configRevision + let backgroundRevision = store.backgroundWorkSettingsRevision + let providerDetailRevision = store.providerDetailSettingsRevision + let costUsageRevision = store.costUsageSettingsRevision + let menuDidChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + menuDidChange.set() + } + + store.mergedOverviewLayout = .providerBars + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(store.mergedOverviewLayout == .providerBars) + #expect(menuDidChange.get()) + #expect(try configEncoder.encode(store.configSnapshot) == configData) + #expect(store.configRevision == configRevision) + #expect(store.backgroundWorkSettingsRevision == backgroundRevision) + #expect(store.providerDetailSettingsRevision == providerDetailRevision) + #expect(store.costUsageSettingsRevision == costUsageRevision) + } + + private static func makeStore(defaults: UserDefaults, suite: String) -> SettingsStore { + self.makeStore(defaults: defaults, configStore: testConfigStore(suiteName: suite)) + } + + private static func makeStore(defaults: UserDefaults, configStore: CodexBarConfigStore) -> SettingsStore { + SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 27a81f1d63..5bf8a0c4db 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -109,6 +109,43 @@ struct LocalizationLanguageCatalogTests { } } + @Test + func `overview layout copy is translated in every catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + let englishValues = [ + "overview_layout_title": "Overview layout", + "overview_layout_subtitle": "Choose how much information Overview shows.", + "overview_layout_detailed": "Detailed", + "overview_layout_compact": "Providers, metrics & bars", + "overview_layout_provider_bars": "Providers & bars", + "overview_layout_bars_only": "Bars only", + "overview_compact_no_bars": "No usage bars", + ] + + #expect(catalogs.count == 23) + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + for (key, englishValue) in englishValues { + let value = try #require(catalog[key]?.trimmingCharacters(in: .whitespacesAndNewlines)) + #expect(!value.isEmpty, "\(catalogURL.lastPathComponent).\(key)") + if catalogURL.lastPathComponent == "en.lproj" { + #expect(value == englishValue, "\(catalogURL.lastPathComponent).\(key)") + } else { + #expect(value != englishValue, "Untranslated \(catalogURL.lastPathComponent).\(key)") + } + } + } + } + @Test func `language picker labels use stable native names`() { let expected: [AppLanguage: String] = [ diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index 5b99c428d3..3ced8ca781 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -126,6 +126,42 @@ struct PreferencesPaneSmokeTests { #expect(!text.contains("%@")) } + @Test + func `menu pane overview layout picker follows merge icons without clearing its value`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-compact-overview") + let store = Self.makeUsageStore(settings: settings) + let layouts = MenuSettingsMenuOptions.mergedOverviewLayouts + #expect(layouts == [.detailed, .compact, .providerBars, .barsOnly]) + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(layouts.map(\.label) == [ + "Detailed", + "Providers, metrics & bars", + "Providers & bars", + "Bars only", + ]) + } + for layout in layouts { + settings.mergedOverviewLayout = layout + _ = MenuPane(settings: settings, store: store).body + #expect(settings.mergedOverviewLayout == layout) + } + + settings.mergedOverviewLayout = .providerBars + + #expect(MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) + _ = MenuPane(settings: settings, store: store).body + + settings.mergeIcons = false + #expect(!MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) + #expect(settings.mergedOverviewLayout == .providerBars) + _ = MenuPane(settings: settings, store: store).body + + settings.mergeIcons = true + #expect(MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) + #expect(settings.mergedOverviewLayout == .providerBars) + _ = MenuPane(settings: settings, store: store).body + } + @Test func `inactive display contrast is available only for icon and percent`() { #expect(!MenuBarPane.inactiveDisplayContrastAvailable(for: .critters)) diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index b2f189fc78..dd0af376a1 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -9,7 +9,8 @@ struct UsageMenuCardLayoutTests { private static let heightTolerance: CGFloat = 1 @Test - func `overview groups provider content without section dividers`() { + func `detailed overview separates provider header without inner usage dividers`() { + #expect(OverviewMenuCardRowView.showsHeaderDivider) #expect(OverviewMenuCardRowView.showsSectionDividers == false) } diff --git a/docs/ui.md b/docs/ui.md index e57e185a71..1ba466bfe7 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -14,6 +14,16 @@ read_when: positions. - When Overview has selected providers, the switcher includes an Overview tab that renders up to 6 provider rows. - Overview row order follows provider order; selecting a row jumps to that provider detail card. +- Preferences → Menu → Content → Overview layout controls how much information merged Overview shows: + Detailed; Providers, metrics & bars; Providers & bars; or Bars only. Detailed retains the full provider cards. +- The three reduced layouts preserve every drawable usage metric in model order and use the existing six-point progress + bars. Providers, metrics & bars shows provider and metric names; Providers & bars hides metric names; Bars only hides + both provider and metric names. Visible numeric percentages are omitted in all three reduced layouts. +- Reduced layouts retain provider actions, detail submenus, refresh behavior, native menu scrolling, and accessible + provider names, metric names, percentages, and used-versus-left semantics. A provider without a drawable metric shows + a visible fallback in Providers, metrics & bars or an accessible unavailable rail in the two bar-only presentations. + The two provider-labeled layouts keep inter-provider dividers; Bars only uses balanced whitespace between and around + provider groups so hover highlighting remains centered. - The global open-menu keyboard shortcut toggles the currently tracked menu closed before opening a new one. - Display → Menu Bar → Layout provides presets plus a token editor. Tokens can be clicked to append, dragged from the palette, reordered between one or two lines, dragged out, or removed with Delete. Layouts can be global or overridden @@ -86,7 +96,10 @@ window has elapsed. - Advanced: “Disable Keychain access” turns off browser cookie import; paste Cookie headers manually in Providers. - Advanced: “Show provider storage usage” enables background scans of known provider-owned local paths; CodexBar only reports sizes and cleanup ideas, it does not delete files. -- Display: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 6). +- Menu Bar → Combined Icon: “Overview tab providers” controls which providers appear in Merge Icons → Overview + (up to 6). +- Menu → Content: “Overview layout” offers Detailed, Providers, metrics & bars, Providers & bars, and Bars only. The + picker is disabled while Merge Icons is off, retains its stored value, and defaults to Detailed. - If no providers are selected for Overview, the Overview tab is hidden. - Providers → Claude: “Avoid Keychain prompts” selects the Security.framework reader's `Never prompt` policy. - The lower-level “Keychain prompt policy” picker remains visible as the source of truth for Claude OAuth prompts.