From 9309dc54e13e1bb9394eca87783ceaa2c13f63e1 Mon Sep 17 00:00:00 2001 From: Trim Date: Fri, 31 Jul 2026 10:49:09 -0400 Subject: [PATCH 1/4] Add compact Overview layout --- Sources/CodexBar/CompactOverviewRow.swift | 435 ++++++++++ Sources/CodexBar/Localization.swift | 4 + Sources/CodexBar/PreferencesMenuPane.swift | 11 + .../Resources/ar.lproj/Localizable.strings | 3 + .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fa.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/gl.lproj/Localizable.strings | 3 + .../Resources/id.lproj/Localizable.strings | 3 + .../Resources/it.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../Resources/nl.lproj/Localizable.strings | 3 + .../Resources/pl.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 3 + .../Resources/th.lproj/Localizable.strings | 3 + .../Resources/tr.lproj/Localizable.strings | 3 + .../Resources/uk.lproj/Localizable.strings | 3 + .../Resources/vi.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/SettingsStoreState.swift | 1 + ...StatusItemController+CompactOverview.swift | 118 +++ .../CodexBar/StatusItemController+Menu.swift | 61 +- .../StatusItemController+MenuCardItems.swift | 6 + ...tatusItemController+MenuPresentation.swift | 26 +- ...ItemController+MenuRefreshScheduling.swift | 30 + .../StatusItemController+MenuTypes.swift | 81 ++ .../StatusItemController+MenuWidthCache.swift | 7 +- Sources/CodexBar/StatusItemController.swift | 23 +- Sources/CodexBar/UsageProgressBar.swift | 7 +- .../CompactOverviewMenuIntegrationTests.swift | 293 +++++++ .../CompactOverviewProjectionTests.swift | 502 +++++++++++ .../CompactOverviewSettingsTests.swift | 74 ++ .../LocalizationLanguageCatalogTests.swift | 33 + .../PreferencesPaneSmokeTests.swift | 20 + ...2026-07-30-compact-overview-menu-design.md | 813 ++++++++++++++++++ docs/ui.md | 16 +- 46 files changed, 2576 insertions(+), 66 deletions(-) create mode 100644 Sources/CodexBar/CompactOverviewRow.swift create mode 100644 Sources/CodexBar/StatusItemController+CompactOverview.swift create mode 100644 Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift create mode 100644 Tests/CodexBarTests/CompactOverviewProjectionTests.swift create mode 100644 Tests/CodexBarTests/CompactOverviewSettingsTests.swift create mode 100644 docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md diff --git a/Sources/CodexBar/CompactOverviewRow.swift b/Sources/CodexBar/CompactOverviewRow.swift new file mode 100644 index 0000000000..b34ed3bb03 --- /dev/null +++ b/Sources/CodexBar/CompactOverviewRow.swift @@ -0,0 +1,435 @@ +import AppKit +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 accessibilityLabel: String { + self.percentStyle.accessibilityLabel + } + } + + 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 + } + + 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 + + init( + model: UsageMenuCardView.Model, + loadingText: String = L("Loading…"), + noBarsText: String = L("overview_compact_no_bars")) + { + self.providerName = model.providerName + self.lanes = model.metrics.compactMap { metric in + guard metric.statusText == nil else { return nil } + return Lane( + id: metric.id, + title: metric.title, + 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) + } else { + self.fallback = nil + } + self.layoutSignature = Self.makeLayoutSignature(lanes: self.lanes, fallback: self.fallback) + } + + var metricTitlesForColumnMeasurement: [String] { + if self.lanes.isEmpty { + return self.fallback?.metricTitle.map { [$0] } ?? [] + } + return self.lanes.map(\.title) + } + + private static func makeFallback( + model: UsageMenuCardView.Model, + loadingText: String, + noBarsText: String) -> 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: metric.title, text: statusText) + } + return .generic(text: noBarsText) + } + + 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) + } + + private static func joinSignature(_ fields: [String]) -> String { + fields.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") + } +} + +struct CompactOverviewTextWidthMeasurer { + enum Role: Hashable { + case provider + case metric + } + + let fontSignature: String + private let measure: (String, Role) -> CGFloat + + init(fontSignature: String, measure: @escaping (String, Role) -> CGFloat) { + self.fontSignature = fontSignature + self.measure = measure + } + + func width(of text: String, role: Role) -> CGFloat { + max(0, self.measure(text, role)) + } + + static func appKit() -> Self { + let bodyFont = NSFont.preferredFont(forTextStyle: .body) + let fonts: [Role: NSFont] = [ + .provider: NSFont.systemFont(ofSize: bodyFont.pointSize, weight: .semibold), + .metric: bodyFont, + ] + let signature = [Role.provider, .metric] + .compactMap { role -> String? in + guard let font = fonts[role] else { return nil } + return "\(font.fontName):\(font.pointSize)" + } + .joined(separator: "|") + return Self(fontSignature: signature) { text, role in + guard let font = fonts[role] else { return 0 } + return ceil((text as NSString).size(withAttributes: [.font: font]).width) + } + } +} + +enum CompactOverviewColumnLayoutError: Error, Equatable { + case menuWidthBelowMinimum(CGFloat) +} + +struct CompactOverviewColumnLayout { + struct AllocationInput { + let menuWidth: CGFloat + let idealMetricWidth: CGFloat + let fontSignature: String + let layoutDirection: LayoutDirection + } + + static let minimumMenuWidth: CGFloat = 310 + static let horizontalPadding: CGFloat = UsageMenuCardLayout.horizontalPadding + static let columnSpacing: CGFloat = 12 + static let metricWidthCap: CGFloat = 112 + static let minimumBarWidth: CGFloat = 146 + static let chevronGutterWidth: CGFloat = 12 + static let rowVerticalPadding: CGFloat = 4 + static let providerContentSpacing: CGFloat = 4 + static let laneSpacing: CGFloat = 3 + static let barHeight: CGFloat = 8 + + let menuWidth: CGFloat + let metricWidth: CGFloat + let barWidth: CGFloat + let layoutDirection: LayoutDirection + let signature: String + + var contentWidth: CGFloat { + self.metricWidth + + Self.columnSpacing + + self.barWidth + } + + var occupiedWidth: CGFloat { + Self.horizontalPadding * 2 + + self.contentWidth + } + + var providerHeaderWidth: CGFloat { + self.contentWidth - Self.chevronGutterWidth + } + + static func resolve( + menuWidth: CGFloat, + projections: [CompactOverviewProjection], + layoutDirection: LayoutDirection, + textWidthMeasurer: CompactOverviewTextWidthMeasurer) throws -> Self + { + let idealMetricWidth = projections + .flatMap(\.metricTitlesForColumnMeasurement) + .map { textWidthMeasurer.width(of: $0, role: .metric) } + .max() ?? 0 + + return try Self.allocate(AllocationInput( + menuWidth: menuWidth, + idealMetricWidth: idealMetricWidth, + fontSignature: textWidthMeasurer.fontSignature, + layoutDirection: layoutDirection)) + } + + static func resolveForMenu( + menuWidth: CGFloat, + projections: [CompactOverviewProjection], + layoutDirection: LayoutDirection, + textWidthMeasurer: CompactOverviewTextWidthMeasurer) -> Self + { + assert( + menuWidth >= self.minimumMenuWidth, + "Compact Overview menu width must be at least \(self.minimumMenuWidth) points") + do { + return try self.resolve( + menuWidth: menuWidth, + projections: projections, + layoutDirection: layoutDirection, + textWidthMeasurer: textWidthMeasurer) + } catch { + preconditionFailure("Compact Overview failed to resolve a supported menu width: \(error)") + } + } + + static func allocate(_ input: AllocationInput) throws -> Self { + guard input.menuWidth >= self.minimumMenuWidth else { + throw CompactOverviewColumnLayoutError.menuWidthBelowMinimum(input.menuWidth) + } + + let idealMetricWidth = max(0, input.idealMetricWidth) + let metricWidth = min(idealMetricWidth, Self.metricWidthCap) + let fixedWidth = Self.horizontalPadding * 2 + + Self.columnSpacing + var barWidth = input.menuWidth - fixedWidth - metricWidth + + let widthExpansion = max(0, Self.minimumBarWidth - barWidth) + let resolvedMenuWidth = input.menuWidth + widthExpansion + barWidth += widthExpansion + + let direction = switch input.layoutDirection { + case .leftToRight: "ltr" + case .rightToLeft: "rtl" + @unknown default: "unknown" + } + let signature = Self.signature(fields: [ + "menu=\(Self.geometryToken(resolvedMenuWidth))", + "metric=\(Self.geometryToken(metricWidth))", + "bar=\(Self.geometryToken(barWidth))", + "spacing=\(Self.geometryToken(Self.columnSpacing))", + "gutter=\(Self.geometryToken(Self.chevronGutterWidth))", + "padding=\(Self.geometryToken(Self.horizontalPadding))", + "barHeight=\(Self.geometryToken(Self.barHeight))", + "providerSpacing=\(Self.geometryToken(Self.providerContentSpacing))", + "laneSpacing=\(Self.geometryToken(Self.laneSpacing))", + "font=\(input.fontSignature)", + "direction=\(direction)", + ]) + return Self( + menuWidth: resolvedMenuWidth, + metricWidth: metricWidth, + barWidth: barWidth, + layoutDirection: input.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 CompactOverviewRowContent: View { + let projection: CompactOverviewProjection + let columns: CompactOverviewColumnLayout + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: CompactOverviewColumnLayout.providerContentSpacing) { + HStack(spacing: 0) { + Text(self.projection.providerName) + .font(.headline) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: self.columns.providerHeaderWidth, alignment: .leading) + .help(self.projection.providerName) + .accessibilityHidden(true) + + Color.clear + .frame(width: CompactOverviewColumnLayout.chevronGutterWidth, height: 0) + .accessibilityHidden(true) + } + + self.content + } + .padding(.horizontal, CompactOverviewColumnLayout.horizontalPadding) + .padding(.vertical, CompactOverviewColumnLayout.rowVerticalPadding) + .frame(width: self.columns.menuWidth, alignment: .leading) + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var content: some View { + if self.projection.lanes.isEmpty, let fallback = self.projection.fallback { + CompactOverviewFallbackContent(fallback: fallback, columns: self.columns) + } else { + VStack(alignment: .leading, spacing: CompactOverviewColumnLayout.laneSpacing) { + ForEach(self.projection.lanes) { lane in + CompactOverviewMetricLane(lane: lane, columns: self.columns) + } + } + .accessibilityElement(children: .contain) + } + } +} + +private struct CompactOverviewMetricLane: View { + let lane: CompactOverviewProjection.Lane + let columns: CompactOverviewColumnLayout + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Text(self.lane.title) + .font(.body) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: self.columns.metricWidth, alignment: .leading) + .help(self.lane.title) + .accessibilityHidden(true) + + Color.clear + .frame(width: CompactOverviewColumnLayout.columnSpacing, height: 0) + .accessibilityHidden(true) + + UsageProgressBar( + percent: self.lane.percent, + tint: self.lane.tint, + accessibilityLabel: self.lane.accessibilityLabel, + pacePercent: self.lane.pacePercent, + paceOnTop: self.lane.paceOnTop, + warningMarkerPercents: self.lane.warningMarkerPercents, + workdayMarkerPercents: self.lane.workdayMarkerPercents, + height: CompactOverviewColumnLayout.barHeight) + .frame(width: self.columns.barWidth) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(self.lane.title) + } +} + +private struct CompactOverviewFallbackContent: View { + let fallback: CompactOverviewProjection.Fallback + let columns: CompactOverviewColumnLayout + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + switch self.fallback { + case let .status(_, title, text): + HStack(spacing: 0) { + self.fallbackText(title, width: self.columns.metricWidth) + Color.clear + .frame(width: CompactOverviewColumnLayout.columnSpacing, height: 0) + .accessibilityHidden(true) + self.fallbackText( + text, + width: self.columns.barWidth) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityValue(text) + case let .loading(text), let .generic(text): + self.fallbackText(text, width: self.columns.contentWidth) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + } + } + + private func fallbackText(_ text: String, width: CGFloat) -> some View { + Text(text) + .font(.body) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: width, alignment: .leading) + .help(text) + } +} 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/PreferencesMenuPane.swift b/Sources/CodexBar/PreferencesMenuPane.swift index b0624477ad..2a60b5845e 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,13 @@ struct MenuPane: View { } Section { + Toggle(isOn: self.$settings.mergedOverviewUsesCompactLayout) { + SettingsRowLabel( + L("overview_compact_title"), + subtitle: L("overview_compact_subtitle")) + } + .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/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index c59e9261ea..f204fdc58e 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -622,6 +622,9 @@ "multi_account_layout_segmented" = "مقسم"; "multi_account_layout_stacked" = "مكدس"; "overview_tab_providers_title" = "نظرة عامة على مزودي تبويب"; +"overview_compact_title" = "نظرة عامة مدمجة"; +"overview_compact_subtitle" = "اعرض أسماء المزوّدين وأشرطة الاستخدام بتنسيق موفّر للمساحة."; +"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..c222e10406 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -600,6 +600,9 @@ "multi_account_layout_segmented" = "Segmentat"; "multi_account_layout_stacked" = "Apilat"; "overview_tab_providers_title" = "Proveïdors de la pestanya Resum"; +"overview_compact_title" = "Resum compacte"; +"overview_compact_subtitle" = "Mostra els noms dels proveïdors i les barres d’ús en un disseny que estalvia espai."; +"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..ad3a54641d 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -614,6 +614,9 @@ "multi_account_layout_segmented" = "Segmentiert"; "multi_account_layout_stacked" = "Gestapelt"; "overview_tab_providers_title" = "Anbieter von Übersichtsregisterkarten"; +"overview_compact_title" = "Kompakte Übersicht"; +"overview_compact_subtitle" = "Zeigt Anbieternamen und Nutzungsbalken in einem platzsparenden Layout."; +"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..dd8d817035 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -599,6 +599,9 @@ "multi_account_layout_segmented" = "Segmented"; "multi_account_layout_stacked" = "Stacked"; "overview_tab_providers_title" = "Overview providers"; +"overview_compact_title" = "Compact Overview"; +"overview_compact_subtitle" = "Show provider names and usage bars in a space-saving layout."; +"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..09f35fd867 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -608,6 +608,9 @@ "multi_account_layout_segmented" = "Segmentado"; "multi_account_layout_stacked" = "Apilado"; "overview_tab_providers_title" = "Proveedores de la pestaña Resumen"; +"overview_compact_title" = "Resumen compacto"; +"overview_compact_subtitle" = "Muestra los nombres de los proveedores y las barras de uso en un diseño compacto."; +"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..e0e38bbdbf 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -622,6 +622,9 @@ "multi_account_layout_segmented" = "بخش بندی شده"; "multi_account_layout_stacked" = "انباشته شده"; "overview_tab_providers_title" = "ارائه دهندگان تب مرور کلی"; +"overview_compact_title" = "نمای کلی فشرده"; +"overview_compact_subtitle" = "نام ارائه‌دهندگان و نوارهای مصرف را در چیدمانی کم‌جا نشان می‌دهد."; +"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..54438260fb 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -616,6 +616,9 @@ "multi_account_layout_segmented" = "Segmenté"; "multi_account_layout_stacked" = "Empilé"; "overview_tab_providers_title" = "Fournisseurs d'onglets de présentation"; +"overview_compact_title" = "Vue d’ensemble compacte"; +"overview_compact_subtitle" = "Affichez les noms des fournisseurs et les barres d’utilisation dans une disposition compacte."; +"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..8f7a2a80a6 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -595,6 +595,9 @@ "multi_account_layout_segmented" = "Segmentado"; "multi_account_layout_stacked" = "Apilado"; "overview_tab_providers_title" = "Provedores da lapela Resumo"; +"overview_compact_title" = "Resumo compacto"; +"overview_compact_subtitle" = "Mostra os nomes dos provedores e as barras de uso nun deseño que aforra espazo."; +"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..eed6ce1d57 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -624,6 +624,9 @@ "multi_account_layout_segmented" = "Tersegmentasi"; "multi_account_layout_stacked" = "Bertumpuk"; "overview_tab_providers_title" = "Penyedia tab ikhtisar"; +"overview_compact_title" = "Ikhtisar ringkas"; +"overview_compact_subtitle" = "Tampilkan nama penyedia dan bilah penggunaan dalam tata letak hemat ruang."; +"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..b72244659b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -624,6 +624,9 @@ "multi_account_layout_segmented" = "Segmentato"; "multi_account_layout_stacked" = "Impilato"; "overview_tab_providers_title" = "Provider della scheda Panoramica"; +"overview_compact_title" = "Panoramica compatta"; +"overview_compact_subtitle" = "Mostra i nomi dei provider e le barre di utilizzo in un layout salvaspazio."; +"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..259c5c835f 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -613,6 +613,9 @@ "multi_account_layout_segmented" = "セグメント"; "multi_account_layout_stacked" = "スタック"; "overview_tab_providers_title" = "概要タブのプロバイダ"; +"overview_compact_title" = "コンパクトな概要"; +"overview_compact_subtitle" = "プロバイダ名と使用量バーを省スペースのレイアウトで表示します。"; +"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..c5f24f2a62 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -605,6 +605,9 @@ "multi_account_layout_segmented" = "분할"; "multi_account_layout_stacked" = "쌓기"; "overview_tab_providers_title" = "개요 탭 공급자"; +"overview_compact_title" = "간결한 개요"; +"overview_compact_subtitle" = "공급자 이름과 사용량 막대를 공간 절약형 레이아웃으로 표시합니다."; +"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..413206dcae 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -616,6 +616,9 @@ "multi_account_layout_segmented" = "Gesegmenteerd"; "multi_account_layout_stacked" = "Gestapeld"; "overview_tab_providers_title" = "Overzicht tabblad aanbieders"; +"overview_compact_title" = "Compact overzicht"; +"overview_compact_subtitle" = "Toon providernamen en gebruiksbalken in een ruimtebesparende indeling."; +"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..43e9bb3a91 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -624,6 +624,9 @@ "multi_account_layout_segmented" = "Segmentowy"; "multi_account_layout_stacked" = "Ułożony"; "overview_tab_providers_title" = "Dostawcy zakładki Przegląd"; +"overview_compact_title" = "Kompaktowy przegląd"; +"overview_compact_subtitle" = "Pokazuj nazwy dostawców i paski użycia w układzie oszczędzającym miejsce."; +"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..4ec6a72b3d 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -613,6 +613,9 @@ "multi_account_layout_segmented" = "Segmentado"; "multi_account_layout_stacked" = "Empilhado"; "overview_tab_providers_title" = "Provedores da aba Visão geral"; +"overview_compact_title" = "Visão geral compacta"; +"overview_compact_subtitle" = "Mostre nomes de provedores e barras de uso em um layout que economiza espaço."; +"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..5d5264f5dc 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -617,6 +617,9 @@ "multi_account_layout_segmented" = "Сегментированный"; "multi_account_layout_stacked" = "Стопкой"; "overview_tab_providers_title" = "Провайдеры вкладки «Обзор»"; +"overview_compact_title" = "Компактный обзор"; +"overview_compact_subtitle" = "Показывать названия провайдеров и индикаторы использования в компактном виде."; +"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..96d92e4ee3 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -615,6 +615,9 @@ "multi_account_layout_segmented" = "Segmenterad"; "multi_account_layout_stacked" = "Staplad"; "overview_tab_providers_title" = "Leverantörer på översiktsfliken"; +"overview_compact_title" = "Kompakt översikt"; +"overview_compact_subtitle" = "Visa leverantörsnamn och användningsstaplar i en utrymmessnål layout."; +"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..b7a5288909 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -622,6 +622,9 @@ "multi_account_layout_segmented" = "แบ่งกลุ่ม"; "multi_account_layout_stacked" = "ซ้อนกัน"; "overview_tab_providers_title" = "ผู้ให้บริการแท็บภาพรวม"; +"overview_compact_title" = "ภาพรวมแบบกะทัดรัด"; +"overview_compact_subtitle" = "แสดงชื่อผู้ให้บริการและแถบการใช้งานในรูปแบบที่ประหยัดพื้นที่"; +"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..a355e60bb3 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -622,6 +622,9 @@ "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_compact_title" = "Kompakt Genel Bakış"; +"overview_compact_subtitle" = "Sağlayıcı adlarını ve kullanım çubuklarını yerden tasarruf eden bir düzende gösterin."; +"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..608e3728a9 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -616,6 +616,9 @@ "multi_account_layout_segmented" = "Сегментований"; "multi_account_layout_stacked" = "складені"; "overview_tab_providers_title" = "Постачальники вкладок огляду"; +"overview_compact_title" = "Компактний огляд"; +"overview_compact_subtitle" = "Показувати назви постачальників і смуги використання в компактному компонуванні."; +"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..f08adb93f0 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -612,6 +612,9 @@ "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_compact_title" = "Tổng quan thu gọn"; +"overview_compact_subtitle" = "Hiển thị tên nhà cung cấp và thanh mức sử dụng trong bố cục tiết kiệm không gian."; +"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..dbd5165b35 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -595,6 +595,9 @@ "multi_account_layout_segmented" = "分段"; "multi_account_layout_stacked" = "堆叠"; "overview_tab_providers_title" = "概览标签提供商"; +"overview_compact_title" = "紧凑概览"; +"overview_compact_subtitle" = "以节省空间的布局显示提供商名称和使用量条。"; +"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..90cf9967d6 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -615,6 +615,9 @@ "multi_account_layout_segmented" = "分段"; "multi_account_layout_stacked" = "堆疊"; "overview_tab_providers_title" = "概覽標籤提供者"; +"overview_compact_title" = "精簡概覽"; +"overview_compact_subtitle" = "以節省空間的版面顯示提供者名稱和用量列。"; +"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..2666c6f534 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -748,6 +748,14 @@ extension SettingsStore { } } + var mergedOverviewUsesCompactLayout: Bool { + get { self.defaultsState.mergedOverviewUsesCompactLayout } + set { + self.defaultsState.mergedOverviewUsesCompactLayout = newValue + self.userDefaults.set(newValue, 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..f56f6e0dbe 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.mergedOverviewUsesCompactLayout _ = self.switcherShowsIcons _ = self.mergedOverviewSelectedProviders _ = self.zaiAPIToken diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6970f1a131..c82705d6c1 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -524,6 +524,8 @@ extension SettingsStore { } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true + let mergedOverviewUsesCompactLayout = userDefaults.object( + forKey: "mergedOverviewUsesCompactLayout") as? Bool ?? false let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true let mergedMenuLastSelectedWasOverview = userDefaults.object( forKey: "mergedMenuLastSelectedWasOverview") as? Bool ?? false @@ -611,6 +613,7 @@ extension SettingsStore { providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, + mergedOverviewUsesCompactLayout: mergedOverviewUsesCompactLayout, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 5e49bc9a62..a997da60f4 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 mergedOverviewUsesCompactLayout: Bool 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..7e349eb43e --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CompactOverview.swift @@ -0,0 +1,118 @@ +import AppKit +import CodexBarCore +import QuartzCore +import SwiftUI + +extension StatusItemController { + @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 compactRequested = self.settings.mergedOverviewUsesCompactLayout + 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 compactRequested, model.usesLiveSubtitle { + self.menuCardRefreshMonitor.model(for: provider, fallback: model) + } else { + model + } + return ( + provider: provider, + model: model, + layoutModel: layoutModel, + projection: compactRequested ? CompactOverviewProjection(model: layoutModel) : nil) + } + guard !rows.isEmpty else { return false } + + let compactColumns: CompactOverviewColumnLayout? = if compactRequested { + CompactOverviewColumnLayout.resolveForMenu( + menuWidth: menuWidth, + projections: rows.compactMap(\.projection), + layoutDirection: codexBarUsesRightToLeftLayout() + ? .rightToLeft + : .leftToRight, + textWidthMeasurer: .appKit()) + } else { + nil + } + let rowStyle: OverviewMenuRowStyle = compactRequested ? .compact : .detailed + + 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 = 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.layoutModel.heightFingerprint( + section: "overviewCompact", + additional: [ + "projection=\(row.projection?.layoutSignature ?? "missing")", + "columns=\(compactColumns?.signature ?? "missing")", + ]) + } + let accessibilityLabel = rowStyle == .compact + ? row.projection?.providerName ?? row.layoutModel.providerName + : row.model.providerName + let item = self.makeMenuCardItem( + OverviewMenuCardRowView( + model: row.model, + layoutModel: row.layoutModel, + storageText: storageText, + width: menuWidth, + style: rowStyle, + compactColumns: compactColumns), + id: identifier, + width: menuWidth, + heightCacheScope: row.provider.rawValue, + heightCacheFingerprint: heightFingerprint, + submenu: submenu, + containsInteractiveControls: OverviewMenuRowInteractionPolicy.containsInteractiveControls( + style: rowStyle, + model: row.model), + usesGPUSelection: true, + layoutDirection: rowStyle == .compact ? compactColumns?.layoutDirection : nil, + accessibilityLabel: accessibilityLabel, + accessibilityHelp: rowStyle == .compact ? 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 index < rows.count - 1 { + menu.addItem(.separator()) + } + } + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index d4b74dcef3..e6b5d44d56 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, + usesCompactOverview: isOverviewSelected && self.settings.mergedOverviewUsesCompactLayout) 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..9608926b17 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -34,6 +34,9 @@ extension StatusItemController { submenuIndicatorTopPadding: CGFloat = 8, containsInteractiveControls: Bool = false, usesGPUSelection: Bool = false, + layoutDirection: LayoutDirection? = nil, + accessibilityLabel: String? = nil, + accessibilityHelp: String? = nil, onClick: (() -> Void)? = nil) -> NSMenuItem { let allowsMenuHighlight = submenu != nil || onClick != nil @@ -60,6 +63,9 @@ extension StatusItemController { allowsMenuHighlight: allowsMenuHighlight, containsInteractiveControls: containsInteractiveControls, usesGPUSelection: usesGPUSelection, + layoutDirection: layoutDirection, + accessibilityLabel: accessibilityLabel, + accessibilityHelp: accessibilityHelp, onClick: onClick) let hosting: ErasedMenuCardHostingView if let recycled = self.takeRecyclableMenuCardView( diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 748c6e4864..8d3fbff8ef 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -142,6 +142,9 @@ struct MenuCardRowPayload { let allowsMenuHighlight: Bool let containsInteractiveControls: Bool let usesGPUSelection: Bool + let layoutDirection: LayoutDirection? = nil + let accessibilityLabel: String? = nil + let accessibilityHelp: String? = nil let onClick: (() -> Void)? } @@ -210,6 +213,7 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin self.hosting.wantsLayer = true self.hosting.autoresizingMask = [.width, .height] self.addSubview(self.hosting) + self.applyAccessibilityPayload() self.configureSelectionMode(animated: false) } @@ -233,6 +237,7 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin highlightState: self.highlightState, refreshMonitor: refreshMonitor, interactiveRegionStore: self.interactiveRegionStore) + self.applyAccessibilityPayload() self.configureSelectionMode(animated: false) self.invalidateIntrinsicContentSize() } @@ -491,6 +496,7 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin showsSubmenuIndicator: payload.showsSubmenuIndicator, submenuIndicatorAlignment: payload.submenuIndicatorAlignment, submenuIndicatorTopPadding: payload.submenuIndicatorTopPadding, + layoutDirection: payload.layoutDirection, refreshMonitor: refreshMonitor, interactiveRegionStore: interactiveRegionStore) { @@ -498,6 +504,11 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin } } + private func applyAccessibilityPayload() { + self.setAccessibilityLabel(self.rowPayload.accessibilityLabel) + 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)) @@ -814,15 +825,18 @@ struct MenuCardSectionContainerView: View { let showsSubmenuIndicator: Bool let submenuIndicatorAlignment: Alignment let submenuIndicatorTopPadding: CGFloat + let layoutDirectionOverride: LayoutDirection? var refreshMonitor: MenuCardRefreshMonitor? var interactiveRegionStore: MenuCardInteractiveRegionStore? @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, @ViewBuilder content: @escaping () -> Content) @@ -831,6 +845,7 @@ struct MenuCardSectionContainerView: View { self.showsSubmenuIndicator = showsSubmenuIndicator self.submenuIndicatorAlignment = submenuIndicatorAlignment self.submenuIndicatorTopPadding = submenuIndicatorTopPadding + self.layoutDirectionOverride = layoutDirection self.refreshMonitor = refreshMonitor self.interactiveRegionStore = interactiveRegionStore self.content = content @@ -855,13 +870,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" } } diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index 6f8adbcdef..af4a51121f 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -122,6 +122,13 @@ extension StatusItemController { "claudeSwapRevision=\(self.store.claudeSwapRevision)", ] + if self.shouldMergeIcons, + self.settings.mergedMenuLastSelectedWasOverview, + self.settings.mergedOverviewUsesCompactLayout + { + 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 +146,29 @@ extension StatusItemController { return parts.joined(separator: "|") } + func compactOverviewStructuralSignature() -> String { + let providers = self.settings.resolvedMergedOverviewProviders( + activeProviders: self.store.enabledProvidersForDisplay(), + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + return 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: ";") + } + static func dashboardBreakdownReadinessSignature( _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String { diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index ada2187c6b..0f236f34c0 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -21,15 +21,87 @@ extension ProviderSwitcherSelection { } } +enum OverviewMenuRowStyle: Equatable { + case detailed + case compact +} + +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 showsSectionDividers = false let model: UsageMenuCardView.Model + let layoutModel: UsageMenuCardView.Model? let storageText: String? let width: CGFloat + let style: OverviewMenuRowStyle + let compactColumns: CompactOverviewColumnLayout? @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, + compactColumns: CompactOverviewColumnLayout? = nil) + { + self.model = model + self.layoutModel = layoutModel + self.storageText = storageText + self.width = width + self.style = style + self.compactColumns = compactColumns + } var body: some View { + switch self.style { + case .detailed: + self.detailedContent + case .compact: + if let compactColumns = self.compactColumns { + CompactOverviewRowContent( + projection: self.compactProjection, + columns: compactColumns) + } + } + } + + private var detailedContent: some View { VStack(alignment: .leading, spacing: 0) { UsageMenuCardHeaderSectionView( model: self.model, @@ -63,6 +135,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..bbf12a07a0 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, + usesCompactOverview: Bool = false) -> 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 usesCompactOverview else { return measuredWidth } + return max(measuredWidth, CompactOverviewColumnLayout.minimumMenuWidth) } func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 1e27a4cee8..3c0481f468 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 usesCompactLayout: Bool + let selectedProviders: [UsageProvider] + + init(settings: SettingsStore) { + self.usesCompactLayout = settings.mergedOverviewUsesCompactLayout + 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 @@ -681,6 +694,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin 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 } @@ -695,6 +713,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 +725,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.persistWidgetSnapshotIfWidgetDisplaySettingsChanged() if shouldRefreshOpenMenus { self.refreshOpenMenusAllowingParentRebuild( - deferParentRebuildDuringTracking: !localizationChanged) + deferParentRebuildDuringTracking: !( + localizationChanged || overviewChanged)) } } diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index de00f0486a..03925a1fff 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -39,6 +39,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 +50,8 @@ struct UsageProgressBar: View { pacePercent: Double? = nil, paceOnTop: Bool = true, warningMarkerPercents: [Double] = [], - workdayMarkerPercents: [Double] = []) + workdayMarkerPercents: [Double] = [], + height: CGFloat = 6) { self.percent = percent self.tint = tint @@ -58,6 +60,7 @@ struct UsageProgressBar: View { self.paceOnTop = paceOnTop self.warningMarkerPercents = warningMarkerPercents self.workdayMarkerPercents = workdayMarkerPercents + self.height = height } private var clamped: Double { @@ -154,7 +157,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..f6223a4aaa --- /dev/null +++ b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift @@ -0,0 +1,293 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct CompactOverviewMenuIntegrationTests { + @Test + func `compact overview assembles variable lane rows in provider order`() throws { + let fixture = self.makeFixture(compact: true) + 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", + ]) + + let cursorIndex = try #require(menu.items.firstIndex(of: rows[0])) + let claudeIndex = try #require(menu.items.firstIndex(of: rows[1])) + #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() == 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() == 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? GPUSelectionHostingView) + #expect(claudeView._test_simulateRuntimeClick()) + #expect(!fixture.settings.mergedMenuLastSelectedWasOverview) + #expect(fixture.settings.selectedMenuProvider == .claude) + } + + @Test + func `compact and detailed rows use distinct cache geometry`() throws { + let compact = self.makeFixture(compact: true) + let detailed = self.makeFixture(compact: false) + defer { + compact.controller.releaseStatusItemsForTesting() + detailed.controller.releaseStatusItemsForTesting() + } + + let compactMenu = self.renderOverviewMenu(compact.controller) + let detailedMenu = self.renderOverviewMenu(detailed.controller) + let compactRows = Self.rowsByProvider(in: compactMenu) + let detailedRows = Self.rowsByProvider(in: detailedMenu) + 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(compactKeys.map(\.scope)) == expectedScopes) + #expect(Set(detailedKeys.map(\.scope)) == expectedScopes) + + for provider in [UsageProvider.cursor, .claude] { + let compactRow = try #require(compactRows[provider]) + let detailedRow = try #require(detailedRows[provider]) + let compactKey = try #require(compactKeys.first { $0.scope == provider.rawValue }) + let detailedKey = try #require(detailedKeys.first { $0.scope == provider.rawValue }) + let compactHeight = try #require(compactRow.view?.frame.height) + let detailedHeight = try #require(detailedRow.view?.frame.height) + + #expect(compactKey.id == detailedKey.id) + #expect(compactKey.fingerprint != detailedKey.fingerprint) + #expect(compactHeight < detailedHeight) + } + + let initialCursorFingerprint = try #require( + compactKeys.first { $0.scope == UsageProvider.cursor.rawValue }?.fingerprint) + 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 == 2) + } + + @Test + func `structural signature ignores values and detects lanes and peer titles`() { + let fixture = self.makeFixture(compact: true) + 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) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + let controller: StatusItemController + } + + private func makeFixture(compact: Bool) -> 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.mergedOverviewUsesCompactLayout = compact + settings.historicalTrackingEnabled = false + settings.showOptionalCreditsAndExtraUsage = true + settings.providerStorageFootprintsEnabled = false + settings.costUsageEnabled = false + settings.setProviderOrder([.cursor, .claude]) + self.enableOnly([.cursor, .claude], settings: settings) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + 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: false, + observeProviderConfigNotifications: false) + return Fixture(settings: settings, store: store, controller: controller) + } + + 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 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..577df8a6a9 --- /dev/null +++ b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift @@ -0,0 +1,502 @@ +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)) + } + + @Test + func `hosted row height includes a provider header and follows every lane`() throws { + let layout = try CompactOverviewColumnLayout.allocate(.init( + menuWidth: 310, + idealMetricWidth: 112, + fontSignature: "fixture-font", + layoutDirection: .leftToRight)) + var heights: [Int: CGFloat] = [:] + for count in [0, 1, 2, 3, 6, 12] { + let metrics = (0..= 40) + #expect(oneLaneHeight < twoLaneHeight) + #expect(twoLaneHeight < threeLaneHeight) + #expect(threeLaneHeight < sixLaneHeight) + #expect(sixLaneHeight < twelveLaneHeight) + #expect(twoLaneHeight <= 66) + #expect(twoLaneHeight + 7 <= 73) + #expect(abs((twoLaneHeight - oneLaneHeight) - (threeLaneHeight - twoLaneHeight)) <= 1) + + let canonicalAttachedHeight = 2 * (oneLaneHeight + 7) + + 2 * (twoLaneHeight + 7) + + 2 * (threeLaneHeight + 7) + #expect(canonicalAttachedHeight <= 432) + } + + 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..1d4c7bee09 --- /dev/null +++ b/Tests/CodexBarTests/CompactOverviewSettingsTests.swift @@ -0,0 +1,74 @@ +import CodexBarCore +import Foundation +import Observation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct CompactOverviewSettingsTests { + 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 `compact overview defaults off persists and refreshes only menus`() async throws { + let suite = "SettingsStoreTests-compact-overview" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.mergedOverviewUsesCompactLayout == false) + #expect(defaults.object(forKey: "mergedOverviewUsesCompactLayout") == nil) + + 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.mergedOverviewUsesCompactLayout = true + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(defaults.bool(forKey: "mergedOverviewUsesCompactLayout")) + #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) + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.mergedOverviewUsesCompactLayout) + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 27a81f1d63..ba42f02f2a 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -109,6 +109,39 @@ struct LocalizationLanguageCatalogTests { } } + @Test + func `compact overview 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_compact_title": "Compact Overview", + "overview_compact_subtitle": "Show provider names and usage bars in a space-saving layout.", + "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..3431f7619b 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -126,6 +126,26 @@ struct PreferencesPaneSmokeTests { #expect(!text.contains("%@")) } + @Test + func `menu pane compact overview toggle follows merge icons without clearing its value`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-compact-overview") + let store = Self.makeUsageStore(settings: settings) + settings.mergedOverviewUsesCompactLayout = true + + #expect(MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) + _ = MenuPane(settings: settings, store: store).body + + settings.mergeIcons = false + #expect(!MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) + #expect(settings.mergedOverviewUsesCompactLayout) + _ = MenuPane(settings: settings, store: store).body + + settings.mergeIcons = true + #expect(MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) + #expect(settings.mergedOverviewUsesCompactLayout) + _ = 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/docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md b/docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md new file mode 100644 index 0000000000..b62b6044a6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md @@ -0,0 +1,813 @@ +--- +summary: "Approved opt-in compact layout for the merged menu's Overview tab." +read_when: + - Implementing or reviewing compact Overview rows + - Changing Overview provider selection, row height, or menu refresh behavior + - Changing which usage-bar details appear in the merged menu +--- + +# Compact Overview menu — design + +**Status:** implemented +**Date:** 2026-07-30 +**Revised:** 2026-07-31 + +## Decision summary + +Add an opt-in **Compact Overview** setting for the merged menu. Detailed Overview remains the default. + +Compact Overview keeps the existing Overview provider selection, ordering, navigation, refresh, and submenu behavior, +but replaces each rich provider card with one actionable provider item containing: + +- a dedicated provider-name header row; and +- every drawable usage metric beneath that header, in the provider's existing + `UsageMenuCardView.Model.metrics` order. + +Each bar keeps a short metric label so multiple windows remain distinguishable. Visible numeric percentages are +omitted; the progress bar retains the existing percentage and used-versus-left accessibility semantics. Account identity, +freshness, plan, reset time, details, storage, notes, dashboards, and dedicated credit or cost sections stay available +in provider details but do not appear inline in Compact Overview. A provider with no drawable metric uses one muted, +single-line fallback beneath its header so the row does not look unfinished; that fallback may surface an existing +status-only balance metric. + +“All bars” means all drawable bars for the providers selected for Overview. The existing six-provider selection limit +does not change in this feature. + +For this feature, a **drawable metric** is exactly a `UsageMenuCardView.Model.metrics` entry whose `statusText` is +`nil`. The existing model builder remains responsible for producing a valid percentage, title, and semantic metric; +Compact Overview adds no provider-specific or numeric filtering. Credits, cost, storage, reset credits, and inline +dashboards are separate model sections rather than compact metrics and remain excluded. + +## Why this needs a bounded design + +The current Overview row is effectively a full provider card. It composes the two-line provider header, full metric +rows, reset and detail text, optional notes or dashboards, and optional storage text. Six providers can therefore make +the menu taller than the available display and require scrolling. + +The desired compact mode is presentation-only, but Overview is also coupled to: + +- persisted provider selection and a six-provider cap; +- live model updates while an `NSMenu` is tracking; +- measured-height caching; +- provider-detail submenus and click-to-select behavior; +- custom wheel navigation and native trackpad scrolling; and +- GPU-backed row selection added after the Overview scroll-stutter investigation. + +A dedicated contract prevents a small layout option from accidentally changing those established behaviors. The +performance history and earlier Lite-row experiment are documented in +[`docs/overview-scroll-stutter-investigation.md`](../../overview-scroll-stutter-investigation.md). + +## Goals + +- Let users compare the selected providers' usage bars at a glance. +- Keep the normal six-provider Overview substantially shorter than Detailed while using comfortable, legible text and + bars; prefer a small amount of native scrolling over making the content uncomfortably small. +- Preserve every actual metric bar rather than silently choosing one window per provider. +- Keep the current detailed Overview unchanged for users who do not opt in. +- Reuse the existing menu-card model, progress renderer, live-refresh seam, and interaction wiring. +- Keep the implementation display-only: no new fetches, dependencies, account state, or provider-specific data paths. + +## Non-goals + +- Showing every enabled provider. Overview continues to show at most six user-selected providers. +- Guaranteeing a scroll-free menu for every display, Accessibility text size, or provider response. +- Capping or dropping bars merely to force the menu to fit. +- Changing provider order, Overview selection, switcher contents, row click behavior, or detail submenus. +- Compacting individual provider tabs, Settings provider details, widgets, or menu-bar icons. +- Adding new provider-specific credit, cost, storage, or inline-dashboard projections. The generic no-bar fallback may + reuse an existing redacted metric `statusText`, but it does not inspect provider snapshots or rich-card sections. +- Revisiting the existing Overview GPU-selection or scroll implementations. + +## User experience + +### Setting + +Preferences → Menu → Content gains a toggle as the first content-layout option: + +- **Title:** Compact Overview +- **Subtitle:** Show provider names and usage bars in a space-saving layout. + +The Overview provider selector remains in Preferences → Menu Bar → Combined Icon. Only the presentation toggle +moves to the Menu pane, because it controls menu content rather than the menu-bar status item. The toggle is disabled +when **Merge Icons** is off because Overview is not then available. Its stored value is retained, so re-enabling Merge +Icons restores the user's choice. + +The setting defaults off for both existing and new installations. No migration is required. + +### Compact row + +Each provider is one actionable menu item. Inside it, the provider name occupies a dedicated full-width header row and +the metric lanes are stacked underneath: + +```text +Codex › +Session ███████░░░ +Weekly ███░░░░░░░ + +Claude › +Session █████░░░░░ +Weekly ████████░░ +``` + +This is a layout illustration, not fixed copy. Localized metric titles and the existing used/remaining preference +remain authoritative. + +Each metric lane contains only: + +1. the existing localized metric title; +2. the existing `UsageProgressBar`. + +Do not render a percentage `Text` view or reserve a percentage column. The existing used/remaining preference remains +authoritative for bar fill and accessibility. `UsageProgressBar` continues to expose the formatted percentage together +with its localized `Usage remaining` or `Usage used` meaning to assistive technologies. + +The provider header spans the content width up to the fixed trailing chevron gutter; it is not part of the metric grid. +The chevron aligns vertically with the provider header. Every selected provider uses the same metric-label and bar +column widths. Long provider and metric names truncate to one line while preserving their full accessibility text. The +bar does not compress until the metric label reaches its cap. + +Provider rows are variable-height. A provider with one drawable metric gets one lane, a provider with `N` drawable +metrics gets exactly `N` lanes, and a provider with no drawable metrics gets one fallback lane. Do not reserve an empty +second lane, cap a row at two lanes, collapse additional lanes, or repeat the provider header. Keep vertical padding, +header-to-metrics spacing, lane height, and lane spacing in the compact-layout constant group. At default text size, a +one-lane or fallback hosted item should remain at most 54 points including the existing seven-point AppKit measurement +inset, and each additional lane should add at most 20 points. Attached-menu tests remain authoritative because AppKit +can prefer intrinsic height over the assigned frame. + +The 310-point minimum-width budget is: + +| Element | Budget | +| --- | ---: | +| Outer horizontal padding | 40 pt (`20 pt × 2`) | +| Metric-to-bar spacing | 12 pt | +| Metric label | up to 112 pt | +| Minimum progress bar | 146 pt | +| **Total** | **310 pt** | + +At 310 points and default text size, cap the shared metric column at 112 points. The allocator may reclaim unused metric +label width for the bar, but it must never give one row a different metric/bar split. At wider resolved menu widths, +keep the label cap, padding, and gap fixed and give all surplus width to the bar. The 12-point chevron gutter is carved +out of the provider header's 270-point content width; metric lanes span that full width below the header. + +Use `.headline` for the provider header and `.body` for the metric title. Use logical leading alignment for both. +Vertically center an eight-point progress bar in each metric lane. Use four points of vertical padding on each edge, +four points between the header and first metric, and three points between subsequent lanes. Keep these values in one +compact-layout constant group. + +The allocator's supported input is the production invariant `menuWidth >= 310`; assert that precondition in DEBUG and +unit tests rather than inventing an unreachable below-minimum layout. Measure metric titles with the resolved body +font and cap their shared selected-set maximum at 112 points. That fixed cap and the other fixed reservations guarantee +the 146-point bar floor at 310 points even when text scale changes; larger text truncates visually at the cap and keeps +its full accessibility string. If a future design adds or enlarges a fixed reservation, update this budget and the +production minimum together instead of allowing overlap or horizontal scrolling. + +The submenu chevron is currently an overlay rather than a layout participant. Reserve its gutter in every provider +header, including rows without a submenu, so headers align and cannot overlap the indicator. Because the indicator is +header-aligned, metric lanes below it may use the full content width. + +Compact rows use comfortable but still economical vertical spacing and keep the current separators between providers. +They do not add another scroll container; AppKit remains responsible if the whole menu exceeds the display. + +### Content rules + +Compact Overview first preserves the existing menu-build exclusion for `model.isOverviewErrorOnly`. It then renders, +in order, only metrics with `statusText == nil`. A metric with non-`nil` `statusText` is text-only in the current rich +card and must be omitted from the bar lanes rather than converted into a synthetic empty or full bar. It may be used +only by the no-drawable-metric fallback defined below. + +For each drawable metric, preserve: + +- `percent` and `percentStyle`; +- provider tint; +- pace percentage and pace direction; +- quota-warning marker percentages; and +- workday marker percentages. + +Compact Overview omits inline: + +- email, organization, workspace, and other account identity; +- update/loading/error subtitle and copy button; +- plan; +- reset time and all secondary/detail/session-equivalent lines; +- storage footprint; +- usage notes and placeholders; +- inline usage dashboards and charts; +- reset credits, dedicated credit-balance, provider-cost, and token-cost sections; and +- metric card backgrounds or other rich-card decoration. + +If a selected provider has no drawable metrics but is not excluded by the existing error-only rule, show its provider +header plus one muted, single-line fallback beneath it: + +1. when `model.subtitleStyle == .loading`, show `L("Loading…")`; +2. otherwise, trim each metric's `statusText` with `.whitespacesAndNewlines` and, if any result is non-empty, use the + first such metric in model order; or +3. otherwise show the localized compact no-bars string. + +For a status fallback, put the metric title in the shared metric column and let its trimmed status text occupy the bar +column. For loading or generic no-bars fallback, let the one fallback string span the metric and bar columns. Use +`.body`, secondary styling, leading alignment in the current layout direction, and +`.lineLimit(1)` for fallback text. This avoids hard-coded English punctuation or word order and preserves the shared +metric/bar grid in right-to-left locales. A one-lane fallback row must be no taller than the one-lane compact metric +row. + +This is the only compact-mode exception to names plus drawable bars. It covers shipped status-only balance models and +all-unlimited services, plus dashboard- or notes-only providers and providers with no current usage data. Fallback text +truncates visually but uses the already-redacted full model text for accessibility. The row remains navigable to +provider details. A live transition into or out of these states follows the existing compatible-layout and +structural-rebuild rules. + +### Accessibility + +The compact row remains one actionable AppKit menu item. Use a structured accessibility hierarchy rather than +concatenating a sentence with hard-coded punctuation or English word order: + +- The actionable row exposes the full provider name as its label and retains the existing open-provider-details action + and hint. +- Each drawable lane is a non-actionable informational child, in model order, whose label is the full metric title. + Its `UsageProgressBar` child keeps the existing localized `percentStyle.accessibilityLabel` (`Usage remaining` or + `Usage used`) and existing accessibility value for the formatted percentage, quota-warning markers, and workday + markers. This feature does not add new pace-announcement semantics. +- Hide the separate visual provider and metric `Text` nodes from accessibility so VoiceOver does not announce duplicate + fragments. Visual truncation must not truncate accessibility strings. +- A no-bar row has one non-actionable fallback child. For a status fallback, its label and value are the full metric + title and untruncated trimmed status respectively. For loading or generic fallback, its label is the full localized + fallback. The actionable parent continues to carry the provider name and row action. +- Omitted account, plan, reset, storage, dashboard, credit, and cost content must not remain in hidden accessibility + children. + +Tests must inspect these label/value fields separately, including under an Arabic or Persian locale, and must reject a +single synthesized provider/metric/value phrase. This preserves localized semantics without adding a fourth format +key. + +The three new localization keys are: + +- `overview_compact_title`: **Compact Overview** +- `overview_compact_subtitle`: **Show provider names and usage bars in a space-saving layout.** +- `overview_compact_no_bars`: **No usage bars** + +Use `overview_compact_no_bars` for both the visible generic fallback and its accessibility child label. Add all three +keys to all 23 complete locale catalogs and cover their presence with a focused localization-catalog test. The English +catalog uses the source copy above; the other 22 complete catalogs require non-empty reviewed translations rather than +copied English placeholders. English fallback is not the completion policy for these complete catalogs. + +### Interaction + +Compact and Detailed Overview preserve the same row-level interaction: + +- the same `overviewRow-` identity; +- the same provider order and selected subset; +- the same click and keyboard activation behavior; +- the same usage, cost, storage, and provider-specific submenus; +- the same submenu indicator; +- the same `usesGPUSelection: true` hosting path; +- the same mouse-wheel and trackpad behavior; +- the same viewport restoration behavior; and +- the same Refresh, Settings, About, and Quit footer. + +Selecting a compact row opens the existing detailed provider tab. Compact mode does not create a second compact +provider-detail surface. Compact has no embedded SwiftUI button, so its menu item must use +`containsInteractiveControls: false` while retaining `usesGPUSelection: true`. +Detailed keeps its existing `containsInteractiveControls` expression because its live error subtitle can expose a copy +button. + +## Fit contract + +Compact Overview should remain substantially shorter than Detailed without sacrificing readable text or bars. Use this +representative case for UI proof: + +- macOS 27.0 in the project's Parallels UI environment; +- a 1280 × 720-point logical display at 2× scale, English locale, default text size, and light appearance; +- an actual resolved menu width of 310 points, not merely the 310-point baseline before standard-item measurement; +- Overview plus Codex, Claude, Cursor, OpenCode, Warp, and Gemini in the switcher, with switcher icons enabled; +- those same six providers selected for Overview and none excluded by the error-only filter; +- drawable-metric counts of `1, 1, 2, 2, 3, 3` in provider order, totaling twelve bars, using **Session**, + **Weekly**, and **Code review** as needed at deterministic percentages; +- no update, agent-session, contextual-provider, storage, cost, credit, or debug rows; and +- the normal Refresh, Settings, About, and Quit footer. + +For that fixture, the complete menu's intrinsic content height should be no more than 680 points. The canonical +attached clip-view height is at least 656 points; the expected Parallels result is roughly 660 points. A vertical scroll +range of up to 24 points is acceptable. If the attached viewport is shorter than 656 points, record it as an +environment mismatch and report its actual scroll range separately. Zero scrolling is preferred when the actual +display and menu attachment permit it, but the implementation must not shrink the revised header, metric text, or +progress bars merely to achieve zero. + +The 680-point ceiling is a revised comfort-first design budget, not a previously measured result: + +| Element | Count and per-item target | Budget | +| --- | --- | ---: | +| Overview plus six-provider switcher | two 36 pt rows + 4 pt spacing | 76 pt | +| Compact provider items | six header-plus-metrics items, twelve total lanes, including each host's 7 pt measurement inset | ≤432 pt aggregate | +| Refresh | `1 × 24 pt` | 24 pt | +| Native Settings, About, and Quit rows | provisional `3 × 22 pt` | 66 pt | +| Separators | provisional `7 × 9 pt` | 63 pt | +| AppKit measurement and pixel-rounding headroom | remainder | 19 pt | +| **Total ceiling** | | **680 pt** | + +The seven-separator fixture is one separator after the switcher, five between the six provider rows, and one before the +footer. If the built menu has a different separator topology, record the actual count and heights and rebalance the +budget explicitly rather than treating 63 points as fixed. + +The switcher and Refresh values come from current source constants. Compact-item, native-item, and separator values +must be verified against the freshly built bundle because AppKit owns some of their geometry. A one-lane compact item +that exceeds 54 points, an additional lane increment that exceeds 20 points, or canonical compact items whose +aggregate exceeds 432 points is a failed design-budget assumption even if the complete menu happens to fit. + +Available height is also measured rather than inferred solely from the display resolution. Runtime proof must record +the display's `visibleFrame.height` and the attached menu clip-view height; the expected Parallels fixture has roughly +660 points of usable vertical space. The revised 680-point ceiling intentionally accepts at most one metric-lane-sized +scroll increment in that environment in exchange for a dedicated provider header, larger labels, and larger bars. + +This is an acceptance target, not a universal geometric guarantee. Metric counts are not globally bounded: +`extraRateWindows` and some provider responses can add additional windows. Accessibility text sizes and small displays +also reduce available space. + +When fit and completeness conflict, preserve all drawable bars and allow AppKit scrolling. Do not silently cap metrics, +restore visible percentages, shrink the approved text or bar sizes, or introduce horizontal scrolling. + +The PR's runtime proof must record the actual OS build and confirm every canonical parameter above. Record the resolved +menu width, display visible-frame height, intrinsic content height, attached clip-view height, per-item heights, and +vertical scroll range. A screenshot alone is supporting evidence, not the measurement. + +## Technical design + +### Settings and persistence + +Add a display-only Boolean: + +```swift +var mergedOverviewUsesCompactLayout: Bool +``` + +Persist it under a new stable `UserDefaults` key named `mergedOverviewUsesCompactLayout`. A missing key resolves to +`false`. + +Plumbing: + +- `SettingsStoreState.swift`: add the field near the merged-menu settings. +- `SettingsStore.swift`: load the default and pass it into `SettingsDefaultsState`. +- `SettingsStore+Defaults.swift`: add the synchronous state/UserDefaults accessor. +- `SettingsStore+MenuObservation.swift`: include it in `menuObservationToken`. +- `PreferencesMenuPane.swift`: add the toggle at the start of the Content section. + +This preference must not increment `backgroundWorkSettingsRevision`, enter `CodexBarConfig`, change widget state, or +trigger provider fetching. + +### Presentation seam + +Keep `OverviewMenuCardRowView` as the outer row type so its callers and menu-item wiring do not fork. Give it an +explicit module-internal presentation style: + +```swift +enum OverviewMenuRowStyle { + case detailed + case compact +} +``` + +`addOverviewRows` resolves the style from `mergedOverviewUsesCompactLayout` and passes it to the row. Detailed uses the +existing view composition unchanged. Compact uses a dedicated, small SwiftUI subview and the existing +`UsageProgressBar`. Resolve `storageFootprintText(for:)` only for Detailed; Compact omits the inline value and does not +need to read it. Continue constructing `makeOverviewRowSubmenu` in both styles because its storage fallback is +independent of the inline storage string. + +Add a pure module-internal projection for compact metric lanes. It should make inclusion, order, labels, values, and +full accessibility source strings testable without constructing an `NSStatusBar` or live `NSMenu`. + +Do not add a second provider model. The compact projection consumes `UsageMenuCardView.Model` and contains only the +fields necessary to render metric lanes or the deterministic no-bar fallback. It must consume the finished model after +`hidePersonalInfo` redaction rather than rebuilding any string from raw snapshots. + +Keep responsibilities explicit: + +- `OverviewMenuCardRowView` remains the outer style switch. +- Its Compact branch owns the fallback model, initial `layoutModel`, refresh-monitor resolution, and shared metric/bar + columns. +- A projection-only `CompactOverviewRowContent` leaf receives only a compact projection and + `CompactOverviewColumnLayout`; it has no refresh monitor, raw model, storage, or rich-card fields. + +The projection also exposes an ordered **drawable-lane layout signature** derived from each included metric's +`Metric.id`, title, and percent style. Encode both ID and title through +`UsageMenuCardView.Model.heightFingerprintField(_:_:)`; the signature must never contain either raw string. It changes +when a lane is added, removed, reordered, replaced by a different ID, changes title or percent style, or changes +between bar and text-status presentation. Use it for compact compatibility and height-cache assertions; raw metric +count is not sufficient. + +A no-bar projection exposes a separate **fallback layout signature**: + +- `loading` includes the fallback kind; +- `status` includes the kind, selected `Metric.id`, and title shape; and +- `generic` includes the fallback kind. + +Encode the selected status ID and title through `UsageMenuCardView.Model.heightFingerprintField(_:_:)`, which hashes +the value and records only its geometry-relevant shape rather than storing raw potentially personal text in a cache key. +Localization and fallback text content remain covered by the resolved model's existing height fingerprint, but are not +part of this layout signature: all fallback text is fixed to one `.body` line, and status text spans fixed columns, +so changing only that text cannot change measured height or shared-column allocation. + +Implement the width rule through a module-internal `CompactOverviewColumnLayout`, computed once in `addOverviewRows` +from every monitor-resolved compact projection remaining after the existing error-only filter. Excluded providers must +not influence geometry. The layout contains the shared metric and bar widths plus spacing and chevron-gutter widths and +a stable signature. Provider headers span the content width and are not allocator inputs. + +The shared-column signature contains only resolved numeric geometry, resolved text scale/font identity, layout +direction, and menu width. Metric titles are transient allocator inputs and must not be copied into that signature; +provider names and fallback copy are not allocator inputs at all. + +The allocator receives the resolved menu width and the maximum ideal metric-label width across the whole rendered set. +Inputs include every drawable metric title and the selected title of each status fallback; loading and generic +fallbacks add no metric-title input. It clamps that maximum to 112 points, reserves the header-only chevron gutter and 12-point +metric-to-bar gap, and gives all remaining width to the bar subject to its 146-point floor. The allocator requires a +menu width of at least 310 points. Do not derive widths independently inside each hosted row: SwiftUI alignment guides +cannot cross the separate `NSMenuItem` hosts. + +Obtain ideal widths before host construction through one injected, module-internal text-width measurer configured with +the exact compact metric font. Production may use the corresponding AppKit preferred font; pure allocator tests use a +deterministic stub. Do not create temporary SwiftUI hosts or query private subview geometry merely to measure strings. + +Pass the same resolved `CompactOverviewColumnLayout` to every compact row. A DEBUG-only geometry probe may expose +resolved frames for the hosted-view integration test; do not inspect private SwiftUI hierarchy. The probe must prove +that metric and bar columns align across at least two separate hosted rows, that provider headers occupy their own row, +and that neither a submenu nor a no-submenu row overlaps the reserved chevron gutter. + +### Live refresh + +The compact branch must mirror Detailed Overview's `usesLiveSubtitle` gate, resolve one complete live model through +`MenuCardRefreshMonitor`, and derive the provider name, metrics, bar values, markers, tint, fallback, and accessibility +strings from that same result: + +```swift +let liveModel = model.usesLiveSubtitle + ? refreshMonitor?.model(for: model.provider, fallback: model) ?? model + : model +``` + +Do not resolve a live subtitle separately or mix live and fallback metric state. The earlier Lite-row experiment +demonstrated that doing so can show a refreshed state beside stale bars. + +At menu construction, `addOverviewRows` uses the same gate to obtain one `layoutModel` for each provider before it +builds compact projections, the shared column layout, and height fingerprints. The compact view receives the fallback +model, that initial `layoutModel`, and the shared columns. This mirrors the existing full-card model/layout-model seam: +compatible value changes can update in place, while the initial rendered or frozen shape controls measurement. + +Menu construction and SwiftUI rendering are separate resolution transactions. Construction resolves at most once per +gated provider for measurement and fingerprints. Each later Compact body evaluation resolves at most once, immediately +projects that one result, and passes the projection to `CompactOverviewRowContent`. The first render may therefore make +a second resolver call after construction; that is intentional and keeps Observation dependencies current. Tests +assert one call within each transaction, not one call across the entire menu-build-plus-render lifecycle. + +Existing compatible-layout rules remain authoritative. A structural metric-shape change may require the existing menu +rebuild path; Compact Overview should not weaken those guards. Compatible tracked updates cannot change metric ID, +title, percent style, or bar-versus-status shape. Any such change is rejected for in-place display and the subsequent +structural rebuild recomputes projections and shared columns for the entire rendered set. Provider-name or localization +changes likewise invalidate rendered content; a localized metric-title change also recomputes the shared metric/bar +columns rather than changing one row's widths in place. + +Add a Compact-specific compatibility guard for the fallback layout signature. A change between loading, status, and +generic, or a change in the selected status metric ID/title, is structurally incompatible and forces the same +whole-menu rebuild so shared columns are recomputed. A trimmed status value may update in place when the selected +status metric and fallback kind stay the same; the fixed one-line span makes that a content-only change. + +Keep model resolution separate from the pure projection. A focused resolver test should inject a monitor/resolver that +returns distinguishable values on successive calls, then prove one compact projection/update transaction resolves +once and produces one coherent row. Do not assert how often SwiftUI evaluates `body`. + +Manual refresh deliberately inherits `MenuCardRefreshMonitor.model(for:fallback:)` freezing. While a provider has +active manual-refresh work, Compact keeps rendering the compatible frozen pre-refresh bars; it does not show the +subtitle monitor's synthetic `Refreshing…` text because Compact has no subtitle. Compute the initial lane signature, +no-bar fallback, shared-column inputs, and height fingerprint from the monitor-resolved or frozen `layoutModel`, not +from the fallback model. The metric-subset compatibility path can intentionally return a frozen model with more lanes +than the rebuilding fallback, and its measured height must win. + +Every monitor result used here must come from the same finished `menuCardModel(for:)` path as the fallback and therefore +already include `hidePersonalInfo` redaction. Compact must not install a raw-snapshot resolver. + +### Open-menu rebuilding + +Adding the setting to `menuObservationToken` invalidates cached menus, but it is not sufficient for a menu that is +already open. + +Track the last observed compact-layout value beside `lastMergeIcons`, `lastSwitcherShowsIcons`, and +`lastObservedUsageBarsShowUsed` in `StatusItemController`. A change must make +`shouldRefreshOpenMenusForProviderSwitcher()` return true so the open merged menu receives a safe structural rebuild. + +### Height cache + +Detailed and compact rows must not share a measured-height fingerprint. Use a style-specific section or include the +style as an explicit fingerprint field, for example: + +```swift +section: style == .compact ? "overviewCompact" : "overviewDetailed" +``` + +Renaming Detailed's current section from `"overview"` to `"overviewDetailed"` changes cache identity only; it is not a +visual or interaction change. Detailed mode keeps storage text in its fingerprint. Compact mode omits storage from both +its visible content and height fingerprint. This prevents a cached detailed height from leaving blank space around +compact content after the toggle changes. + +For Compact, use `UsageMenuCardView.Model.heightFingerprint` on the resolved `layoutModel`, which already includes +metric shape and localization. Detailed continues using its current model-plus-storage fingerprint under the renamed +section. The existing cache key already includes menu width and resolved text scale. Add the style, compact +drawable-lane or fallback-layout signature, and shared-column-layout signature as explicit Compact fingerprint inputs. +Although GPU Overview hosts are built fresh rather than recycled, explicit height-cache entries survive menu +invalidation and remain provider-scoped; a peer provider can therefore change shared columns without changing this +provider's base model. + +Test peer-selection column changes, bar ↔ status, one-lane, two-lane, frozen-subset layout, and +Detailed → Compact → Detailed keys. Re-entering Detailed may safely reuse its original detailed height. Keep the +existing model compatibility rule that treats `statusText` nilness as a structural difference. + +### Privacy and provider isolation + +Compact mode is more private by construction because it removes inline account and plan identity. `hidePersonalInfo` +still applies: compact projection starts from the already-redacted `UsageMenuCardView.Model`, so embedded emails in +metric titles or status fallback text remain protected even when the setting causes no obvious visual difference. No +provider may borrow identity, plan, tint, metrics, or fallback text from a different provider. + +No new logging or telemetry is needed. + +## Edge cases + +| Case | Required behavior | +| --- | --- | +| More than six providers enabled | Keep the existing configured Overview subset and six-provider cap | +| Provider has one drawable metric | Show one provider header followed by one metric lane | +| Provider has several drawable metrics | Show one provider header followed by every drawable lane in model order | +| Some metrics have `statusText` instead of a bar | Omit those metrics while rendering every drawable metric | +| Provider is loading and has no drawable metrics | Show the provider header followed by the localized generic loading fallback | +| Provider has only text-status metrics | Show the provider header followed by the first non-empty status metric in model order | +| Provider has dashboard, notes, credits, cost, or no data but no drawable/status metric | Show the provider header followed by `overview_compact_no_bars` | +| Provider is error-only at build time | Apply the existing exclusion before the compact projection | +| Live values change without metric-shape change | Update all compact bar data from one monitor-resolved model | +| Live metric shape changes | Follow existing compatible-layout/rebuild behavior | +| Manual refresh is active | Keep compatible frozen bars; do not add a compact `Refreshing…` subtitle | +| Long or right-to-left localized names | Routine truncation is allowed at 310 points; preserve the shared metric/bar columns, 146-point bar, and full accessibility text | +| Used/remaining preference changes | Update bar fill and preserve full `percentStyle` value and used/remaining semantics in accessibility; do not add visible numeric text | +| Pace or marker settings change | Preserve existing bar rendering and menu invalidation behavior | +| Storage footprints enabled | Omit inline storage; retain the existing storage submenu fallback | +| A peer provider or Overview selection changes | Recompute one shared column layout and include its signature in compact height keys | +| Compact toggled while menu is open | Structurally rebuild the open Overview and use the correct cached height | +| Merge Icons disabled | Disable the control but retain its stored value | +| Accessibility or unusual metric count causes overflow | Preserve all bars and allow native menu scrolling | +| Refresh completes during a style rebuild | Main-actor rebuild rules keep updates on the current host and cache key | + +## Files expected to change + +Keep the implementation small and localized: + +- `Sources/CodexBar/SettingsStoreState.swift` +- `Sources/CodexBar/SettingsStore.swift` +- `Sources/CodexBar/SettingsStore+Defaults.swift` +- `Sources/CodexBar/SettingsStore+MenuObservation.swift` +- `Sources/CodexBar/PreferencesMenuBarPane.swift` to remove the toggle from Combined Icon +- `Sources/CodexBar/PreferencesMenuPane.swift` to add the toggle to Content +- `Sources/CodexBar/StatusItemController.swift` +- `Sources/CodexBar/StatusItemController+Menu.swift` +- `Sources/CodexBar/StatusItemController+MenuWidthCache.swift` to update the Compact width contribution if required +- `Sources/CodexBar/StatusItemController+MenuTypes.swift` +- an optional focused compact projection/layout file under `Sources/CodexBar` +- `Sources/CodexBar/Resources/*.lproj/Localizable.strings` +- focused tests under `Tests/CodexBarTests` +- `docs/ui.md` with the revised behavior + +Do not edit `CHANGELOG.md` as part of feature implementation. + +## Test plan + +### Pure compact projection + +Add `Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift` and cover: + +- every drawable metric is retained in model order; +- text-only metrics are omitted; +- loading, first-status-metric, and generic no-bars fallback precedence is deterministic; +- status-only Mistral/DeepSeek-style, all-unlimited MiniMax-style, and dashboard-only OpenAI-style fixtures produce an + informative single-line fallback rather than a bare name; +- whitespace-only status text is skipped, status fallback uses separate metric/status columns without hard-coded + punctuation, and loading is selected exactly by `subtitleStyle == .loading`; +- percent value/style, tint, pace, quota markers, and workday markers pass through unchanged; +- a source model populated with sentinel account, plan, subtitle, reset, detail, dashboard, notes, cost, and credit + strings, plus a detailed-row-only storage sentinel, produces compact projection/rendered accessibility output + containing none of those sentinels except an explicitly selected, already-redacted status fallback; +- no visible numeric percentage is projected or rendered while the bar retains the complete percentage and + used/remaining accessibility meaning; and +- full provider and metric strings survive projection unchanged. + +Cover fallback, 1-, 2-, 3-, 6-, and 12-lane projections. Assert there are no reserved blank lanes, no two-lane cap, +and no provider-name repetition; every drawable metric must survive in model order. + +Assert drawable-lane signature changes independently for ID-only replacement, reordering, title change, +`percentStyle` change, insertion/removal, and bar ↔ status change. Also assert each no-bar fallback kind includes the +specified layout fields without embedding raw IDs, provider names, metric titles, localized copy, or status values in +the key. Assert the shared-column signature likewise contains geometry only. Verify a status-value-only change keeps +the fallback layout signature stable while updating visible and accessible content. + +Make `CompactOverviewRowContent` accept only the compact projection and shared columns; absence of refresh, model, +storage, and unrelated rich-card fields from that leaf type is a compile-time and review invariant. Test live resolution +separately with an injected spy resolver. Assert that `usesLiveSubtitle == false` performs no resolution. For a live +model, return different sentinel values on successive calls, then assert separately that construction and one +projection/update transaction each perform at most one resolution and that each transaction produces one coherent +provider/tint/metric result. + +Add a manual-refresh fixture in which the monitor's compatible frozen model has more lanes than the rebuilding fallback; +measurement and fingerprinting must use the frozen lane shape, frozen values remain visibly projected, and Compact +contains no synthetic `Refreshing…` text. Run the live resolver with `hidePersonalInfo` enabled and assert the monitor +projection contains only already-redacted titles and fallback strings. + +Table-test the pure column allocator at the 310-point production minimum and at wider widths with short, long, German, +Polish, Persian or Arabic right-to-left, and Traditional Chinese ideal widths. Reject an input below 310. At 310 and +default text size, assert the exact 40-point outer padding, 112-point metric cap, 12-point metric-to-bar gap, 12-point +header chevron gutter, and 146-point bar floor. Assert provider-title widths do not affect the shared layout. At wider widths, +assert all surplus goes to the shared bar. At a larger text scale, assert width/text-scale cache keys differ while the +112-point metric cap and 146-point bar floor remain intact. + +Changing a peer provider must change the shared layout only when its metric titles change a capped selected-set maximum +or another resolved column width. Provider-name-only changes must not change the shared layout. Cover uncapped metric +width change, above-cap no-op, provider removal, and the error-only filter so an excluded provider never influences +columns. + +Use the DEBUG geometry probe against at least two separately hosted compact rows to prove each provider header occupies +its own line and their metric and bar frames align. Cover logical leading/trailing behavior, submenu and no-submenu +rows, header-aligned chevrons, and assert no content enters the chevron gutter. Separately assert visual labels stay +single-line, no visible numeric percentage view exists, drawable lanes are non-actionable accessibility children in +model order without duplicate visual text nodes, and a no-bar row exposes the specified parent label plus +fallback-child label/value fields. The accessibility representation must retain full provider and metric strings, +used/remaining semantics, percentage, fallback, and existing marker semantics without a synthesized English-order +phrase. Exercise the hierarchy under an Arabic or Persian locale. Keep pixel screenshots out of CI. + +### Settings and observation + +Extend `SettingsStoreTests.swift` to prove: + +- the default is detailed (`false`); +- compact mode round-trips through a second `SettingsStore` using the same suite; +- changing it fires the menu observation callback; and +- changing it leaves background-work, provider-fetch scheduling, config, cost/widget display, and provider-detail + revisions unchanged. + +Extend localization-catalog coverage to require `overview_compact_title`, `overview_compact_subtitle`, and +`overview_compact_no_bars` in all 23 complete catalogs with non-empty values. PR review must confirm the 22 non-English +catalog values are translations rather than copied English placeholders. + +Extend `PreferencesPaneSmokeTests.swift` to construct the Menu pane with both values. Cover the toggle binding, its +placement in the Content section rather than the Menu Bar pane, disabled state while Merge Icons is off, and value +retention across Merge Icons off → on. Use runtime visual proof for exact row placement rather than brittle SwiftUI +tree introspection. + +### Menu integration + +Cover both styles while preserving existing assertions: + +- same row count, provider order, and `overviewRow-` identities; +- same click and keyboard actions; +- same detail submenus and storage fallback; +- same GPU-selection host and scroll targeting; +- Compact passes `containsInteractiveControls: false`, while Detailed retains its existing expression; +- Compact skips `storageFootprintText(for:)` without losing the independently constructed storage submenu fallback; +- a programmatic setting change while the menu is tracking replaces detailed content with compact content in both + directions; +- a refresh delivered around that rebuild updates only the current host/model/cache key; +- detailed and compact height fingerprints cannot collide, while peer metric/bar geometry, fallback, bar ↔ status, frozen-subset, + and one- versus two-lane compact layouts also differ; and +- a fallback-kind or selected-status-metric change causes a whole-menu rebuild, while a value-only change for the same + selected status metric updates in place; and +- a representative compact row measures shorter than the unchanged detailed branch for the same fixture. + +Also cover zero selected providers, more than six enabled providers, mixed drawable/text-only metrics, bar ↔ status +shape changes, and an overflow fixture that preserves every lane. Preserve existing viewport behavior; this feature +does not add a new pixel- or provider-anchor policy for a setting normally changed while the menu is closed. + +Prefer pure projection and state seams. Keep AppKit coverage focused on the wiring that cannot be proven otherwise. + +### Validation commands + +During implementation, run the fastest focused checks first: + +```bash +swift test --filter OverviewMenuCardRowViewTests +swift test --filter SettingsStoreTests +swift test --filter PreferencesPaneSmokeTests +swift test --filter CompactOverviewMenuIntegrationTests +swift test --filter StatusMenuOverviewScrollTests +swift test --filter "StatusMenuTests.*overview" +make check +``` + +Name the new integration suite `CompactOverviewMenuIntegrationTests`. Validation notes must record the tests selected +and executed so a filter that accidentally matches zero tests cannot pass unnoticed. + +Before submitting a PR, run the repository's full `make test` suite. The focused tests must use synthetic snapshots, +stub stores, and `KeychainNoUIQuery`-safe paths; do not run live provider or browser-cookie probes. + +### Runtime proof + +Because the product goal is spatial and an `NSMenuTrackingSession` cannot be fully proven by unit tests, validate a +freshly built bundle in the project's supported macOS UI environment. Use an existing local synthetic/debug injection +if one is available. If not, keep the fixture in a test harness or DEBUG-only launch path that cannot ship as a +production provider or fetch path and that disables real probes. + +Add or reuse a DEBUG/test-only numeric measurement seam that reports: + +- resolved menu width and display visible-frame height; +- full menu intrinsic content height; +- attached clip-view height; +- vertical scroll range, calculated as `max(0, documentHeight - viewportHeight)`; +- ordered item identifiers and measured heights; and +- the ordered provider/lane IDs present in the menu. + +The canonical fit fixture passes when resolved width is exactly 310 points, each provider header occupies its own line, +one-lane items are shorter than two-lane items, every one-lane item is at most 54 points tall, each additional lane adds +at most 20 points, the six compact items total at most 432 points, intrinsic height is at most 680 points, vertical +scroll range is at most 24 points, and all twelve expected lane IDs are present. The overflow fixture passes when every +expected lane ID is present and vertical scroll range exceeds the canonical allowance. + +The canonical harness must configure the production width inputs and then observe the attached menu's resolved width; +it must not force a DEBUG-only 310-point frame after layout. If AppKit or a standard item widens the canonical fixture, +the exact-width assertion fails and the fixture, width calculation, or 310-point contract must be corrected. + +1. Configure the exact canonical fixture from the Fit contract using local synthetic data. +2. Record the actual OS build, resolved width, visible-frame height, per-item heights, and all other measured fit values + required by the contract. +3. Capture Detailed and Compact Overview at the same display size and text settings. +4. Prove Compact shows all twelve bars plus the six dedicated provider headers, switcher, and standard footer, stays + within 680 points, and has at most 24 points of vertical scroll range. +5. Use an in-process test hook to change the preference while the menu is tracking and verify both style directions, + current-host refresh, and correct row heights. +6. Run a response-sized overflow fixture with enough additional lanes to force scrolling; prove every lane remains and + native scrolling works. +7. Click and keyboard-activate rows, open representative usage/cost/storage submenus, and exercise trackpad and wheel + navigation. +8. Repeat in light and dark appearance and with personal-info hiding enabled. +9. Capture redacted screenshots or a short recording for the PR. + +Use `./Scripts/compile_and_run.sh` only for this final bundle-level UI validation, after focused tests and +`make check` pass. + +## Implementation plan + +1. Add the default-off persisted Boolean, menu observation, and Menu-pane toggle. +2. Add the pure compact-row projection and focused tests for inclusion, order, no-bar fallback, semantics, and full + source strings. +3. Add the shared metric/bar column allocator, fixed chevron gutter, minimum-width budget tests, and hosted geometry + probe. +4. Add the compact SwiftUI row using the existing progress renderer and one gated monitor-resolved live model. +5. Pass the selected row style, resolved layout models, and shared columns through `addOverviewRows`; preserve row + identity, set Compact's embedded-control flag false, and skip only Compact's inline storage lookup. +6. Add open-menu structural refresh tracking and style-, rendered-shape-, and shared-column-specific height + fingerprints. +7. Extend Overview interaction, frozen-refresh, submenu, settings, all-catalog localization, accessibility, and overflow + tests. +8. Run focused checks, `make check`, then the freshly built bundle UI proof with per-item measurements. +9. Update `docs/ui.md`; run `make test` immediately before PR submission. + +## Acceptance criteria + +The feature is ready when: + +- Detailed Overview remains the default, uses the existing detailed view composition, and has no intentional visual or + interaction change. +- The setting persists, appears in Preferences → Menu → Content rather than the Menu Bar pane, is disabled without + clearing while Merge Icons is off, and is contextual to the merged Overview UI. +- Changing the setting has no provider-fetch, background-work, config, widget, account, or provider-detail side + effects. +- Compact Overview shows every provider remaining after the existing error-only filter and every drawable metric bar + for those providers in model order. +- Compact mode contains none of the excluded rich-card content. +- Loading, status-only, and other no-bar providers use the specified visible, accessible fallback; existing error-only + filtering remains unchanged. +- In the canonical English/default-text-size fixture at a resolved width of exactly 310 points, every provider has one + dedicated header row and every item shares one metric/bar column layout. Labels truncate within their caps, bars + remain at least 146 points wide and eight points tall, no numeric percentage is visible, the chevron gutter remains + unobstructed, and accessibility keeps the full strings and percentage/marker semantics. Larger resolved text may + increase item height but keeps the fixed metric cap and bar floor. +- The canonical mixed-cardinality `1, 1, 2, 2, 3, 3` six-provider/twelve-bar fixture has one-lane items no taller than + 54 points, additional lanes adding at most 20 points each, compact items totaling at most 432 points, a complete menu + within 680 points, at most 24 points of vertical scroll range, all twelve expected lane IDs, and redacted visual proof + plus recorded per-item measurements. +- Every drawable bar is retained for 0/1/2/3+ and response-sized metric sets. When completeness and fit conflict, + Compact uses native vertical scrolling rather than truncation, collapsing, ranking, or a per-provider bar cap. +- Live refresh mirrors the existing gate, never mixes stale and current row state, and preserves compatible frozen bars + during manual refresh. +- A setting change during menu tracking rebuilds in both directions, survives a concurrent refresh, and cannot reuse a + detailed-row, wrong-lane-count, wrong-fallback, frozen-shape, or peer-metric-geometry height. +- Existing click, keyboard, submenu, GPU highlight, scroll, viewport, provider-order, and selection behavior remains + green; Compact alone disables embedded-control hit testing. +- `overview_compact_title`, `overview_compact_subtitle`, and `overview_compact_no_bars` are present in all 23 complete + locale catalogs, with reviewed non-English translations rather than English placeholders. +- Focused tests, `make check`, and the pre-PR full suite pass without Keychain prompts. + +## Approved owner decisions + +| Decision | Recommendation | Alternative and cost | +| --- | --- | --- | +| Default layout | Detailed | Defaulting compact changes every existing installation's menu | +| Setting shape | Boolean toggle | An enum/picker adds ceremony without a third approved layout | +| Provider scope | Existing selected six | “Every enabled provider” requires a separate selection-model redesign | +| Visible bar text | Short metric label only; the bar retains percentage and used/remaining semantics in accessibility | A visible percentage reduces bar width and made the first implementation feel too small | +| Metric completeness | Keep every drawable metric | Capping bars contradicts the request and hides quota windows | +| Overflow policy | Allow native scrolling in exceptional cases | Dropping data or shrinking below platform norms is misleading | +| Provider presentation | One dedicated header row inside each actionable provider item, with metric lanes below | A provider column competes with metric and bar width and made the item feel cramped | +| Cross-row geometry | One shared selected-set metric/bar layout with fixed cap and chevron gutter | Per-host ideal sizing cannot align independent `NSMenuItem` roots | +| Non-bar providers | One muted status/loading/no-bars fallback below the provider header | A bare provider name reads as an unfinished row; hiding it changes the configured subset | + +These approved decisions make this document the bounded implementation contract. diff --git a/docs/ui.md b/docs/ui.md index e57e185a71..de09061df9 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -14,6 +14,17 @@ 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. +- Compact Overview is an opt-in presentation for the merged menu. Each actionable provider item has a dedicated + provider-name header row, followed by every drawable usage bar in model order; one, two, or many bars produce matching + item heights. Providers without a drawable bar show a single loading, status, or “No usage bars” fallback beneath the + header instead of a blank item. +- Compact items share one metric-label/bar layout across the selected providers, using body-sized metric labels and + eight-point progress bars. Provider headers sit outside that grid and span the content width. Every item reserves the + same trailing chevron gutter in its header, including items without a submenu; metric lanes use the full width below + it. Visible numeric percentages are omitted; each progress bar retains its percentage and used-versus-left + accessibility semantics. Long labels truncate visually + while preserving their full accessibility text. Compact mode keeps the same provider actions, detail submenus, + separators, refresh behavior, and native menu scrolling as Detailed Overview. - 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 +97,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: “Compact Overview” chooses the provider-header-and-bars presentation. It is disabled while Merge + Icons is off, retains its stored value, and defaults off so existing Detailed Overview behavior is unchanged. - 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. From 2e8020137b7ea37c8349a9b5cbe0e2f485d308b6 Mon Sep 17 00:00:00 2001 From: Trim Date: Fri, 31 Jul 2026 21:20:48 -0400 Subject: [PATCH 2/4] Refine Overview layout modes --- Sources/CodexBar/CompactOverviewRow.swift | 441 ++++++---- Sources/CodexBar/MenuCardView.swift | 22 +- Sources/CodexBar/PreferencesMenuPane.swift | 18 +- Sources/CodexBar/PreferencesMenuPicker.swift | 1 + .../Resources/ar.lproj/Localizable.strings | 6 + .../Resources/ca.lproj/Localizable.strings | 6 + .../Resources/de.lproj/Localizable.strings | 6 + .../Resources/en.lproj/Localizable.strings | 6 + .../Resources/es.lproj/Localizable.strings | 6 + .../Resources/fa.lproj/Localizable.strings | 6 + .../Resources/fr.lproj/Localizable.strings | 6 + .../Resources/gl.lproj/Localizable.strings | 6 + .../Resources/id.lproj/Localizable.strings | 6 + .../Resources/it.lproj/Localizable.strings | 6 + .../Resources/ja.lproj/Localizable.strings | 6 + .../Resources/ko.lproj/Localizable.strings | 6 + .../Resources/nl.lproj/Localizable.strings | 6 + .../Resources/pl.lproj/Localizable.strings | 6 + .../Resources/pt-BR.lproj/Localizable.strings | 6 + .../Resources/ru.lproj/Localizable.strings | 6 + .../Resources/sv.lproj/Localizable.strings | 6 + .../Resources/th.lproj/Localizable.strings | 6 + .../Resources/tr.lproj/Localizable.strings | 6 + .../Resources/uk.lproj/Localizable.strings | 6 + .../Resources/vi.lproj/Localizable.strings | 6 + .../zh-Hans.lproj/Localizable.strings | 6 + .../zh-Hant.lproj/Localizable.strings | 6 + Sources/CodexBar/SettingsStore+Defaults.swift | 11 +- .../SettingsStore+MenuObservation.swift | 2 +- .../SettingsStore+MenuPreferences.swift | 24 + Sources/CodexBar/SettingsStore.swift | 16 +- Sources/CodexBar/SettingsStoreState.swift | 2 +- ...StatusItemController+CompactOverview.swift | 70 +- .../CodexBar/StatusItemController+Menu.swift | 2 +- .../StatusItemController+MenuCardItems.swift | 19 +- ...tatusItemController+MenuPresentation.swift | 4 + ...ItemController+MenuRefreshScheduling.swift | 14 +- .../StatusItemController+MenuTypes.swift | 42 +- .../StatusItemController+MenuWidthCache.swift | 6 +- Sources/CodexBar/StatusItemController.swift | 4 +- Sources/CodexBar/UsageMenuCardLayout.swift | 1 + Sources/CodexBar/UsageProgressBar.swift | 3 +- .../CompactOverviewMenuIntegrationTests.swift | 263 +++++- .../CompactOverviewProjectionTests.swift | 274 +++--- .../CompactOverviewSettingsTests.swift | 115 ++- .../LocalizationLanguageCatalogTests.swift | 8 +- .../PreferencesPaneSmokeTests.swift | 24 +- .../UsageMenuCardLayoutTests.swift | 3 +- ...2026-07-30-compact-overview-menu-design.md | 813 ------------------ docs/ui.md | 25 +- 50 files changed, 1123 insertions(+), 1242 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md diff --git a/Sources/CodexBar/CompactOverviewRow.swift b/Sources/CodexBar/CompactOverviewRow.swift index b34ed3bb03..404df08534 100644 --- a/Sources/CodexBar/CompactOverviewRow.swift +++ b/Sources/CodexBar/CompactOverviewRow.swift @@ -1,4 +1,3 @@ -import AppKit import CodexBarCore import SwiftUI @@ -80,7 +79,7 @@ struct CompactOverviewProjection { guard metric.statusText == nil else { return nil } return Lane( id: metric.id, - title: metric.title, + title: UsageMenuCardView.popupMetricTitle(provider: model.provider, metric: metric), percent: metric.percent, percentStyle: metric.percentStyle, tint: model.progressColor, @@ -101,13 +100,6 @@ struct CompactOverviewProjection { self.layoutSignature = Self.makeLayoutSignature(lanes: self.lanes, fallback: self.fallback) } - var metricTitlesForColumnMeasurement: [String] { - if self.lanes.isEmpty { - return self.fallback?.metricTitle.map { [$0] } ?? [] - } - return self.lanes.map(\.title) - } - private static func makeFallback( model: UsageMenuCardView.Model, loadingText: String, @@ -122,7 +114,10 @@ struct CompactOverviewProjection { .trimmingCharacters(in: .whitespacesAndNewlines), !statusText.isEmpty else { continue } - return .status(metricID: metric.id, title: metric.title, text: statusText) + return .status( + metricID: metric.id, + title: UsageMenuCardView.popupMetricTitle(provider: model.provider, metric: metric), + text: statusText) } return .generic(text: noBarsText) } @@ -141,168 +136,103 @@ struct CompactOverviewProjection { 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 CompactOverviewTextWidthMeasurer { - enum Role: Hashable { - case provider - case metric - } - - let fontSignature: String - private let measure: (String, Role) -> CGFloat - - init(fontSignature: String, measure: @escaping (String, Role) -> CGFloat) { - self.fontSignature = fontSignature - self.measure = measure - } - - func width(of text: String, role: Role) -> CGFloat { - max(0, self.measure(text, role)) - } - - static func appKit() -> Self { - let bodyFont = NSFont.preferredFont(forTextStyle: .body) - let fonts: [Role: NSFont] = [ - .provider: NSFont.systemFont(ofSize: bodyFont.pointSize, weight: .semibold), - .metric: bodyFont, - ] - let signature = [Role.provider, .metric] - .compactMap { role -> String? in - guard let font = fonts[role] else { return nil } - return "\(font.fontName):\(font.pointSize)" - } - .joined(separator: "|") - return Self(fontSignature: signature) { text, role in - guard let font = fonts[role] else { return 0 } - return ceil((text as NSString).size(withAttributes: [.font: font]).width) - } +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 = 18 + static let barsOnlySectionOuterSpacing: CGFloat = UsageMenuCardLayout.metricSpacing + static var barsOnlyVerticalPadding: CGFloat { + (self.barsOnlyInterProviderSpacing - MenuCardItemSizing.measuredHeightPadding) / 2 } -} - -enum CompactOverviewColumnLayoutError: Error, Equatable { - case menuWidthBelowMinimum(CGFloat) -} -struct CompactOverviewColumnLayout { - struct AllocationInput { - let menuWidth: CGFloat - let idealMetricWidth: CGFloat - let fontSignature: String - let layoutDirection: LayoutDirection + static var barsOnlySectionSpacerHeight: CGFloat { + self.barsOnlySectionOuterSpacing + - MenuCardItemSizing.measuredHeightPadding / 2 + - self.barsOnlyVerticalPadding } - static let minimumMenuWidth: CGFloat = 310 - static let horizontalPadding: CGFloat = UsageMenuCardLayout.horizontalPadding - static let columnSpacing: CGFloat = 12 - static let metricWidthCap: CGFloat = 112 - static let minimumBarWidth: CGFloat = 146 - static let chevronGutterWidth: CGFloat = 12 - static let rowVerticalPadding: CGFloat = 4 - static let providerContentSpacing: CGFloat = 4 - static let laneSpacing: CGFloat = 3 - static let barHeight: CGFloat = 8 - let menuWidth: CGFloat - let metricWidth: CGFloat - let barWidth: CGFloat let layoutDirection: LayoutDirection let signature: String var contentWidth: CGFloat { - self.metricWidth - + Self.columnSpacing - + self.barWidth + self.menuWidth - Self.horizontalPadding * 2 } - var occupiedWidth: CGFloat { - Self.horizontalPadding * 2 - + self.contentWidth + var labeledBarWidth: CGFloat { + self.contentWidth } var providerHeaderWidth: CGFloat { self.contentWidth - Self.chevronGutterWidth } - static func resolve( - menuWidth: CGFloat, - projections: [CompactOverviewProjection], - layoutDirection: LayoutDirection, - textWidthMeasurer: CompactOverviewTextWidthMeasurer) throws -> Self - { - let idealMetricWidth = projections - .flatMap(\.metricTitlesForColumnMeasurement) - .map { textWidthMeasurer.width(of: $0, role: .metric) } - .max() ?? 0 + var providerBarsBarWidth: CGFloat { + self.contentWidth + } - return try Self.allocate(AllocationInput( - menuWidth: menuWidth, - idealMetricWidth: idealMetricWidth, - fontSignature: textWidthMeasurer.fontSignature, - layoutDirection: layoutDirection)) + var barsOnlyBarWidth: CGFloat { + self.contentWidth } static func resolveForMenu( menuWidth: CGFloat, - projections: [CompactOverviewProjection], - layoutDirection: LayoutDirection, - textWidthMeasurer: CompactOverviewTextWidthMeasurer) -> Self + layoutDirection: LayoutDirection) -> Self { assert( menuWidth >= self.minimumMenuWidth, "Compact Overview menu width must be at least \(self.minimumMenuWidth) points") - do { - return try self.resolve( - menuWidth: menuWidth, - projections: projections, - layoutDirection: layoutDirection, - textWidthMeasurer: textWidthMeasurer) - } catch { - preconditionFailure("Compact Overview failed to resolve a supported menu width: \(error)") - } - } - - static func allocate(_ input: AllocationInput) throws -> Self { - guard input.menuWidth >= self.minimumMenuWidth else { - throw CompactOverviewColumnLayoutError.menuWidthBelowMinimum(input.menuWidth) - } - - let idealMetricWidth = max(0, input.idealMetricWidth) - let metricWidth = min(idealMetricWidth, Self.metricWidthCap) - let fixedWidth = Self.horizontalPadding * 2 - + Self.columnSpacing - var barWidth = input.menuWidth - fixedWidth - metricWidth - - let widthExpansion = max(0, Self.minimumBarWidth - barWidth) - let resolvedMenuWidth = input.menuWidth + widthExpansion - barWidth += widthExpansion - - let direction = switch input.layoutDirection { + precondition(menuWidth >= self.minimumMenuWidth) + precondition(self.barsOnlyVerticalPadding >= 0) + precondition(self.barsOnlySectionSpacerHeight >= 0) + let direction = switch layoutDirection { case .leftToRight: "ltr" case .rightToLeft: "rtl" @unknown default: "unknown" } let signature = Self.signature(fields: [ - "menu=\(Self.geometryToken(resolvedMenuWidth))", - "metric=\(Self.geometryToken(metricWidth))", - "bar=\(Self.geometryToken(barWidth))", - "spacing=\(Self.geometryToken(Self.columnSpacing))", + "menu=\(Self.geometryToken(menuWidth))", "gutter=\(Self.geometryToken(Self.chevronGutterWidth))", "padding=\(Self.geometryToken(Self.horizontalPadding))", "barHeight=\(Self.geometryToken(Self.barHeight))", - "providerSpacing=\(Self.geometryToken(Self.providerContentSpacing))", - "laneSpacing=\(Self.geometryToken(Self.laneSpacing))", - "font=\(input.fontSignature)", + "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, - metricWidth: metricWidth, - barWidth: barWidth, - layoutDirection: input.layoutDirection, + menuWidth: menuWidth, + layoutDirection: layoutDirection, signature: signature) } @@ -315,69 +245,73 @@ struct CompactOverviewColumnLayout { } } -struct CompactOverviewRowContent: View { +struct CompactOverviewLabeledContent: View { let projection: CompactOverviewProjection - let columns: CompactOverviewColumnLayout - @Environment(\.menuItemHighlighted) private var isHighlighted + let layout: CompactOverviewLayout var body: some View { - VStack(alignment: .leading, spacing: CompactOverviewColumnLayout.providerContentSpacing) { - HStack(spacing: 0) { - Text(self.projection.providerName) - .font(.headline) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - .lineLimit(1) - .truncationMode(.tail) - .frame(width: self.columns.providerHeaderWidth, alignment: .leading) - .help(self.projection.providerName) - .accessibilityHidden(true) - - Color.clear - .frame(width: CompactOverviewColumnLayout.chevronGutterWidth, height: 0) - .accessibilityHidden(true) - } + VStack(alignment: .leading, spacing: 0) { + CompactOverviewProviderHeader(projection: self.projection, layout: self.layout) self.content } - .padding(.horizontal, CompactOverviewColumnLayout.horizontalPadding) - .padding(.vertical, CompactOverviewColumnLayout.rowVerticalPadding) - .frame(width: self.columns.menuWidth, alignment: .leading) + .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 { - CompactOverviewFallbackContent(fallback: fallback, columns: self.columns) + CompactOverviewLabeledFallback(fallback: fallback, layout: self.layout) } else { - VStack(alignment: .leading, spacing: CompactOverviewColumnLayout.laneSpacing) { + VStack(alignment: .leading, spacing: CompactOverviewLayout.labeledMetricSpacing) { ForEach(self.projection.lanes) { lane in - CompactOverviewMetricLane(lane: lane, columns: self.columns) + CompactOverviewLabeledMetric(lane: lane, layout: self.layout) } } + .padding(.horizontal, CompactOverviewLayout.horizontalPadding) + .padding(.top, CompactOverviewLayout.labeledUsageTopPadding) + .padding(.bottom, CompactOverviewLayout.labeledBottomPadding) .accessibilityElement(children: .contain) } } } -private struct CompactOverviewMetricLane: View { - let lane: CompactOverviewProjection.Lane - let columns: CompactOverviewColumnLayout - @Environment(\.menuItemHighlighted) private var isHighlighted +private struct CompactOverviewProviderHeader: View { + let projection: CompactOverviewProjection + let layout: CompactOverviewLayout var body: some View { - HStack(alignment: .center, spacing: 0) { - Text(self.lane.title) - .font(.body) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + HStack(spacing: 0) { + Text(self.projection.providerName) + .usageMenuCardProviderTitleStyle() .lineLimit(1) .truncationMode(.tail) - .frame(width: self.columns.metricWidth, alignment: .leading) - .help(self.lane.title) + .frame(width: self.layout.providerHeaderWidth, alignment: .leading) + .help(self.projection.providerName) .accessibilityHidden(true) Color.clear - .frame(width: CompactOverviewColumnLayout.columnSpacing, height: 0) + .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( @@ -388,48 +322,177 @@ private struct CompactOverviewMetricLane: View { paceOnTop: self.lane.paceOnTop, warningMarkerPercents: self.lane.warningMarkerPercents, workdayMarkerPercents: self.lane.workdayMarkerPercents, - height: CompactOverviewColumnLayout.barHeight) - .frame(width: self.columns.barWidth) + height: CompactOverviewLayout.barHeight) + .frame(width: self.layout.labeledBarWidth) } .accessibilityElement(children: .contain) .accessibilityLabel(self.lane.title) } } -private struct CompactOverviewFallbackContent: View { +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 columns: CompactOverviewColumnLayout + let layout: CompactOverviewLayout @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { - switch self.fallback { - case let .status(_, title, text): - HStack(spacing: 0) { - self.fallbackText(title, width: self.columns.metricWidth) - Color.clear - .frame(width: CompactOverviewColumnLayout.columnSpacing, height: 0) - .accessibilityHidden(true) - self.fallbackText( - text, - width: self.columns.barWidth) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(title) - .accessibilityValue(text) - case let .loading(text), let .generic(text): - self.fallbackText(text, width: self.columns.contentWidth) + 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(text) + .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, width: CGFloat) -> some View { + private func fallbackText(_ text: String, font: Font) -> some View { Text(text) - .font(.body) + .font(font) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) .truncationMode(.tail) - .frame(width: width, alignment: .leading) + .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.accessibilityLabel, + 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/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 2a60b5845e..6703c92b59 100644 --- a/Sources/CodexBar/PreferencesMenuPane.swift +++ b/Sources/CodexBar/PreferencesMenuPane.swift @@ -49,12 +49,18 @@ struct MenuPane: View { } Section { - Toggle(isOn: self.$settings.mergedOverviewUsesCompactLayout) { - SettingsRowLabel( - L("overview_compact_title"), - subtitle: L("overview_compact_subtitle")) - } - .disabled(!Self.compactOverviewAvailable(mergeIcons: self.settings.mergeIcons)) + 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) 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 f204fdc58e..313a159c30 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -622,6 +622,12 @@ "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_title" = "نظرة عامة مدمجة"; "overview_compact_subtitle" = "اعرض أسماء المزوّدين وأشرطة الاستخدام بتنسيق موفّر للمساحة."; "overview_compact_no_bars" = "لا توجد أشرطة استخدام"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c222e10406..6241e29b70 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -600,6 +600,12 @@ "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_title" = "Resum compacte"; "overview_compact_subtitle" = "Mostra els noms dels proveïdors i les barres d’ús en un disseny que estalvia espai."; "overview_compact_no_bars" = "No hi ha barres d’ús"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index ad3a54641d..9892b02ab5 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -614,6 +614,12 @@ "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_title" = "Kompakte Übersicht"; "overview_compact_subtitle" = "Zeigt Anbieternamen und Nutzungsbalken in einem platzsparenden Layout."; "overview_compact_no_bars" = "Keine Nutzungsbalken"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index dd8d817035..075729f403 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -599,6 +599,12 @@ "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_title" = "Compact Overview"; "overview_compact_subtitle" = "Show provider names and usage bars in a space-saving layout."; "overview_compact_no_bars" = "No usage bars"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 09f35fd867..374722458e 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -608,6 +608,12 @@ "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_title" = "Resumen compacto"; "overview_compact_subtitle" = "Muestra los nombres de los proveedores y las barras de uso en un diseño compacto."; "overview_compact_no_bars" = "No hay barras de uso"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index e0e38bbdbf..6dea57322b 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -622,6 +622,12 @@ "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_title" = "نمای کلی فشرده"; "overview_compact_subtitle" = "نام ارائه‌دهندگان و نوارهای مصرف را در چیدمانی کم‌جا نشان می‌دهد."; "overview_compact_no_bars" = "نوار مصرفی وجود ندارد"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 54438260fb..8f2633fbfd 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -616,6 +616,12 @@ "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_title" = "Vue d’ensemble compacte"; "overview_compact_subtitle" = "Affichez les noms des fournisseurs et les barres d’utilisation dans une disposition compacte."; "overview_compact_no_bars" = "Aucune barre d’utilisation"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 8f7a2a80a6..b8e5c2da66 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -595,6 +595,12 @@ "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_title" = "Resumo compacto"; "overview_compact_subtitle" = "Mostra os nomes dos provedores e as barras de uso nun deseño que aforra espazo."; "overview_compact_no_bars" = "Non hai barras de uso"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index eed6ce1d57..fa3e739b25 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -624,6 +624,12 @@ "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_title" = "Ikhtisar ringkas"; "overview_compact_subtitle" = "Tampilkan nama penyedia dan bilah penggunaan dalam tata letak hemat ruang."; "overview_compact_no_bars" = "Tidak ada bilah penggunaan"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index b72244659b..be9c4eec42 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -624,6 +624,12 @@ "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_title" = "Panoramica compatta"; "overview_compact_subtitle" = "Mostra i nomi dei provider e le barre di utilizzo in un layout salvaspazio."; "overview_compact_no_bars" = "Nessuna barra di utilizzo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 259c5c835f..cfe26657c3 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -613,6 +613,12 @@ "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_title" = "コンパクトな概要"; "overview_compact_subtitle" = "プロバイダ名と使用量バーを省スペースのレイアウトで表示します。"; "overview_compact_no_bars" = "使用量バーはありません"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index c5f24f2a62..1d2ba11a70 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -605,6 +605,12 @@ "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_title" = "간결한 개요"; "overview_compact_subtitle" = "공급자 이름과 사용량 막대를 공간 절약형 레이아웃으로 표시합니다."; "overview_compact_no_bars" = "사용량 막대 없음"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 413206dcae..507747da25 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -616,6 +616,12 @@ "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_title" = "Compact overzicht"; "overview_compact_subtitle" = "Toon providernamen en gebruiksbalken in een ruimtebesparende indeling."; "overview_compact_no_bars" = "Geen gebruiksbalken"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 43e9bb3a91..36d2bb887a 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -624,6 +624,12 @@ "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_title" = "Kompaktowy przegląd"; "overview_compact_subtitle" = "Pokazuj nazwy dostawców i paski użycia w układzie oszczędzającym miejsce."; "overview_compact_no_bars" = "Brak pasków użycia"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 4ec6a72b3d..ae8fbac489 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -613,6 +613,12 @@ "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_title" = "Visão geral compacta"; "overview_compact_subtitle" = "Mostre nomes de provedores e barras de uso em um layout que economiza espaço."; "overview_compact_no_bars" = "Sem barras de uso"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 5d5264f5dc..a2551bd50f 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -617,6 +617,12 @@ "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_title" = "Компактный обзор"; "overview_compact_subtitle" = "Показывать названия провайдеров и индикаторы использования в компактном виде."; "overview_compact_no_bars" = "Нет индикаторов использования"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 96d92e4ee3..25933415d3 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -615,6 +615,12 @@ "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_title" = "Kompakt översikt"; "overview_compact_subtitle" = "Visa leverantörsnamn och användningsstaplar i en utrymmessnål layout."; "overview_compact_no_bars" = "Inga användningsstaplar"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index b7a5288909..efe3edef8f 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -622,6 +622,12 @@ "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_title" = "ภาพรวมแบบกะทัดรัด"; "overview_compact_subtitle" = "แสดงชื่อผู้ให้บริการและแถบการใช้งานในรูปแบบที่ประหยัดพื้นที่"; "overview_compact_no_bars" = "ไม่มีแถบการใช้งาน"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index a355e60bb3..a1a8d04525 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -622,6 +622,12 @@ "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_title" = "Kompakt Genel Bakış"; "overview_compact_subtitle" = "Sağlayıcı adlarını ve kullanım çubuklarını yerden tasarruf eden bir düzende gösterin."; "overview_compact_no_bars" = "Kullanım çubuğu yok"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 608e3728a9..ad252338b5 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -616,6 +616,12 @@ "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_title" = "Компактний огляд"; "overview_compact_subtitle" = "Показувати назви постачальників і смуги використання в компактному компонуванні."; "overview_compact_no_bars" = "Немає смуг використання"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index f08adb93f0..d2b00cb8b2 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -612,6 +612,12 @@ "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_title" = "Tổng quan thu gọn"; "overview_compact_subtitle" = "Hiển thị tên nhà cung cấp và thanh mức sử dụng trong bố cục tiết kiệm không gian."; "overview_compact_no_bars" = "Không có thanh mức sử dụng"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index dbd5165b35..6aa60dc1b6 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -595,6 +595,12 @@ "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_title" = "紧凑概览"; "overview_compact_subtitle" = "以节省空间的布局显示提供商名称和使用量条。"; "overview_compact_no_bars" = "无使用量条"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 90cf9967d6..61e62d5b57 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -615,6 +615,12 @@ "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_title" = "精簡概覽"; "overview_compact_subtitle" = "以節省空間的版面顯示提供者名稱和用量列。"; "overview_compact_no_bars" = "沒有用量列"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 2666c6f534..ff2ba0ab59 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -748,11 +748,14 @@ extension SettingsStore { } } - var mergedOverviewUsesCompactLayout: Bool { - get { self.defaultsState.mergedOverviewUsesCompactLayout } + var mergedOverviewLayout: MergedOverviewLayout { + get { + MergedOverviewLayout(rawValue: self.defaultsState.mergedOverviewLayoutRaw) ?? .detailed + } set { - self.defaultsState.mergedOverviewUsesCompactLayout = newValue - self.userDefaults.set(newValue, forKey: "mergedOverviewUsesCompactLayout") + self.defaultsState.mergedOverviewLayoutRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "mergedOverviewLayout") + self.userDefaults.set(newValue.usesReducedContent, forKey: "mergedOverviewUsesCompactLayout") } } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f56f6e0dbe..dada7eb142 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -88,7 +88,7 @@ extension SettingsStore { _ = self.zoomMateCookieSource _ = self.ollamaCookieSource _ = self.mergeIcons - _ = self.mergedOverviewUsesCompactLayout + _ = 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 c82705d6c1..f273da4a53 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -524,8 +524,18 @@ extension SettingsStore { } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true - let mergedOverviewUsesCompactLayout = userDefaults.object( - forKey: "mergedOverviewUsesCompactLayout") as? Bool ?? false + 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 @@ -613,7 +623,7 @@ extension SettingsStore { providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, - mergedOverviewUsesCompactLayout: mergedOverviewUsesCompactLayout, + mergedOverviewLayoutRaw: mergedOverviewLayoutRaw, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index a997da60f4..5948df359d 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -64,7 +64,7 @@ struct SettingsDefaultsState { var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool - var mergedOverviewUsesCompactLayout: 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 index 7e349eb43e..aeae4079cb 100644 --- a/Sources/CodexBar/StatusItemController+CompactOverview.swift +++ b/Sources/CodexBar/StatusItemController+CompactOverview.swift @@ -4,6 +4,8 @@ import QuartzCore import SwiftUI extension StatusItemController { + static let overviewBarsOnlySpacerIdentifierPrefix = "overviewBarsOnlySpacer-" + @discardableResult func addOverviewRows( to menu: NSMenu, @@ -16,7 +18,8 @@ extension StatusItemController { let interactionMenu = captureMenu ?? menu let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( activeProviders: enabledProviders) - let compactRequested = self.settings.mergedOverviewUsesCompactLayout + let overviewLayout = self.settings.mergedOverviewLayout + let usesReducedContent = overviewLayout.usesReducedContent let rows: [( provider: UsageProvider, model: UsageMenuCardView.Model, @@ -25,7 +28,7 @@ extension StatusItemController { .compactMap { provider in guard let model = self.menuCardModel(for: provider) else { return nil } guard !model.isOverviewErrorOnly else { return nil } - let layoutModel = if compactRequested, model.usesLiveSubtitle { + let layoutModel = if usesReducedContent, model.usesLiveSubtitle { self.menuCardRefreshMonitor.model(for: provider, fallback: model) } else { model @@ -34,26 +37,27 @@ extension StatusItemController { provider: provider, model: model, layoutModel: layoutModel, - projection: compactRequested ? CompactOverviewProjection(model: layoutModel) : nil) + projection: usesReducedContent ? CompactOverviewProjection(model: layoutModel) : nil) } guard !rows.isEmpty else { return false } - let compactColumns: CompactOverviewColumnLayout? = if compactRequested { - CompactOverviewColumnLayout.resolveForMenu( + let compactLayout: CompactOverviewLayout? = if usesReducedContent { + CompactOverviewLayout.resolveForMenu( menuWidth: menuWidth, - projections: rows.compactMap(\.projection), layoutDirection: codexBarUsesRightToLeftLayout() ? .rightToLeft - : .leftToRight, - textWidthMeasurer: .appKit()) + : .leftToRight) } else { nil } - let rowStyle: OverviewMenuRowStyle = compactRequested ? .compact : .detailed + 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 @@ -69,14 +73,20 @@ extension StatusItemController { section: "overviewDetailed", additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]) case .compact: - row.layoutModel.heightFingerprint( + row.projection?.heightFingerprint( section: "overviewCompact", - additional: [ - "projection=\(row.projection?.layoutSignature ?? "missing")", - "columns=\(compactColumns?.signature ?? "missing")", - ]) + 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 == .compact + let accessibilityLabel = rowStyle.usesReducedContent ? row.projection?.providerName ?? row.layoutModel.providerName : row.model.providerName let item = self.makeMenuCardItem( @@ -86,19 +96,22 @@ extension StatusItemController { storageText: storageText, width: menuWidth, style: rowStyle, - compactColumns: compactColumns), + 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), usesGPUSelection: true, - layoutDirection: rowStyle == .compact ? compactColumns?.layoutDirection : nil, + layoutDirection: rowStyle.usesReducedContent ? compactLayout?.layoutDirection : nil, accessibilityLabel: accessibilityLabel, - accessibilityHelp: rowStyle == .compact ? L("Show details") : 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) @@ -109,10 +122,29 @@ extension StatusItemController { item.action = #selector(self.selectOverviewProvider(_:)) } menu.addItem(item) - if index < rows.count - 1 { + 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 e6b5d44d56..1791dc08d4 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -270,7 +270,7 @@ extension StatusItemController { for: enabledProviders, selectedProvider: selectedProvider, descriptor: descriptor, - usesCompactOverview: isOverviewSelected && self.settings.mergedOverviewUsesCompactLayout) + overviewLayout: isOverviewSelected ? self.settings.mergedOverviewLayout : .detailed) let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 9608926b17..ae83f6059e 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,6 +39,7 @@ extension StatusItemController { heightCacheScope: String? = nil, heightCacheFingerprint: String? = nil, submenu: NSMenu? = nil, + showsSubmenuIndicator: Bool = true, submenuIndicatorAlignment: Alignment = .topTrailing, submenuIndicatorTopPadding: CGFloat = 8, containsInteractiveControls: Bool = false, @@ -57,7 +67,7 @@ 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, @@ -119,15 +129,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 8d3fbff8ef..eaec317298 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -817,6 +817,10 @@ extension MenuRowContainerView { var hasGPUSelectionLayerForTesting: Bool { self.selectionView != nil } + + var showsSubmenuIndicatorForTesting: Bool { + self.rowPayload.showsSubmenuIndicator + } } #endif diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index af4a51121f..23634fa996 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -122,11 +122,12 @@ extension StatusItemController { "claudeSwapRevision=\(self.store.claudeSwapRevision)", ] - if self.shouldMergeIcons, - self.settings.mergedMenuLastSelectedWasOverview, - self.settings.mergedOverviewUsesCompactLayout - { - parts.append("compactOverview=\(self.compactOverviewStructuralSignature())") + 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() { @@ -150,7 +151,7 @@ extension StatusItemController { let providers = self.settings.resolvedMergedOverviewProviders( activeProviders: self.store.enabledProvidersForDisplay(), maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) - return providers.map { provider in + let providerSignature = providers.map { provider in guard let model = self.menuCardModel(for: provider) else { return "\(provider.rawValue):missing" } @@ -167,6 +168,7 @@ extension StatusItemController { projection.layoutSignature, ].map { "\($0.utf8.count):\($0)" }.joined(separator: "|") }.joined(separator: ";") + return "layout=\(self.settings.mergedOverviewLayout.rawValue)|\(providerSignature)" } static func dashboardBreakdownReadinessSignature( diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 0f236f34c0..dbf92c5f3e 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -24,6 +24,21 @@ 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 { @@ -61,6 +76,7 @@ enum CompactOverviewProjectionResolver { } struct OverviewMenuCardRowView: View { + static let showsHeaderDivider = true static let showsSectionDividers = false let model: UsageMenuCardView.Model @@ -68,7 +84,7 @@ struct OverviewMenuCardRowView: View { let storageText: String? let width: CGFloat let style: OverviewMenuRowStyle - let compactColumns: CompactOverviewColumnLayout? + let compactLayout: CompactOverviewLayout? @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.menuCardRefreshMonitor) private var refreshMonitor @@ -78,14 +94,14 @@ struct OverviewMenuCardRowView: View { storageText: String?, width: CGFloat, style: OverviewMenuRowStyle = .detailed, - compactColumns: CompactOverviewColumnLayout? = nil) + compactLayout: CompactOverviewLayout? = nil) { self.model = model self.layoutModel = layoutModel self.storageText = storageText self.width = width self.style = style - self.compactColumns = compactColumns + self.compactLayout = compactLayout } var body: some View { @@ -93,10 +109,22 @@ struct OverviewMenuCardRowView: View { case .detailed: self.detailedContent case .compact: - if let compactColumns = self.compactColumns { - CompactOverviewRowContent( + if let compactLayout = self.compactLayout { + CompactOverviewLabeledContent( + projection: self.compactProjection, + layout: compactLayout) + } + case .providerBars: + if let compactLayout = self.compactLayout { + CompactOverviewProviderBarsContent( + projection: self.compactProjection, + layout: compactLayout) + } + case .barsOnly: + if let compactLayout = self.compactLayout { + CompactOverviewBarsOnlyContent( projection: self.compactProjection, - columns: compactColumns) + layout: compactLayout) } } } @@ -105,7 +133,7 @@ struct OverviewMenuCardRowView: 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( diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index bbf12a07a0..6db575487f 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -8,7 +8,7 @@ extension StatusItemController { for providers: [UsageProvider], selectedProvider: UsageProvider?, descriptor: MenuDescriptor, - usesCompactOverview: Bool = false) -> CGFloat + overviewLayout: MergedOverviewLayout = .detailed) -> CGFloat { let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { providers.map { provider in @@ -23,8 +23,8 @@ extension StatusItemController { [descriptor.sections] } let measuredWidth = self.measuredMenuCardWidth(for: sectionSets) - guard usesCompactOverview else { return measuredWidth } - return max(measuredWidth, CompactOverviewColumnLayout.minimumMenuWidth) + 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 3c0481f468..c54e3e2eb7 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -39,11 +39,11 @@ struct NativeHighlightDeferredMenuRebuild { @MainActor private struct MergedOverviewMenuObservation: Equatable { - let usesCompactLayout: Bool + let layout: MergedOverviewLayout let selectedProviders: [UsageProvider] init(settings: SettingsStore) { - self.usesCompactLayout = settings.mergedOverviewUsesCompactLayout + self.layout = settings.mergedOverviewLayout self.selectedProviders = settings.mergedOverviewSelectedProviders } } 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 03925a1fff..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 @@ -51,7 +52,7 @@ struct UsageProgressBar: View { paceOnTop: Bool = true, warningMarkerPercents: [Double] = [], workdayMarkerPercents: [Double] = [], - height: CGFloat = 6) + height: CGFloat = Self.defaultHeight) { self.percent = percent self.tint = tint diff --git a/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift index f6223a4aaa..a06be717fc 100644 --- a/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift +++ b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift @@ -7,8 +7,14 @@ import Testing @Suite(.serialized) struct CompactOverviewMenuIntegrationTests { @Test - func `compact overview assembles variable lane rows in provider order`() throws { - let fixture = self.makeFixture(compact: true) + 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) + } + + 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)) @@ -25,8 +31,23 @@ struct CompactOverviewMenuIntegrationTests { let cursorIndex = try #require(menu.items.firstIndex(of: rows[0])) let claudeIndex = try #require(menu.items.firstIndex(of: rows[1])) - #expect(claudeIndex == cursorIndex + 2) - #expect(menu.items[cursorIndex + 1].isSeparatorItem) + 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) @@ -49,48 +70,90 @@ struct CompactOverviewMenuIntegrationTests { #expect(cursorHeight > 0) #expect(cursorHeight < claudeHeight) - let claudeView = try #require( - claudeRow.view as? GPUSelectionHostingView) + 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) } @Test - func `compact and detailed rows use distinct cache geometry`() throws { - let compact = self.makeFixture(compact: true) - let detailed = self.makeFixture(compact: false) + 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 ? 24 : 60 + 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, @@ -107,12 +170,120 @@ struct CompactOverviewMenuIntegrationTests { .map(\.fingerprint)) #expect(cursorCompactFingerprints.contains(initialCursorFingerprint)) - #expect(cursorCompactFingerprints.count == 2) + #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 - 24) <= 1) + #expect(abs(codexInteriorHeight - 24) <= 1) + #expect(abs(claudeLastHeight - 24) <= 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 - 24) <= 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 - 24) <= 1) + #expect(Self.barsOnlySpacers(in: oneProviderMenu).count == 2) + let soleFingerprints = fingerprints(.codex) + #expect(soleFingerprints == firstFingerprints) } @Test - func `structural signature ignores values and detects lanes and peer titles`() { - let fixture = self.makeFixture(compact: true) + 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() @@ -151,6 +322,16 @@ struct CompactOverviewMenuIntegrationTests { 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 { @@ -159,7 +340,12 @@ struct CompactOverviewMenuIntegrationTests { let controller: StatusItemController } - private func makeFixture(compact: Bool) -> Fixture { + 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) @@ -174,27 +360,36 @@ struct CompactOverviewMenuIntegrationTests { settings.mergeIcons = true settings.switcherShowsIcons = false settings.mergedMenuLastSelectedWasOverview = true - settings.mergedOverviewUsesCompactLayout = compact + settings.mergedOverviewLayout = layout settings.historicalTrackingEnabled = false settings.showOptionalCreditsAndExtraUsage = true settings.providerStorageFootprintsEnabled = false settings.costUsageEnabled = false - settings.setProviderOrder([.cursor, .claude]) - self.enableOnly([.cursor, .claude], settings: settings) + 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) - store._setSnapshotForTesting(Self.cursorSnapshot(primaryPercent: 10), provider: .cursor) - store._setSnapshotForTesting( - Self.claudeSnapshot( - primaryPercent: 10, - secondaryPercent: 20, - extraPercent: 30, - extraTitle: "Peer"), - provider: .claude) + 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, @@ -204,11 +399,22 @@ struct CompactOverviewMenuIntegrationTests { preferencesSelection: PreferencesSelection(), statusBar: .system, menuCardRenderingEnabled: true, - menuRefreshEnabled: false, + 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 func enableOnly(_ enabled: Set, settings: SettingsStore) { for provider in UsageProvider.allCases { guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } @@ -231,6 +437,13 @@ struct CompactOverviewMenuIntegrationTests { } } + 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 } diff --git a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift index 577df8a6a9..fa39ac8c86 100644 --- a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift +++ b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift @@ -45,6 +45,19 @@ struct CompactOverviewProjectionTests { #expect(projection.fallback == nil) } + @Test + func `projection uses the same provider specific metric titles as detailed cards`() { + let drawable = CompactOverviewProjection(model: Self.model( + provider: .openrouter, + metrics: [Self.metric(id: "primary", title: "Credits")])) + let status = CompactOverviewProjection(model: Self.model( + provider: .openrouter, + metrics: [Self.metric(id: "primary", title: "Credits", statusText: "Unavailable")])) + + #expect(drawable.lanes.first?.title == L("API key limit")) + #expect(status.fallback?.metricTitle == L("API key limit")) + } + @Test func `loading status and generic fallback precedence is deterministic`() { let statusMetrics = [ @@ -188,103 +201,66 @@ struct CompactOverviewProjectionTests { } @Test - func `allocator gives the 310 point budget to a wide bar and full provider header`() throws { - let minimum = try CompactOverviewColumnLayout.allocate(.init( + func `layout gives every reduced mode stable full width geometry`() { + let minimum = CompactOverviewLayout.resolveForMenu( menuWidth: 310, - idealMetricWidth: 112, - fontSignature: "fixture-font", - layoutDirection: .leftToRight)) - let wider = try CompactOverviewColumnLayout.allocate(.init( + layoutDirection: .leftToRight) + let wider = CompactOverviewLayout.resolveForMenu( menuWidth: 360, - idealMetricWidth: 112, - fontSignature: "fixture-font", - layoutDirection: .leftToRight)) - - #expect(minimum.menuWidth == 310) - #expect(minimum.metricWidth == 112) - #expect(minimum.barWidth == 146) - #expect(minimum.barWidth == CompactOverviewColumnLayout.minimumBarWidth) + layoutDirection: .leftToRight) + + #expect(minimum.menuWidth == CompactOverviewLayout.minimumMenuWidth) #expect(minimum.contentWidth == 270) - #expect(minimum.providerHeaderWidth == 258) - #expect(minimum.occupiedWidth == 310) - #expect(wider.metricWidth == minimum.metricWidth) - #expect(wider.barWidth == minimum.barWidth + 50) - #expect(wider.occupiedWidth == 360) - #expect(CompactOverviewColumnLayout.barHeight == 8) - #expect(CompactOverviewColumnLayout.providerContentSpacing == 4) - #expect(CompactOverviewColumnLayout.laneSpacing == 3) + #expect(minimum.labeledBarWidth == minimum.contentWidth) + #expect(minimum.providerHeaderWidth + CompactOverviewLayout.chevronGutterWidth == minimum.contentWidth) + #expect(minimum.providerBarsBarWidth == minimum.contentWidth) + #expect(minimum.barsOnlyBarWidth == minimum.contentWidth) + #expect(minimum.labeledBarWidth == minimum.providerBarsBarWidth) + #expect(minimum.labeledBarWidth == minimum.barsOnlyBarWidth) + #expect(wider.contentWidth == minimum.contentWidth + 50) + #expect(wider.labeledBarWidth == minimum.labeledBarWidth + 50) + #expect(wider.providerBarsBarWidth == minimum.providerBarsBarWidth + 50) + #expect(wider.barsOnlyBarWidth == minimum.barsOnlyBarWidth + 50) + #expect(CompactOverviewLayout.barHeight == UsageProgressBar.defaultHeight) + #expect(CompactOverviewLayout.providerBarsLaneSpacing == 12) + #expect(CompactOverviewLayout.barsOnlyLaneSpacing == 12) + #expect(CompactOverviewLayout.barsOnlyInterProviderSpacing == 18) + #expect(CompactOverviewLayout.barsOnlySectionOuterSpacing == 12) + #expect(CompactOverviewLayout.barsOnlyVerticalPadding == 5.5) + #expect(CompactOverviewLayout.barsOnlySectionSpacerHeight == 3) + let interProviderSpacing = MenuCardItemSizing.measuredHeightPadding + + CompactOverviewLayout.barsOnlyVerticalPadding * 2 + #expect(interProviderSpacing == CompactOverviewLayout.barsOnlyInterProviderSpacing) + let sectionOuterSpacing = MenuCardItemSizing.measuredHeightPadding / 2 + + CompactOverviewLayout.barsOnlyVerticalPadding + + CompactOverviewLayout.barsOnlySectionSpacerHeight + #expect(sectionOuterSpacing == CompactOverviewLayout.barsOnlySectionOuterSpacing) + #expect(CompactOverviewLayout.labeledMetricSpacing == CompactOverviewLayout.barsOnlyLaneSpacing) + #expect(minimum.signature.contains("barsOnlySectionSpacer")) } @Test - func `allocator rejects unsupported width caps long labels and reclaims short label space`() throws { - do { - _ = try CompactOverviewColumnLayout.allocate(.init( - menuWidth: 309, - idealMetricWidth: 112, - fontSignature: "fixture-font", - layoutDirection: .leftToRight)) - Issue.record("Expected minimum-width rejection") - } catch let error as CompactOverviewColumnLayoutError { - #expect(error == .menuWidthBelowMinimum(309)) - } - - let capped = try CompactOverviewColumnLayout.allocate(.init( + func `layout signature tracks width and direction without provider text`() { + let leftToRight = CompactOverviewLayout.resolveForMenu( menuWidth: 310, - idealMetricWidth: 500, - fontSignature: "fixture-font", - layoutDirection: .leftToRight)) - #expect(capped.metricWidth == 112) - #expect(capped.barWidth == 146) - - let short = try CompactOverviewColumnLayout.allocate(.init( + layoutDirection: .leftToRight) + let same = CompactOverviewLayout.resolveForMenu( menuWidth: 310, - idealMetricWidth: 48, - fontSignature: "fixture-font", - layoutDirection: .leftToRight)) - #expect(short.metricWidth == 48) - #expect(short.barWidth == 210) - #expect(short.occupiedWidth == 310) - } - - @Test - func `resolver measures only selected metric titles without caching source text`() throws { - var measured: [(String, CompactOverviewTextWidthMeasurer.Role)] = [] - let measurer = CompactOverviewTextWidthMeasurer(fontSignature: "fixture-font") { text, role in - measured.append((text, role)) - return switch role { - case .provider: 80 - case .metric: 140 - } - } - let statusProjection = Self.projection( - providerName: "Private Status Provider", - metrics: [Self.metric( - id: "balance", - title: "Balance Title", - statusText: "Private status value")]) - let laneProjection = Self.projection( - providerName: "Private Bar Provider", - metrics: [Self.metric(id: "session", title: "Session Title")]) - let layout = try CompactOverviewColumnLayout.resolve( + layoutDirection: .leftToRight) + let wider = CompactOverviewLayout.resolveForMenu( + menuWidth: 360, + layoutDirection: .leftToRight) + let rightToLeft = CompactOverviewLayout.resolveForMenu( menuWidth: 310, - projections: [statusProjection, laneProjection], - layoutDirection: .rightToLeft, - textWidthMeasurer: measurer) - let measuredTexts = measured.map(\.0) - - #expect(!measuredTexts.contains("Private Status Provider")) - #expect(!measuredTexts.contains("Private Bar Provider")) - #expect(measuredTexts.contains("Balance Title")) - #expect(measuredTexts.contains("Session Title")) - #expect(!measuredTexts.contains("Private status value")) - #expect(measured.allSatisfy { $0.1 == .metric }) - #expect(layout.metricWidth == 112) - #expect(layout.barWidth == 146) - for rawValue in ["Private Status Provider", "Balance Title", "Private Bar Provider", "Session Title"] { - #expect(!layout.signature.contains(rawValue)) - } - #expect(layout.signature.contains("direction=rtl")) - #expect(layout.layoutDirection == .rightToLeft) + layoutDirection: .rightToLeft) + + #expect(leftToRight.signature == same.signature) + #expect(leftToRight.signature != wider.signature) + #expect(leftToRight.signature != rightToLeft.signature) + #expect(leftToRight.signature.contains("direction=ltr")) + #expect(rightToLeft.signature.contains("direction=rtl")) + #expect(!leftToRight.signature.contains("Private Provider Name")) + #expect(rightToLeft.layoutDirection == .rightToLeft) } @Test @@ -302,6 +278,16 @@ struct CompactOverviewProjectionTests { #expect(!english) #expect(arabic) #expect(persian) + let leftToRight = CompactOverviewLayout.resolveForMenu( + menuWidth: 310, + layoutDirection: .leftToRight) + let rightToLeft = CompactOverviewLayout.resolveForMenu( + menuWidth: 310, + layoutDirection: .rightToLeft) + #expect(leftToRight.contentWidth == rightToLeft.contentWidth) + #expect(leftToRight.labeledBarWidth == rightToLeft.labeledBarWidth) + #expect(leftToRight.providerBarsBarWidth == rightToLeft.providerBarsBarWidth) + #expect(leftToRight.barsOnlyBarWidth == rightToLeft.barsOnlyBarWidth) #expect(MenuCardSectionContainerView.submenuIndicatorSystemName(for: .leftToRight) == "chevron.right") #expect(MenuCardSectionContainerView.submenuIndicatorSystemName(for: .rightToLeft) == @@ -386,49 +372,101 @@ struct CompactOverviewProjectionTests { #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 row height includes a provider header and follows every lane`() throws { - let layout = try CompactOverviewColumnLayout.allocate(.init( + func `hosted reduced rows preserve every lane in increasing detail order`() throws { + let layout = CompactOverviewLayout.resolveForMenu( menuWidth: 310, - idealMetricWidth: 112, - fontSignature: "fixture-font", - layoutDirection: .leftToRight)) - var heights: [Int: CGFloat] = [:] - for count in [0, 1, 2, 3, 6, 12] { + 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..= 40) - #expect(oneLaneHeight < twoLaneHeight) - #expect(twoLaneHeight < threeLaneHeight) - #expect(threeLaneHeight < sixLaneHeight) - #expect(sixLaneHeight < twelveLaneHeight) - #expect(twoLaneHeight <= 66) - #expect(twoLaneHeight + 7 <= 73) - #expect(abs((twoLaneHeight - oneLaneHeight) - (threeLaneHeight - twoLaneHeight)) <= 1) - - let canonicalAttachedHeight = 2 * (oneLaneHeight + 7) - + 2 * (twoLaneHeight + 7) - + 2 * (threeLaneHeight + 7) - #expect(canonicalAttachedHeight <= 432) + let fallbackLabeledHeight = try #require(labeledHeights[0]) + let fallbackProviderBarsHeight = try #require(providerBarsHeights[0]) + let fallbackBarsOnlyHeight = try #require(barsOnlyHeights[0]) + #expect(fallbackLabeledHeight > 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, 17.0), (2, 35.0), (3, 53.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 - 17) <= 1) + #expect(CompactOverviewLayout.barsOnlySectionSpacerHeight == 3) } private static func projection( diff --git a/Tests/CodexBarTests/CompactOverviewSettingsTests.swift b/Tests/CodexBarTests/CompactOverviewSettingsTests.swift index 1d4c7bee09..002415ee3c 100644 --- a/Tests/CodexBarTests/CompactOverviewSettingsTests.swift +++ b/Tests/CodexBarTests/CompactOverviewSettingsTests.swift @@ -7,6 +7,9 @@ import Testing @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 @@ -25,19 +28,98 @@ struct CompactOverviewSettingsTests { } @Test - func `compact overview defaults off persists and refreshes only menus`() async throws { - let suite = "SettingsStoreTests-compact-overview" + 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 configStore = testConfigStore(suiteName: suite) - let store = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(store.mergedOverviewUsesCompactLayout == false) - #expect(defaults.object(forKey: "mergedOverviewUsesCompactLayout") == nil) + 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 @@ -53,22 +135,27 @@ struct CompactOverviewSettingsTests { menuDidChange.set() } - store.mergedOverviewUsesCompactLayout = true + store.mergedOverviewLayout = .providerBars try? await Task.sleep(nanoseconds: 50_000_000) - #expect(defaults.bool(forKey: "mergedOverviewUsesCompactLayout")) + #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)) + } - let reloaded = SettingsStore( + private static func makeStore(defaults: UserDefaults, configStore: CodexBarConfigStore) -> SettingsStore { + SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(reloaded.mergedOverviewUsesCompactLayout) } } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index ba42f02f2a..83be640eb3 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -110,7 +110,7 @@ struct LocalizationLanguageCatalogTests { } @Test - func `compact overview copy is translated in every catalog`() throws { + func `overview layout copy is translated in every catalog`() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() @@ -121,6 +121,12 @@ struct LocalizationLanguageCatalogTests { 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_title": "Compact Overview", "overview_compact_subtitle": "Show provider names and usage bars in a space-saving layout.", "overview_compact_no_bars": "No usage bars", diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index 3431f7619b..3ced8ca781 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -127,22 +127,38 @@ struct PreferencesPaneSmokeTests { } @Test - func `menu pane compact overview toggle follows merge icons without clearing its value`() { + 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) - settings.mergedOverviewUsesCompactLayout = true + 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.mergedOverviewUsesCompactLayout) + #expect(settings.mergedOverviewLayout == .providerBars) _ = MenuPane(settings: settings, store: store).body settings.mergeIcons = true #expect(MenuPane.compactOverviewAvailable(mergeIcons: settings.mergeIcons)) - #expect(settings.mergedOverviewUsesCompactLayout) + #expect(settings.mergedOverviewLayout == .providerBars) _ = MenuPane(settings: settings, store: store).body } 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/superpowers/specs/2026-07-30-compact-overview-menu-design.md b/docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md deleted file mode 100644 index b62b6044a6..0000000000 --- a/docs/superpowers/specs/2026-07-30-compact-overview-menu-design.md +++ /dev/null @@ -1,813 +0,0 @@ ---- -summary: "Approved opt-in compact layout for the merged menu's Overview tab." -read_when: - - Implementing or reviewing compact Overview rows - - Changing Overview provider selection, row height, or menu refresh behavior - - Changing which usage-bar details appear in the merged menu ---- - -# Compact Overview menu — design - -**Status:** implemented -**Date:** 2026-07-30 -**Revised:** 2026-07-31 - -## Decision summary - -Add an opt-in **Compact Overview** setting for the merged menu. Detailed Overview remains the default. - -Compact Overview keeps the existing Overview provider selection, ordering, navigation, refresh, and submenu behavior, -but replaces each rich provider card with one actionable provider item containing: - -- a dedicated provider-name header row; and -- every drawable usage metric beneath that header, in the provider's existing - `UsageMenuCardView.Model.metrics` order. - -Each bar keeps a short metric label so multiple windows remain distinguishable. Visible numeric percentages are -omitted; the progress bar retains the existing percentage and used-versus-left accessibility semantics. Account identity, -freshness, plan, reset time, details, storage, notes, dashboards, and dedicated credit or cost sections stay available -in provider details but do not appear inline in Compact Overview. A provider with no drawable metric uses one muted, -single-line fallback beneath its header so the row does not look unfinished; that fallback may surface an existing -status-only balance metric. - -“All bars” means all drawable bars for the providers selected for Overview. The existing six-provider selection limit -does not change in this feature. - -For this feature, a **drawable metric** is exactly a `UsageMenuCardView.Model.metrics` entry whose `statusText` is -`nil`. The existing model builder remains responsible for producing a valid percentage, title, and semantic metric; -Compact Overview adds no provider-specific or numeric filtering. Credits, cost, storage, reset credits, and inline -dashboards are separate model sections rather than compact metrics and remain excluded. - -## Why this needs a bounded design - -The current Overview row is effectively a full provider card. It composes the two-line provider header, full metric -rows, reset and detail text, optional notes or dashboards, and optional storage text. Six providers can therefore make -the menu taller than the available display and require scrolling. - -The desired compact mode is presentation-only, but Overview is also coupled to: - -- persisted provider selection and a six-provider cap; -- live model updates while an `NSMenu` is tracking; -- measured-height caching; -- provider-detail submenus and click-to-select behavior; -- custom wheel navigation and native trackpad scrolling; and -- GPU-backed row selection added after the Overview scroll-stutter investigation. - -A dedicated contract prevents a small layout option from accidentally changing those established behaviors. The -performance history and earlier Lite-row experiment are documented in -[`docs/overview-scroll-stutter-investigation.md`](../../overview-scroll-stutter-investigation.md). - -## Goals - -- Let users compare the selected providers' usage bars at a glance. -- Keep the normal six-provider Overview substantially shorter than Detailed while using comfortable, legible text and - bars; prefer a small amount of native scrolling over making the content uncomfortably small. -- Preserve every actual metric bar rather than silently choosing one window per provider. -- Keep the current detailed Overview unchanged for users who do not opt in. -- Reuse the existing menu-card model, progress renderer, live-refresh seam, and interaction wiring. -- Keep the implementation display-only: no new fetches, dependencies, account state, or provider-specific data paths. - -## Non-goals - -- Showing every enabled provider. Overview continues to show at most six user-selected providers. -- Guaranteeing a scroll-free menu for every display, Accessibility text size, or provider response. -- Capping or dropping bars merely to force the menu to fit. -- Changing provider order, Overview selection, switcher contents, row click behavior, or detail submenus. -- Compacting individual provider tabs, Settings provider details, widgets, or menu-bar icons. -- Adding new provider-specific credit, cost, storage, or inline-dashboard projections. The generic no-bar fallback may - reuse an existing redacted metric `statusText`, but it does not inspect provider snapshots or rich-card sections. -- Revisiting the existing Overview GPU-selection or scroll implementations. - -## User experience - -### Setting - -Preferences → Menu → Content gains a toggle as the first content-layout option: - -- **Title:** Compact Overview -- **Subtitle:** Show provider names and usage bars in a space-saving layout. - -The Overview provider selector remains in Preferences → Menu Bar → Combined Icon. Only the presentation toggle -moves to the Menu pane, because it controls menu content rather than the menu-bar status item. The toggle is disabled -when **Merge Icons** is off because Overview is not then available. Its stored value is retained, so re-enabling Merge -Icons restores the user's choice. - -The setting defaults off for both existing and new installations. No migration is required. - -### Compact row - -Each provider is one actionable menu item. Inside it, the provider name occupies a dedicated full-width header row and -the metric lanes are stacked underneath: - -```text -Codex › -Session ███████░░░ -Weekly ███░░░░░░░ - -Claude › -Session █████░░░░░ -Weekly ████████░░ -``` - -This is a layout illustration, not fixed copy. Localized metric titles and the existing used/remaining preference -remain authoritative. - -Each metric lane contains only: - -1. the existing localized metric title; -2. the existing `UsageProgressBar`. - -Do not render a percentage `Text` view or reserve a percentage column. The existing used/remaining preference remains -authoritative for bar fill and accessibility. `UsageProgressBar` continues to expose the formatted percentage together -with its localized `Usage remaining` or `Usage used` meaning to assistive technologies. - -The provider header spans the content width up to the fixed trailing chevron gutter; it is not part of the metric grid. -The chevron aligns vertically with the provider header. Every selected provider uses the same metric-label and bar -column widths. Long provider and metric names truncate to one line while preserving their full accessibility text. The -bar does not compress until the metric label reaches its cap. - -Provider rows are variable-height. A provider with one drawable metric gets one lane, a provider with `N` drawable -metrics gets exactly `N` lanes, and a provider with no drawable metrics gets one fallback lane. Do not reserve an empty -second lane, cap a row at two lanes, collapse additional lanes, or repeat the provider header. Keep vertical padding, -header-to-metrics spacing, lane height, and lane spacing in the compact-layout constant group. At default text size, a -one-lane or fallback hosted item should remain at most 54 points including the existing seven-point AppKit measurement -inset, and each additional lane should add at most 20 points. Attached-menu tests remain authoritative because AppKit -can prefer intrinsic height over the assigned frame. - -The 310-point minimum-width budget is: - -| Element | Budget | -| --- | ---: | -| Outer horizontal padding | 40 pt (`20 pt × 2`) | -| Metric-to-bar spacing | 12 pt | -| Metric label | up to 112 pt | -| Minimum progress bar | 146 pt | -| **Total** | **310 pt** | - -At 310 points and default text size, cap the shared metric column at 112 points. The allocator may reclaim unused metric -label width for the bar, but it must never give one row a different metric/bar split. At wider resolved menu widths, -keep the label cap, padding, and gap fixed and give all surplus width to the bar. The 12-point chevron gutter is carved -out of the provider header's 270-point content width; metric lanes span that full width below the header. - -Use `.headline` for the provider header and `.body` for the metric title. Use logical leading alignment for both. -Vertically center an eight-point progress bar in each metric lane. Use four points of vertical padding on each edge, -four points between the header and first metric, and three points between subsequent lanes. Keep these values in one -compact-layout constant group. - -The allocator's supported input is the production invariant `menuWidth >= 310`; assert that precondition in DEBUG and -unit tests rather than inventing an unreachable below-minimum layout. Measure metric titles with the resolved body -font and cap their shared selected-set maximum at 112 points. That fixed cap and the other fixed reservations guarantee -the 146-point bar floor at 310 points even when text scale changes; larger text truncates visually at the cap and keeps -its full accessibility string. If a future design adds or enlarges a fixed reservation, update this budget and the -production minimum together instead of allowing overlap or horizontal scrolling. - -The submenu chevron is currently an overlay rather than a layout participant. Reserve its gutter in every provider -header, including rows without a submenu, so headers align and cannot overlap the indicator. Because the indicator is -header-aligned, metric lanes below it may use the full content width. - -Compact rows use comfortable but still economical vertical spacing and keep the current separators between providers. -They do not add another scroll container; AppKit remains responsible if the whole menu exceeds the display. - -### Content rules - -Compact Overview first preserves the existing menu-build exclusion for `model.isOverviewErrorOnly`. It then renders, -in order, only metrics with `statusText == nil`. A metric with non-`nil` `statusText` is text-only in the current rich -card and must be omitted from the bar lanes rather than converted into a synthetic empty or full bar. It may be used -only by the no-drawable-metric fallback defined below. - -For each drawable metric, preserve: - -- `percent` and `percentStyle`; -- provider tint; -- pace percentage and pace direction; -- quota-warning marker percentages; and -- workday marker percentages. - -Compact Overview omits inline: - -- email, organization, workspace, and other account identity; -- update/loading/error subtitle and copy button; -- plan; -- reset time and all secondary/detail/session-equivalent lines; -- storage footprint; -- usage notes and placeholders; -- inline usage dashboards and charts; -- reset credits, dedicated credit-balance, provider-cost, and token-cost sections; and -- metric card backgrounds or other rich-card decoration. - -If a selected provider has no drawable metrics but is not excluded by the existing error-only rule, show its provider -header plus one muted, single-line fallback beneath it: - -1. when `model.subtitleStyle == .loading`, show `L("Loading…")`; -2. otherwise, trim each metric's `statusText` with `.whitespacesAndNewlines` and, if any result is non-empty, use the - first such metric in model order; or -3. otherwise show the localized compact no-bars string. - -For a status fallback, put the metric title in the shared metric column and let its trimmed status text occupy the bar -column. For loading or generic no-bars fallback, let the one fallback string span the metric and bar columns. Use -`.body`, secondary styling, leading alignment in the current layout direction, and -`.lineLimit(1)` for fallback text. This avoids hard-coded English punctuation or word order and preserves the shared -metric/bar grid in right-to-left locales. A one-lane fallback row must be no taller than the one-lane compact metric -row. - -This is the only compact-mode exception to names plus drawable bars. It covers shipped status-only balance models and -all-unlimited services, plus dashboard- or notes-only providers and providers with no current usage data. Fallback text -truncates visually but uses the already-redacted full model text for accessibility. The row remains navigable to -provider details. A live transition into or out of these states follows the existing compatible-layout and -structural-rebuild rules. - -### Accessibility - -The compact row remains one actionable AppKit menu item. Use a structured accessibility hierarchy rather than -concatenating a sentence with hard-coded punctuation or English word order: - -- The actionable row exposes the full provider name as its label and retains the existing open-provider-details action - and hint. -- Each drawable lane is a non-actionable informational child, in model order, whose label is the full metric title. - Its `UsageProgressBar` child keeps the existing localized `percentStyle.accessibilityLabel` (`Usage remaining` or - `Usage used`) and existing accessibility value for the formatted percentage, quota-warning markers, and workday - markers. This feature does not add new pace-announcement semantics. -- Hide the separate visual provider and metric `Text` nodes from accessibility so VoiceOver does not announce duplicate - fragments. Visual truncation must not truncate accessibility strings. -- A no-bar row has one non-actionable fallback child. For a status fallback, its label and value are the full metric - title and untruncated trimmed status respectively. For loading or generic fallback, its label is the full localized - fallback. The actionable parent continues to carry the provider name and row action. -- Omitted account, plan, reset, storage, dashboard, credit, and cost content must not remain in hidden accessibility - children. - -Tests must inspect these label/value fields separately, including under an Arabic or Persian locale, and must reject a -single synthesized provider/metric/value phrase. This preserves localized semantics without adding a fourth format -key. - -The three new localization keys are: - -- `overview_compact_title`: **Compact Overview** -- `overview_compact_subtitle`: **Show provider names and usage bars in a space-saving layout.** -- `overview_compact_no_bars`: **No usage bars** - -Use `overview_compact_no_bars` for both the visible generic fallback and its accessibility child label. Add all three -keys to all 23 complete locale catalogs and cover their presence with a focused localization-catalog test. The English -catalog uses the source copy above; the other 22 complete catalogs require non-empty reviewed translations rather than -copied English placeholders. English fallback is not the completion policy for these complete catalogs. - -### Interaction - -Compact and Detailed Overview preserve the same row-level interaction: - -- the same `overviewRow-` identity; -- the same provider order and selected subset; -- the same click and keyboard activation behavior; -- the same usage, cost, storage, and provider-specific submenus; -- the same submenu indicator; -- the same `usesGPUSelection: true` hosting path; -- the same mouse-wheel and trackpad behavior; -- the same viewport restoration behavior; and -- the same Refresh, Settings, About, and Quit footer. - -Selecting a compact row opens the existing detailed provider tab. Compact mode does not create a second compact -provider-detail surface. Compact has no embedded SwiftUI button, so its menu item must use -`containsInteractiveControls: false` while retaining `usesGPUSelection: true`. -Detailed keeps its existing `containsInteractiveControls` expression because its live error subtitle can expose a copy -button. - -## Fit contract - -Compact Overview should remain substantially shorter than Detailed without sacrificing readable text or bars. Use this -representative case for UI proof: - -- macOS 27.0 in the project's Parallels UI environment; -- a 1280 × 720-point logical display at 2× scale, English locale, default text size, and light appearance; -- an actual resolved menu width of 310 points, not merely the 310-point baseline before standard-item measurement; -- Overview plus Codex, Claude, Cursor, OpenCode, Warp, and Gemini in the switcher, with switcher icons enabled; -- those same six providers selected for Overview and none excluded by the error-only filter; -- drawable-metric counts of `1, 1, 2, 2, 3, 3` in provider order, totaling twelve bars, using **Session**, - **Weekly**, and **Code review** as needed at deterministic percentages; -- no update, agent-session, contextual-provider, storage, cost, credit, or debug rows; and -- the normal Refresh, Settings, About, and Quit footer. - -For that fixture, the complete menu's intrinsic content height should be no more than 680 points. The canonical -attached clip-view height is at least 656 points; the expected Parallels result is roughly 660 points. A vertical scroll -range of up to 24 points is acceptable. If the attached viewport is shorter than 656 points, record it as an -environment mismatch and report its actual scroll range separately. Zero scrolling is preferred when the actual -display and menu attachment permit it, but the implementation must not shrink the revised header, metric text, or -progress bars merely to achieve zero. - -The 680-point ceiling is a revised comfort-first design budget, not a previously measured result: - -| Element | Count and per-item target | Budget | -| --- | --- | ---: | -| Overview plus six-provider switcher | two 36 pt rows + 4 pt spacing | 76 pt | -| Compact provider items | six header-plus-metrics items, twelve total lanes, including each host's 7 pt measurement inset | ≤432 pt aggregate | -| Refresh | `1 × 24 pt` | 24 pt | -| Native Settings, About, and Quit rows | provisional `3 × 22 pt` | 66 pt | -| Separators | provisional `7 × 9 pt` | 63 pt | -| AppKit measurement and pixel-rounding headroom | remainder | 19 pt | -| **Total ceiling** | | **680 pt** | - -The seven-separator fixture is one separator after the switcher, five between the six provider rows, and one before the -footer. If the built menu has a different separator topology, record the actual count and heights and rebalance the -budget explicitly rather than treating 63 points as fixed. - -The switcher and Refresh values come from current source constants. Compact-item, native-item, and separator values -must be verified against the freshly built bundle because AppKit owns some of their geometry. A one-lane compact item -that exceeds 54 points, an additional lane increment that exceeds 20 points, or canonical compact items whose -aggregate exceeds 432 points is a failed design-budget assumption even if the complete menu happens to fit. - -Available height is also measured rather than inferred solely from the display resolution. Runtime proof must record -the display's `visibleFrame.height` and the attached menu clip-view height; the expected Parallels fixture has roughly -660 points of usable vertical space. The revised 680-point ceiling intentionally accepts at most one metric-lane-sized -scroll increment in that environment in exchange for a dedicated provider header, larger labels, and larger bars. - -This is an acceptance target, not a universal geometric guarantee. Metric counts are not globally bounded: -`extraRateWindows` and some provider responses can add additional windows. Accessibility text sizes and small displays -also reduce available space. - -When fit and completeness conflict, preserve all drawable bars and allow AppKit scrolling. Do not silently cap metrics, -restore visible percentages, shrink the approved text or bar sizes, or introduce horizontal scrolling. - -The PR's runtime proof must record the actual OS build and confirm every canonical parameter above. Record the resolved -menu width, display visible-frame height, intrinsic content height, attached clip-view height, per-item heights, and -vertical scroll range. A screenshot alone is supporting evidence, not the measurement. - -## Technical design - -### Settings and persistence - -Add a display-only Boolean: - -```swift -var mergedOverviewUsesCompactLayout: Bool -``` - -Persist it under a new stable `UserDefaults` key named `mergedOverviewUsesCompactLayout`. A missing key resolves to -`false`. - -Plumbing: - -- `SettingsStoreState.swift`: add the field near the merged-menu settings. -- `SettingsStore.swift`: load the default and pass it into `SettingsDefaultsState`. -- `SettingsStore+Defaults.swift`: add the synchronous state/UserDefaults accessor. -- `SettingsStore+MenuObservation.swift`: include it in `menuObservationToken`. -- `PreferencesMenuPane.swift`: add the toggle at the start of the Content section. - -This preference must not increment `backgroundWorkSettingsRevision`, enter `CodexBarConfig`, change widget state, or -trigger provider fetching. - -### Presentation seam - -Keep `OverviewMenuCardRowView` as the outer row type so its callers and menu-item wiring do not fork. Give it an -explicit module-internal presentation style: - -```swift -enum OverviewMenuRowStyle { - case detailed - case compact -} -``` - -`addOverviewRows` resolves the style from `mergedOverviewUsesCompactLayout` and passes it to the row. Detailed uses the -existing view composition unchanged. Compact uses a dedicated, small SwiftUI subview and the existing -`UsageProgressBar`. Resolve `storageFootprintText(for:)` only for Detailed; Compact omits the inline value and does not -need to read it. Continue constructing `makeOverviewRowSubmenu` in both styles because its storage fallback is -independent of the inline storage string. - -Add a pure module-internal projection for compact metric lanes. It should make inclusion, order, labels, values, and -full accessibility source strings testable without constructing an `NSStatusBar` or live `NSMenu`. - -Do not add a second provider model. The compact projection consumes `UsageMenuCardView.Model` and contains only the -fields necessary to render metric lanes or the deterministic no-bar fallback. It must consume the finished model after -`hidePersonalInfo` redaction rather than rebuilding any string from raw snapshots. - -Keep responsibilities explicit: - -- `OverviewMenuCardRowView` remains the outer style switch. -- Its Compact branch owns the fallback model, initial `layoutModel`, refresh-monitor resolution, and shared metric/bar - columns. -- A projection-only `CompactOverviewRowContent` leaf receives only a compact projection and - `CompactOverviewColumnLayout`; it has no refresh monitor, raw model, storage, or rich-card fields. - -The projection also exposes an ordered **drawable-lane layout signature** derived from each included metric's -`Metric.id`, title, and percent style. Encode both ID and title through -`UsageMenuCardView.Model.heightFingerprintField(_:_:)`; the signature must never contain either raw string. It changes -when a lane is added, removed, reordered, replaced by a different ID, changes title or percent style, or changes -between bar and text-status presentation. Use it for compact compatibility and height-cache assertions; raw metric -count is not sufficient. - -A no-bar projection exposes a separate **fallback layout signature**: - -- `loading` includes the fallback kind; -- `status` includes the kind, selected `Metric.id`, and title shape; and -- `generic` includes the fallback kind. - -Encode the selected status ID and title through `UsageMenuCardView.Model.heightFingerprintField(_:_:)`, which hashes -the value and records only its geometry-relevant shape rather than storing raw potentially personal text in a cache key. -Localization and fallback text content remain covered by the resolved model's existing height fingerprint, but are not -part of this layout signature: all fallback text is fixed to one `.body` line, and status text spans fixed columns, -so changing only that text cannot change measured height or shared-column allocation. - -Implement the width rule through a module-internal `CompactOverviewColumnLayout`, computed once in `addOverviewRows` -from every monitor-resolved compact projection remaining after the existing error-only filter. Excluded providers must -not influence geometry. The layout contains the shared metric and bar widths plus spacing and chevron-gutter widths and -a stable signature. Provider headers span the content width and are not allocator inputs. - -The shared-column signature contains only resolved numeric geometry, resolved text scale/font identity, layout -direction, and menu width. Metric titles are transient allocator inputs and must not be copied into that signature; -provider names and fallback copy are not allocator inputs at all. - -The allocator receives the resolved menu width and the maximum ideal metric-label width across the whole rendered set. -Inputs include every drawable metric title and the selected title of each status fallback; loading and generic -fallbacks add no metric-title input. It clamps that maximum to 112 points, reserves the header-only chevron gutter and 12-point -metric-to-bar gap, and gives all remaining width to the bar subject to its 146-point floor. The allocator requires a -menu width of at least 310 points. Do not derive widths independently inside each hosted row: SwiftUI alignment guides -cannot cross the separate `NSMenuItem` hosts. - -Obtain ideal widths before host construction through one injected, module-internal text-width measurer configured with -the exact compact metric font. Production may use the corresponding AppKit preferred font; pure allocator tests use a -deterministic stub. Do not create temporary SwiftUI hosts or query private subview geometry merely to measure strings. - -Pass the same resolved `CompactOverviewColumnLayout` to every compact row. A DEBUG-only geometry probe may expose -resolved frames for the hosted-view integration test; do not inspect private SwiftUI hierarchy. The probe must prove -that metric and bar columns align across at least two separate hosted rows, that provider headers occupy their own row, -and that neither a submenu nor a no-submenu row overlaps the reserved chevron gutter. - -### Live refresh - -The compact branch must mirror Detailed Overview's `usesLiveSubtitle` gate, resolve one complete live model through -`MenuCardRefreshMonitor`, and derive the provider name, metrics, bar values, markers, tint, fallback, and accessibility -strings from that same result: - -```swift -let liveModel = model.usesLiveSubtitle - ? refreshMonitor?.model(for: model.provider, fallback: model) ?? model - : model -``` - -Do not resolve a live subtitle separately or mix live and fallback metric state. The earlier Lite-row experiment -demonstrated that doing so can show a refreshed state beside stale bars. - -At menu construction, `addOverviewRows` uses the same gate to obtain one `layoutModel` for each provider before it -builds compact projections, the shared column layout, and height fingerprints. The compact view receives the fallback -model, that initial `layoutModel`, and the shared columns. This mirrors the existing full-card model/layout-model seam: -compatible value changes can update in place, while the initial rendered or frozen shape controls measurement. - -Menu construction and SwiftUI rendering are separate resolution transactions. Construction resolves at most once per -gated provider for measurement and fingerprints. Each later Compact body evaluation resolves at most once, immediately -projects that one result, and passes the projection to `CompactOverviewRowContent`. The first render may therefore make -a second resolver call after construction; that is intentional and keeps Observation dependencies current. Tests -assert one call within each transaction, not one call across the entire menu-build-plus-render lifecycle. - -Existing compatible-layout rules remain authoritative. A structural metric-shape change may require the existing menu -rebuild path; Compact Overview should not weaken those guards. Compatible tracked updates cannot change metric ID, -title, percent style, or bar-versus-status shape. Any such change is rejected for in-place display and the subsequent -structural rebuild recomputes projections and shared columns for the entire rendered set. Provider-name or localization -changes likewise invalidate rendered content; a localized metric-title change also recomputes the shared metric/bar -columns rather than changing one row's widths in place. - -Add a Compact-specific compatibility guard for the fallback layout signature. A change between loading, status, and -generic, or a change in the selected status metric ID/title, is structurally incompatible and forces the same -whole-menu rebuild so shared columns are recomputed. A trimmed status value may update in place when the selected -status metric and fallback kind stay the same; the fixed one-line span makes that a content-only change. - -Keep model resolution separate from the pure projection. A focused resolver test should inject a monitor/resolver that -returns distinguishable values on successive calls, then prove one compact projection/update transaction resolves -once and produces one coherent row. Do not assert how often SwiftUI evaluates `body`. - -Manual refresh deliberately inherits `MenuCardRefreshMonitor.model(for:fallback:)` freezing. While a provider has -active manual-refresh work, Compact keeps rendering the compatible frozen pre-refresh bars; it does not show the -subtitle monitor's synthetic `Refreshing…` text because Compact has no subtitle. Compute the initial lane signature, -no-bar fallback, shared-column inputs, and height fingerprint from the monitor-resolved or frozen `layoutModel`, not -from the fallback model. The metric-subset compatibility path can intentionally return a frozen model with more lanes -than the rebuilding fallback, and its measured height must win. - -Every monitor result used here must come from the same finished `menuCardModel(for:)` path as the fallback and therefore -already include `hidePersonalInfo` redaction. Compact must not install a raw-snapshot resolver. - -### Open-menu rebuilding - -Adding the setting to `menuObservationToken` invalidates cached menus, but it is not sufficient for a menu that is -already open. - -Track the last observed compact-layout value beside `lastMergeIcons`, `lastSwitcherShowsIcons`, and -`lastObservedUsageBarsShowUsed` in `StatusItemController`. A change must make -`shouldRefreshOpenMenusForProviderSwitcher()` return true so the open merged menu receives a safe structural rebuild. - -### Height cache - -Detailed and compact rows must not share a measured-height fingerprint. Use a style-specific section or include the -style as an explicit fingerprint field, for example: - -```swift -section: style == .compact ? "overviewCompact" : "overviewDetailed" -``` - -Renaming Detailed's current section from `"overview"` to `"overviewDetailed"` changes cache identity only; it is not a -visual or interaction change. Detailed mode keeps storage text in its fingerprint. Compact mode omits storage from both -its visible content and height fingerprint. This prevents a cached detailed height from leaving blank space around -compact content after the toggle changes. - -For Compact, use `UsageMenuCardView.Model.heightFingerprint` on the resolved `layoutModel`, which already includes -metric shape and localization. Detailed continues using its current model-plus-storage fingerprint under the renamed -section. The existing cache key already includes menu width and resolved text scale. Add the style, compact -drawable-lane or fallback-layout signature, and shared-column-layout signature as explicit Compact fingerprint inputs. -Although GPU Overview hosts are built fresh rather than recycled, explicit height-cache entries survive menu -invalidation and remain provider-scoped; a peer provider can therefore change shared columns without changing this -provider's base model. - -Test peer-selection column changes, bar ↔ status, one-lane, two-lane, frozen-subset layout, and -Detailed → Compact → Detailed keys. Re-entering Detailed may safely reuse its original detailed height. Keep the -existing model compatibility rule that treats `statusText` nilness as a structural difference. - -### Privacy and provider isolation - -Compact mode is more private by construction because it removes inline account and plan identity. `hidePersonalInfo` -still applies: compact projection starts from the already-redacted `UsageMenuCardView.Model`, so embedded emails in -metric titles or status fallback text remain protected even when the setting causes no obvious visual difference. No -provider may borrow identity, plan, tint, metrics, or fallback text from a different provider. - -No new logging or telemetry is needed. - -## Edge cases - -| Case | Required behavior | -| --- | --- | -| More than six providers enabled | Keep the existing configured Overview subset and six-provider cap | -| Provider has one drawable metric | Show one provider header followed by one metric lane | -| Provider has several drawable metrics | Show one provider header followed by every drawable lane in model order | -| Some metrics have `statusText` instead of a bar | Omit those metrics while rendering every drawable metric | -| Provider is loading and has no drawable metrics | Show the provider header followed by the localized generic loading fallback | -| Provider has only text-status metrics | Show the provider header followed by the first non-empty status metric in model order | -| Provider has dashboard, notes, credits, cost, or no data but no drawable/status metric | Show the provider header followed by `overview_compact_no_bars` | -| Provider is error-only at build time | Apply the existing exclusion before the compact projection | -| Live values change without metric-shape change | Update all compact bar data from one monitor-resolved model | -| Live metric shape changes | Follow existing compatible-layout/rebuild behavior | -| Manual refresh is active | Keep compatible frozen bars; do not add a compact `Refreshing…` subtitle | -| Long or right-to-left localized names | Routine truncation is allowed at 310 points; preserve the shared metric/bar columns, 146-point bar, and full accessibility text | -| Used/remaining preference changes | Update bar fill and preserve full `percentStyle` value and used/remaining semantics in accessibility; do not add visible numeric text | -| Pace or marker settings change | Preserve existing bar rendering and menu invalidation behavior | -| Storage footprints enabled | Omit inline storage; retain the existing storage submenu fallback | -| A peer provider or Overview selection changes | Recompute one shared column layout and include its signature in compact height keys | -| Compact toggled while menu is open | Structurally rebuild the open Overview and use the correct cached height | -| Merge Icons disabled | Disable the control but retain its stored value | -| Accessibility or unusual metric count causes overflow | Preserve all bars and allow native menu scrolling | -| Refresh completes during a style rebuild | Main-actor rebuild rules keep updates on the current host and cache key | - -## Files expected to change - -Keep the implementation small and localized: - -- `Sources/CodexBar/SettingsStoreState.swift` -- `Sources/CodexBar/SettingsStore.swift` -- `Sources/CodexBar/SettingsStore+Defaults.swift` -- `Sources/CodexBar/SettingsStore+MenuObservation.swift` -- `Sources/CodexBar/PreferencesMenuBarPane.swift` to remove the toggle from Combined Icon -- `Sources/CodexBar/PreferencesMenuPane.swift` to add the toggle to Content -- `Sources/CodexBar/StatusItemController.swift` -- `Sources/CodexBar/StatusItemController+Menu.swift` -- `Sources/CodexBar/StatusItemController+MenuWidthCache.swift` to update the Compact width contribution if required -- `Sources/CodexBar/StatusItemController+MenuTypes.swift` -- an optional focused compact projection/layout file under `Sources/CodexBar` -- `Sources/CodexBar/Resources/*.lproj/Localizable.strings` -- focused tests under `Tests/CodexBarTests` -- `docs/ui.md` with the revised behavior - -Do not edit `CHANGELOG.md` as part of feature implementation. - -## Test plan - -### Pure compact projection - -Add `Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift` and cover: - -- every drawable metric is retained in model order; -- text-only metrics are omitted; -- loading, first-status-metric, and generic no-bars fallback precedence is deterministic; -- status-only Mistral/DeepSeek-style, all-unlimited MiniMax-style, and dashboard-only OpenAI-style fixtures produce an - informative single-line fallback rather than a bare name; -- whitespace-only status text is skipped, status fallback uses separate metric/status columns without hard-coded - punctuation, and loading is selected exactly by `subtitleStyle == .loading`; -- percent value/style, tint, pace, quota markers, and workday markers pass through unchanged; -- a source model populated with sentinel account, plan, subtitle, reset, detail, dashboard, notes, cost, and credit - strings, plus a detailed-row-only storage sentinel, produces compact projection/rendered accessibility output - containing none of those sentinels except an explicitly selected, already-redacted status fallback; -- no visible numeric percentage is projected or rendered while the bar retains the complete percentage and - used/remaining accessibility meaning; and -- full provider and metric strings survive projection unchanged. - -Cover fallback, 1-, 2-, 3-, 6-, and 12-lane projections. Assert there are no reserved blank lanes, no two-lane cap, -and no provider-name repetition; every drawable metric must survive in model order. - -Assert drawable-lane signature changes independently for ID-only replacement, reordering, title change, -`percentStyle` change, insertion/removal, and bar ↔ status change. Also assert each no-bar fallback kind includes the -specified layout fields without embedding raw IDs, provider names, metric titles, localized copy, or status values in -the key. Assert the shared-column signature likewise contains geometry only. Verify a status-value-only change keeps -the fallback layout signature stable while updating visible and accessible content. - -Make `CompactOverviewRowContent` accept only the compact projection and shared columns; absence of refresh, model, -storage, and unrelated rich-card fields from that leaf type is a compile-time and review invariant. Test live resolution -separately with an injected spy resolver. Assert that `usesLiveSubtitle == false` performs no resolution. For a live -model, return different sentinel values on successive calls, then assert separately that construction and one -projection/update transaction each perform at most one resolution and that each transaction produces one coherent -provider/tint/metric result. - -Add a manual-refresh fixture in which the monitor's compatible frozen model has more lanes than the rebuilding fallback; -measurement and fingerprinting must use the frozen lane shape, frozen values remain visibly projected, and Compact -contains no synthetic `Refreshing…` text. Run the live resolver with `hidePersonalInfo` enabled and assert the monitor -projection contains only already-redacted titles and fallback strings. - -Table-test the pure column allocator at the 310-point production minimum and at wider widths with short, long, German, -Polish, Persian or Arabic right-to-left, and Traditional Chinese ideal widths. Reject an input below 310. At 310 and -default text size, assert the exact 40-point outer padding, 112-point metric cap, 12-point metric-to-bar gap, 12-point -header chevron gutter, and 146-point bar floor. Assert provider-title widths do not affect the shared layout. At wider widths, -assert all surplus goes to the shared bar. At a larger text scale, assert width/text-scale cache keys differ while the -112-point metric cap and 146-point bar floor remain intact. - -Changing a peer provider must change the shared layout only when its metric titles change a capped selected-set maximum -or another resolved column width. Provider-name-only changes must not change the shared layout. Cover uncapped metric -width change, above-cap no-op, provider removal, and the error-only filter so an excluded provider never influences -columns. - -Use the DEBUG geometry probe against at least two separately hosted compact rows to prove each provider header occupies -its own line and their metric and bar frames align. Cover logical leading/trailing behavior, submenu and no-submenu -rows, header-aligned chevrons, and assert no content enters the chevron gutter. Separately assert visual labels stay -single-line, no visible numeric percentage view exists, drawable lanes are non-actionable accessibility children in -model order without duplicate visual text nodes, and a no-bar row exposes the specified parent label plus -fallback-child label/value fields. The accessibility representation must retain full provider and metric strings, -used/remaining semantics, percentage, fallback, and existing marker semantics without a synthesized English-order -phrase. Exercise the hierarchy under an Arabic or Persian locale. Keep pixel screenshots out of CI. - -### Settings and observation - -Extend `SettingsStoreTests.swift` to prove: - -- the default is detailed (`false`); -- compact mode round-trips through a second `SettingsStore` using the same suite; -- changing it fires the menu observation callback; and -- changing it leaves background-work, provider-fetch scheduling, config, cost/widget display, and provider-detail - revisions unchanged. - -Extend localization-catalog coverage to require `overview_compact_title`, `overview_compact_subtitle`, and -`overview_compact_no_bars` in all 23 complete catalogs with non-empty values. PR review must confirm the 22 non-English -catalog values are translations rather than copied English placeholders. - -Extend `PreferencesPaneSmokeTests.swift` to construct the Menu pane with both values. Cover the toggle binding, its -placement in the Content section rather than the Menu Bar pane, disabled state while Merge Icons is off, and value -retention across Merge Icons off → on. Use runtime visual proof for exact row placement rather than brittle SwiftUI -tree introspection. - -### Menu integration - -Cover both styles while preserving existing assertions: - -- same row count, provider order, and `overviewRow-` identities; -- same click and keyboard actions; -- same detail submenus and storage fallback; -- same GPU-selection host and scroll targeting; -- Compact passes `containsInteractiveControls: false`, while Detailed retains its existing expression; -- Compact skips `storageFootprintText(for:)` without losing the independently constructed storage submenu fallback; -- a programmatic setting change while the menu is tracking replaces detailed content with compact content in both - directions; -- a refresh delivered around that rebuild updates only the current host/model/cache key; -- detailed and compact height fingerprints cannot collide, while peer metric/bar geometry, fallback, bar ↔ status, frozen-subset, - and one- versus two-lane compact layouts also differ; and -- a fallback-kind or selected-status-metric change causes a whole-menu rebuild, while a value-only change for the same - selected status metric updates in place; and -- a representative compact row measures shorter than the unchanged detailed branch for the same fixture. - -Also cover zero selected providers, more than six enabled providers, mixed drawable/text-only metrics, bar ↔ status -shape changes, and an overflow fixture that preserves every lane. Preserve existing viewport behavior; this feature -does not add a new pixel- or provider-anchor policy for a setting normally changed while the menu is closed. - -Prefer pure projection and state seams. Keep AppKit coverage focused on the wiring that cannot be proven otherwise. - -### Validation commands - -During implementation, run the fastest focused checks first: - -```bash -swift test --filter OverviewMenuCardRowViewTests -swift test --filter SettingsStoreTests -swift test --filter PreferencesPaneSmokeTests -swift test --filter CompactOverviewMenuIntegrationTests -swift test --filter StatusMenuOverviewScrollTests -swift test --filter "StatusMenuTests.*overview" -make check -``` - -Name the new integration suite `CompactOverviewMenuIntegrationTests`. Validation notes must record the tests selected -and executed so a filter that accidentally matches zero tests cannot pass unnoticed. - -Before submitting a PR, run the repository's full `make test` suite. The focused tests must use synthetic snapshots, -stub stores, and `KeychainNoUIQuery`-safe paths; do not run live provider or browser-cookie probes. - -### Runtime proof - -Because the product goal is spatial and an `NSMenuTrackingSession` cannot be fully proven by unit tests, validate a -freshly built bundle in the project's supported macOS UI environment. Use an existing local synthetic/debug injection -if one is available. If not, keep the fixture in a test harness or DEBUG-only launch path that cannot ship as a -production provider or fetch path and that disables real probes. - -Add or reuse a DEBUG/test-only numeric measurement seam that reports: - -- resolved menu width and display visible-frame height; -- full menu intrinsic content height; -- attached clip-view height; -- vertical scroll range, calculated as `max(0, documentHeight - viewportHeight)`; -- ordered item identifiers and measured heights; and -- the ordered provider/lane IDs present in the menu. - -The canonical fit fixture passes when resolved width is exactly 310 points, each provider header occupies its own line, -one-lane items are shorter than two-lane items, every one-lane item is at most 54 points tall, each additional lane adds -at most 20 points, the six compact items total at most 432 points, intrinsic height is at most 680 points, vertical -scroll range is at most 24 points, and all twelve expected lane IDs are present. The overflow fixture passes when every -expected lane ID is present and vertical scroll range exceeds the canonical allowance. - -The canonical harness must configure the production width inputs and then observe the attached menu's resolved width; -it must not force a DEBUG-only 310-point frame after layout. If AppKit or a standard item widens the canonical fixture, -the exact-width assertion fails and the fixture, width calculation, or 310-point contract must be corrected. - -1. Configure the exact canonical fixture from the Fit contract using local synthetic data. -2. Record the actual OS build, resolved width, visible-frame height, per-item heights, and all other measured fit values - required by the contract. -3. Capture Detailed and Compact Overview at the same display size and text settings. -4. Prove Compact shows all twelve bars plus the six dedicated provider headers, switcher, and standard footer, stays - within 680 points, and has at most 24 points of vertical scroll range. -5. Use an in-process test hook to change the preference while the menu is tracking and verify both style directions, - current-host refresh, and correct row heights. -6. Run a response-sized overflow fixture with enough additional lanes to force scrolling; prove every lane remains and - native scrolling works. -7. Click and keyboard-activate rows, open representative usage/cost/storage submenus, and exercise trackpad and wheel - navigation. -8. Repeat in light and dark appearance and with personal-info hiding enabled. -9. Capture redacted screenshots or a short recording for the PR. - -Use `./Scripts/compile_and_run.sh` only for this final bundle-level UI validation, after focused tests and -`make check` pass. - -## Implementation plan - -1. Add the default-off persisted Boolean, menu observation, and Menu-pane toggle. -2. Add the pure compact-row projection and focused tests for inclusion, order, no-bar fallback, semantics, and full - source strings. -3. Add the shared metric/bar column allocator, fixed chevron gutter, minimum-width budget tests, and hosted geometry - probe. -4. Add the compact SwiftUI row using the existing progress renderer and one gated monitor-resolved live model. -5. Pass the selected row style, resolved layout models, and shared columns through `addOverviewRows`; preserve row - identity, set Compact's embedded-control flag false, and skip only Compact's inline storage lookup. -6. Add open-menu structural refresh tracking and style-, rendered-shape-, and shared-column-specific height - fingerprints. -7. Extend Overview interaction, frozen-refresh, submenu, settings, all-catalog localization, accessibility, and overflow - tests. -8. Run focused checks, `make check`, then the freshly built bundle UI proof with per-item measurements. -9. Update `docs/ui.md`; run `make test` immediately before PR submission. - -## Acceptance criteria - -The feature is ready when: - -- Detailed Overview remains the default, uses the existing detailed view composition, and has no intentional visual or - interaction change. -- The setting persists, appears in Preferences → Menu → Content rather than the Menu Bar pane, is disabled without - clearing while Merge Icons is off, and is contextual to the merged Overview UI. -- Changing the setting has no provider-fetch, background-work, config, widget, account, or provider-detail side - effects. -- Compact Overview shows every provider remaining after the existing error-only filter and every drawable metric bar - for those providers in model order. -- Compact mode contains none of the excluded rich-card content. -- Loading, status-only, and other no-bar providers use the specified visible, accessible fallback; existing error-only - filtering remains unchanged. -- In the canonical English/default-text-size fixture at a resolved width of exactly 310 points, every provider has one - dedicated header row and every item shares one metric/bar column layout. Labels truncate within their caps, bars - remain at least 146 points wide and eight points tall, no numeric percentage is visible, the chevron gutter remains - unobstructed, and accessibility keeps the full strings and percentage/marker semantics. Larger resolved text may - increase item height but keeps the fixed metric cap and bar floor. -- The canonical mixed-cardinality `1, 1, 2, 2, 3, 3` six-provider/twelve-bar fixture has one-lane items no taller than - 54 points, additional lanes adding at most 20 points each, compact items totaling at most 432 points, a complete menu - within 680 points, at most 24 points of vertical scroll range, all twelve expected lane IDs, and redacted visual proof - plus recorded per-item measurements. -- Every drawable bar is retained for 0/1/2/3+ and response-sized metric sets. When completeness and fit conflict, - Compact uses native vertical scrolling rather than truncation, collapsing, ranking, or a per-provider bar cap. -- Live refresh mirrors the existing gate, never mixes stale and current row state, and preserves compatible frozen bars - during manual refresh. -- A setting change during menu tracking rebuilds in both directions, survives a concurrent refresh, and cannot reuse a - detailed-row, wrong-lane-count, wrong-fallback, frozen-shape, or peer-metric-geometry height. -- Existing click, keyboard, submenu, GPU highlight, scroll, viewport, provider-order, and selection behavior remains - green; Compact alone disables embedded-control hit testing. -- `overview_compact_title`, `overview_compact_subtitle`, and `overview_compact_no_bars` are present in all 23 complete - locale catalogs, with reviewed non-English translations rather than English placeholders. -- Focused tests, `make check`, and the pre-PR full suite pass without Keychain prompts. - -## Approved owner decisions - -| Decision | Recommendation | Alternative and cost | -| --- | --- | --- | -| Default layout | Detailed | Defaulting compact changes every existing installation's menu | -| Setting shape | Boolean toggle | An enum/picker adds ceremony without a third approved layout | -| Provider scope | Existing selected six | “Every enabled provider” requires a separate selection-model redesign | -| Visible bar text | Short metric label only; the bar retains percentage and used/remaining semantics in accessibility | A visible percentage reduces bar width and made the first implementation feel too small | -| Metric completeness | Keep every drawable metric | Capping bars contradicts the request and hides quota windows | -| Overflow policy | Allow native scrolling in exceptional cases | Dropping data or shrinking below platform norms is misleading | -| Provider presentation | One dedicated header row inside each actionable provider item, with metric lanes below | A provider column competes with metric and bar width and made the item feel cramped | -| Cross-row geometry | One shared selected-set metric/bar layout with fixed cap and chevron gutter | Per-host ideal sizing cannot align independent `NSMenuItem` roots | -| Non-bar providers | One muted status/loading/no-bars fallback below the provider header | A bare provider name reads as an unfinished row; hiding it changes the configured subset | - -These approved decisions make this document the bounded implementation contract. diff --git a/docs/ui.md b/docs/ui.md index de09061df9..1ba466bfe7 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -14,17 +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. -- Compact Overview is an opt-in presentation for the merged menu. Each actionable provider item has a dedicated - provider-name header row, followed by every drawable usage bar in model order; one, two, or many bars produce matching - item heights. Providers without a drawable bar show a single loading, status, or “No usage bars” fallback beneath the - header instead of a blank item. -- Compact items share one metric-label/bar layout across the selected providers, using body-sized metric labels and - eight-point progress bars. Provider headers sit outside that grid and span the content width. Every item reserves the - same trailing chevron gutter in its header, including items without a submenu; metric lanes use the full width below - it. Visible numeric percentages are omitted; each progress bar retains its percentage and used-versus-left - accessibility semantics. Long labels truncate visually - while preserving their full accessibility text. Compact mode keeps the same provider actions, detail submenus, - separators, refresh behavior, and native menu scrolling as Detailed Overview. +- 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 @@ -99,8 +98,8 @@ window has elapsed. reports sizes and cleanup ideas, it does not delete files. - Menu Bar → Combined Icon: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 6). -- Menu → Content: “Compact Overview” chooses the provider-header-and-bars presentation. It is disabled while Merge - Icons is off, retains its stored value, and defaults off so existing Detailed Overview behavior is unchanged. +- 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. From 6d2a06963889c1f82d00f5d5d7271112dc987f06 Mon Sep 17 00:00:00 2001 From: Trim Date: Mon, 3 Aug 2026 11:34:48 -0400 Subject: [PATCH 3/4] Address compact Overview review feedback --- Sources/CodexBar/CompactOverviewRow.swift | 44 +++-- .../Resources/ar.lproj/Localizable.strings | 2 - .../Resources/ca.lproj/Localizable.strings | 2 - .../Resources/de.lproj/Localizable.strings | 2 - .../Resources/en.lproj/Localizable.strings | 2 - .../Resources/es.lproj/Localizable.strings | 2 - .../Resources/fa.lproj/Localizable.strings | 2 - .../Resources/fr.lproj/Localizable.strings | 2 - .../Resources/gl.lproj/Localizable.strings | 2 - .../Resources/id.lproj/Localizable.strings | 2 - .../Resources/it.lproj/Localizable.strings | 2 - .../Resources/ja.lproj/Localizable.strings | 2 - .../Resources/ko.lproj/Localizable.strings | 2 - .../Resources/nl.lproj/Localizable.strings | 2 - .../Resources/pl.lproj/Localizable.strings | 2 - .../Resources/pt-BR.lproj/Localizable.strings | 2 - .../Resources/ru.lproj/Localizable.strings | 2 - .../Resources/sv.lproj/Localizable.strings | 2 - .../Resources/th.lproj/Localizable.strings | 2 - .../Resources/tr.lproj/Localizable.strings | 2 - .../Resources/uk.lproj/Localizable.strings | 2 - .../Resources/vi.lproj/Localizable.strings | 2 - .../zh-Hans.lproj/Localizable.strings | 2 - .../zh-Hant.lproj/Localizable.strings | 2 - ...StatusItemController+CompactOverview.swift | 6 +- .../StatusItemController+MenuCardItems.swift | 2 + ...tatusItemController+MenuPresentation.swift | 82 +++++++- .../StatusItemController+MenuTypes.swift | 18 +- Sources/CodexBar/StatusItemController.swift | 78 ++++---- .../CompactOverviewMenuIntegrationTests.swift | 175 +++++++++++++++++- .../CompactOverviewProjectionTests.swift | 20 +- .../LocalizationLanguageCatalogTests.swift | 2 - 32 files changed, 348 insertions(+), 125 deletions(-) diff --git a/Sources/CodexBar/CompactOverviewRow.swift b/Sources/CodexBar/CompactOverviewRow.swift index 404df08534..8fd288ae3e 100644 --- a/Sources/CodexBar/CompactOverviewRow.swift +++ b/Sources/CodexBar/CompactOverviewRow.swift @@ -13,9 +13,13 @@ struct CompactOverviewProjection { let warningMarkerPercents: [Double] let workdayMarkerPercents: [Double] - var accessibilityLabel: String { + var barAccessibilityLabel: String { self.percentStyle.accessibilityLabel } + + var accessibilitySummary: String { + "\(self.title), \(UsageFormatter.percentText(self.percent, suffix: self.percentStyle.labelSuffix))" + } } enum Fallback { @@ -44,6 +48,13 @@ struct CompactOverviewProjection { 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: @@ -69,6 +80,18 @@ struct CompactOverviewProjection { 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…"), @@ -161,8 +184,8 @@ struct CompactOverviewLayout { static let labeledMetricContentSpacing: CGFloat = 6 static let providerBarsLaneSpacing: CGFloat = UsageMenuCardLayout.metricSpacing static let barsOnlyLaneSpacing: CGFloat = UsageMenuCardLayout.metricSpacing - static let barsOnlyInterProviderSpacing: CGFloat = 18 - static let barsOnlySectionOuterSpacing: CGFloat = UsageMenuCardLayout.metricSpacing + static let barsOnlyInterProviderSpacing: CGFloat = 24 + static let barsOnlySectionOuterSpacing: CGFloat = 15 static var barsOnlyVerticalPadding: CGFloat { (self.barsOnlyInterProviderSpacing - MenuCardItemSizing.measuredHeightPadding) / 2 } @@ -201,19 +224,14 @@ struct CompactOverviewLayout { menuWidth: CGFloat, layoutDirection: LayoutDirection) -> Self { - assert( - menuWidth >= self.minimumMenuWidth, - "Compact Overview menu width must be at least \(self.minimumMenuWidth) points") - precondition(menuWidth >= self.minimumMenuWidth) - precondition(self.barsOnlyVerticalPadding >= 0) - precondition(self.barsOnlySectionSpacerHeight >= 0) + 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(menuWidth))", + "menu=\(Self.geometryToken(resolvedMenuWidth))", "gutter=\(Self.geometryToken(Self.chevronGutterWidth))", "padding=\(Self.geometryToken(Self.horizontalPadding))", "barHeight=\(Self.geometryToken(Self.barHeight))", @@ -231,7 +249,7 @@ struct CompactOverviewLayout { "direction=\(direction)", ]) return Self( - menuWidth: menuWidth, + menuWidth: resolvedMenuWidth, layoutDirection: layoutDirection, signature: signature) } @@ -317,7 +335,7 @@ private struct CompactOverviewLabeledMetric: View { UsageProgressBar( percent: self.lane.percent, tint: self.lane.tint, - accessibilityLabel: self.lane.accessibilityLabel, + accessibilityLabel: self.lane.barAccessibilityLabel, pacePercent: self.lane.pacePercent, paceOnTop: self.lane.paceOnTop, warningMarkerPercents: self.lane.warningMarkerPercents, @@ -447,7 +465,7 @@ private struct CompactOverviewBareBarLane: View { UsageProgressBar( percent: self.lane.percent, tint: self.lane.tint, - accessibilityLabel: self.lane.accessibilityLabel, + accessibilityLabel: self.lane.barAccessibilityLabel, pacePercent: self.lane.pacePercent, paceOnTop: self.lane.paceOnTop, warningMarkerPercents: self.lane.warningMarkerPercents, diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 313a159c30..6c898ca36c 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -628,8 +628,6 @@ "overview_layout_compact" = "المزوّدون والمقاييس والأشرطة"; "overview_layout_provider_bars" = "المزوّدون والأشرطة"; "overview_layout_bars_only" = "الأشرطة فقط"; -"overview_compact_title" = "نظرة عامة مدمجة"; -"overview_compact_subtitle" = "اعرض أسماء المزوّدين وأشرطة الاستخدام بتنسيق موفّر للمساحة."; "overview_compact_no_bars" = "لا توجد أشرطة استخدام"; "configure" = "تكوين..."; "overview_enable_merge_icons_hint" = "تفعيل أيقونات الدمج لتكوين مزودي تبويب النظرة العامة."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 6241e29b70..a4afd99472 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -606,8 +606,6 @@ "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_title" = "Resum compacte"; -"overview_compact_subtitle" = "Mostra els noms dels proveïdors i les barres d’ús en un disseny que estalvia espai."; "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."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 9892b02ab5..9c055d7040 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -620,8 +620,6 @@ "overview_layout_compact" = "Anbieter, Metriken und Balken"; "overview_layout_provider_bars" = "Anbieter und Balken"; "overview_layout_bars_only" = "Nur Balken"; -"overview_compact_title" = "Kompakte Übersicht"; -"overview_compact_subtitle" = "Zeigt Anbieternamen und Nutzungsbalken in einem platzsparenden Layout."; "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."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 075729f403..681099e5ce 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -605,8 +605,6 @@ "overview_layout_compact" = "Providers, metrics & bars"; "overview_layout_provider_bars" = "Providers & bars"; "overview_layout_bars_only" = "Bars only"; -"overview_compact_title" = "Compact Overview"; -"overview_compact_subtitle" = "Show provider names and usage bars in a space-saving layout."; "overview_compact_no_bars" = "No usage bars"; "configure" = "Configure…"; "overview_enable_merge_icons_hint" = "Turn on Merge icons to configure Overview providers."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 374722458e..28f07c4fa2 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -614,8 +614,6 @@ "overview_layout_compact" = "Proveedores, métricas y barras"; "overview_layout_provider_bars" = "Proveedores y barras"; "overview_layout_bars_only" = "Solo barras"; -"overview_compact_title" = "Resumen compacto"; -"overview_compact_subtitle" = "Muestra los nombres de los proveedores y las barras de uso en un diseño compacto."; "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."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 6dea57322b..a3dd95bf64 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -628,8 +628,6 @@ "overview_layout_compact" = "ارائه‌دهندگان، معیارها و نوارها"; "overview_layout_provider_bars" = "ارائه‌دهندگان و نوارها"; "overview_layout_bars_only" = "فقط نوارها"; -"overview_compact_title" = "نمای کلی فشرده"; -"overview_compact_subtitle" = "نام ارائه‌دهندگان و نوارهای مصرف را در چیدمانی کم‌جا نشان می‌دهد."; "overview_compact_no_bars" = "نوار مصرفی وجود ندارد"; "configure" = "پیکربندی کن..."; "overview_enable_merge_icons_hint" = "فعال سازی Merge Icons برای پیکربندی ارائه دهندگان تب نمای کلی."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 8f2633fbfd..83b41f803e 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -622,8 +622,6 @@ "overview_layout_compact" = "Fournisseurs, métriques et barres"; "overview_layout_provider_bars" = "Fournisseurs et barres"; "overview_layout_bars_only" = "Barres uniquement"; -"overview_compact_title" = "Vue d’ensemble compacte"; -"overview_compact_subtitle" = "Affichez les noms des fournisseurs et les barres d’utilisation dans une disposition compacte."; "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."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index b8e5c2da66..2573480f7a 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -601,8 +601,6 @@ "overview_layout_compact" = "Provedores, métricas e barras"; "overview_layout_provider_bars" = "Provedores e barras"; "overview_layout_bars_only" = "Só barras"; -"overview_compact_title" = "Resumo compacto"; -"overview_compact_subtitle" = "Mostra os nomes dos provedores e as barras de uso nun deseño que aforra espazo."; "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."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index fa3e739b25..2a5692c744 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -630,8 +630,6 @@ "overview_layout_compact" = "Penyedia, metrik, dan bilah"; "overview_layout_provider_bars" = "Penyedia dan bilah"; "overview_layout_bars_only" = "Hanya bilah"; -"overview_compact_title" = "Ikhtisar ringkas"; -"overview_compact_subtitle" = "Tampilkan nama penyedia dan bilah penggunaan dalam tata letak hemat ruang."; "overview_compact_no_bars" = "Tidak ada bilah penggunaan"; "configure" = "Konfigurasi…"; "overview_enable_merge_icons_hint" = "Aktifkan Gabung Ikon untuk mengonfigurasi penyedia tab Ikhtisar."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index be9c4eec42..70cddcc969 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -630,8 +630,6 @@ "overview_layout_compact" = "Provider, metriche e barre"; "overview_layout_provider_bars" = "Provider e barre"; "overview_layout_bars_only" = "Solo barre"; -"overview_compact_title" = "Panoramica compatta"; -"overview_compact_subtitle" = "Mostra i nomi dei provider e le barre di utilizzo in un layout salvaspazio."; "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."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index cfe26657c3..3fbf621cda 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -619,8 +619,6 @@ "overview_layout_compact" = "プロバイダー、指標、バー"; "overview_layout_provider_bars" = "プロバイダーとバー"; "overview_layout_bars_only" = "バーのみ"; -"overview_compact_title" = "コンパクトな概要"; -"overview_compact_subtitle" = "プロバイダ名と使用量バーを省スペースのレイアウトで表示します。"; "overview_compact_no_bars" = "使用量バーはありません"; "configure" = "設定…"; "overview_enable_merge_icons_hint" = "概要タブのプロバイダを設定するには「アイコンを統合」を有効にしてください。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 1d2ba11a70..dca09ef579 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -611,8 +611,6 @@ "overview_layout_compact" = "제공자, 지표 및 막대"; "overview_layout_provider_bars" = "제공자 및 막대"; "overview_layout_bars_only" = "막대만"; -"overview_compact_title" = "간결한 개요"; -"overview_compact_subtitle" = "공급자 이름과 사용량 막대를 공간 절약형 레이아웃으로 표시합니다."; "overview_compact_no_bars" = "사용량 막대 없음"; "configure" = "구성…"; "overview_enable_merge_icons_hint" = "개요 탭 공급자를 구성하려면 아이콘 병합을 사용하세요."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 507747da25..fc6494a1d3 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -622,8 +622,6 @@ "overview_layout_compact" = "Providers, metrieken en balken"; "overview_layout_provider_bars" = "Providers en balken"; "overview_layout_bars_only" = "Alleen balken"; -"overview_compact_title" = "Compact overzicht"; -"overview_compact_subtitle" = "Toon providernamen en gebruiksbalken in een ruimtebesparende indeling."; "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."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 36d2bb887a..75ab54f2ba 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -630,8 +630,6 @@ "overview_layout_compact" = "Dostawcy, metryki i paski"; "overview_layout_provider_bars" = "Dostawcy i paski"; "overview_layout_bars_only" = "Tylko paski"; -"overview_compact_title" = "Kompaktowy przegląd"; -"overview_compact_subtitle" = "Pokazuj nazwy dostawców i paski użycia w układzie oszczędzającym miejsce."; "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."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index ae8fbac489..ff563397d1 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -619,8 +619,6 @@ "overview_layout_compact" = "Provedores, métricas e barras"; "overview_layout_provider_bars" = "Provedores e barras"; "overview_layout_bars_only" = "Somente barras"; -"overview_compact_title" = "Visão geral compacta"; -"overview_compact_subtitle" = "Mostre nomes de provedores e barras de uso em um layout que economiza espaço."; "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."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a2551bd50f..4d8ca0b7d3 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -623,8 +623,6 @@ "overview_layout_compact" = "Провайдеры, показатели и индикаторы"; "overview_layout_provider_bars" = "Провайдеры и индикаторы"; "overview_layout_bars_only" = "Только индикаторы"; -"overview_compact_title" = "Компактный обзор"; -"overview_compact_subtitle" = "Показывать названия провайдеров и индикаторы использования в компактном виде."; "overview_compact_no_bars" = "Нет индикаторов использования"; "configure" = "Настроить…"; "overview_enable_merge_icons_hint" = "Включите «Объединять значки», чтобы настроить провайдеров вкладки «Обзор»."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 25933415d3..2e9d05ca56 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -621,8 +621,6 @@ "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_title" = "Kompakt översikt"; -"overview_compact_subtitle" = "Visa leverantörsnamn och användningsstaplar i en utrymmessnål layout."; "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."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index efe3edef8f..f007930a90 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -628,8 +628,6 @@ "overview_layout_compact" = "ผู้ให้บริการ เมตริก และแถบ"; "overview_layout_provider_bars" = "ผู้ให้บริการและแถบ"; "overview_layout_bars_only" = "เฉพาะแถบ"; -"overview_compact_title" = "ภาพรวมแบบกะทัดรัด"; -"overview_compact_subtitle" = "แสดงชื่อผู้ให้บริการและแถบการใช้งานในรูปแบบที่ประหยัดพื้นที่"; "overview_compact_no_bars" = "ไม่มีแถบการใช้งาน"; "configure" = "กําหนดค่า..."; "overview_enable_merge_icons_hint" = "เปิดใช้งานไอคอนผสานเพื่อกําหนดค่าผู้ให้บริการแท็บภาพรวม"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index a1a8d04525..93b03e5e2b 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -628,8 +628,6 @@ "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_title" = "Kompakt Genel Bakış"; -"overview_compact_subtitle" = "Sağlayıcı adlarını ve kullanım çubuklarını yerden tasarruf eden bir düzende gösterin."; "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."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index ad252338b5..1664c7eed0 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -622,8 +622,6 @@ "overview_layout_compact" = "Провайдери, показники й смуги"; "overview_layout_provider_bars" = "Провайдери й смуги"; "overview_layout_bars_only" = "Лише смуги"; -"overview_compact_title" = "Компактний огляд"; -"overview_compact_subtitle" = "Показувати назви постачальників і смуги використання в компактному компонуванні."; "overview_compact_no_bars" = "Немає смуг використання"; "configure" = "Налаштувати…"; "overview_enable_merge_icons_hint" = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index d2b00cb8b2..0c116d433e 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -618,8 +618,6 @@ "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_title" = "Tổng quan thu gọn"; -"overview_compact_subtitle" = "Hiển thị tên nhà cung cấp và thanh mức sử dụng trong bố cục tiết kiệm không gian."; "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."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 6aa60dc1b6..8c041df437 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -601,8 +601,6 @@ "overview_layout_compact" = "提供商、指标和使用量条"; "overview_layout_provider_bars" = "提供商和使用量条"; "overview_layout_bars_only" = "仅使用量条"; -"overview_compact_title" = "紧凑概览"; -"overview_compact_subtitle" = "以节省空间的布局显示提供商名称和使用量条。"; "overview_compact_no_bars" = "无使用量条"; "configure" = "配置…"; "overview_enable_merge_icons_hint" = "启用“合并图标”以配置“概览”标签中的提供商。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 61e62d5b57..83a0f9fc5e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -621,8 +621,6 @@ "overview_layout_compact" = "供應商、指標與用量列"; "overview_layout_provider_bars" = "供應商與用量列"; "overview_layout_bars_only" = "僅用量列"; -"overview_compact_title" = "精簡概覽"; -"overview_compact_subtitle" = "以節省空間的版面顯示提供者名稱和用量列。"; "overview_compact_no_bars" = "沒有用量列"; "configure" = "設定…"; "overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; diff --git a/Sources/CodexBar/StatusItemController+CompactOverview.swift b/Sources/CodexBar/StatusItemController+CompactOverview.swift index aeae4079cb..6273997eed 100644 --- a/Sources/CodexBar/StatusItemController+CompactOverview.swift +++ b/Sources/CodexBar/StatusItemController+CompactOverview.swift @@ -87,7 +87,7 @@ extension StatusItemController { ?? "overviewBarsOnly:missing" } let accessibilityLabel = rowStyle.usesReducedContent - ? row.projection?.providerName ?? row.layoutModel.providerName + ? row.projection?.accessibilityLabel ?? row.layoutModel.providerName : row.model.providerName let item = self.makeMenuCardItem( OverviewMenuCardRowView( @@ -108,9 +108,13 @@ extension StatusItemController { 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 } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index ae83f6059e..f0bca4eee6 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -46,6 +46,7 @@ extension StatusItemController { usesGPUSelection: Bool = false, layoutDirection: LayoutDirection? = nil, accessibilityLabel: String? = nil, + accessibilityUserInputLabels: [String]? = nil, accessibilityHelp: String? = nil, onClick: (() -> Void)? = nil) -> NSMenuItem { @@ -75,6 +76,7 @@ extension StatusItemController { usesGPUSelection: usesGPUSelection, layoutDirection: layoutDirection, accessibilityLabel: accessibilityLabel, + accessibilityUserInputLabels: accessibilityUserInputLabels, accessibilityHelp: accessibilityHelp, onClick: onClick) let hosting: ErasedMenuCardHostingView diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index eaec317298..7a9fbf80af 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -142,10 +142,39 @@ struct MenuCardRowPayload { let allowsMenuHighlight: Bool let containsInteractiveControls: Bool let usesGPUSelection: Bool - let layoutDirection: LayoutDirection? = nil - let accessibilityLabel: String? = nil - let accessibilityHelp: String? = nil + 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, @@ -162,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() @@ -489,7 +527,8 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin payload: MenuCardRowPayload, highlightState: MenuCardHighlightState, refreshMonitor: MenuCardRefreshMonitor?, - interactiveRegionStore: MenuCardInteractiveRegionStore) -> MenuCardSectionContainerView + interactiveRegionStore: MenuCardInteractiveRegionStore, + accessibilityLabelStore: MenuCardAccessibilityLabelStore) -> MenuCardSectionContainerView { MenuCardSectionContainerView( highlightState: highlightState, @@ -498,14 +537,16 @@ final class MenuRowContainerView: NSView, MenuCardHighlighting, MenuCardMeasurin submenuIndicatorTopPadding: payload.submenuIndicatorTopPadding, layoutDirection: payload.layoutDirection, refreshMonitor: refreshMonitor, - interactiveRegionStore: interactiveRegionStore) + interactiveRegionStore: interactiveRegionStore, + accessibilityLabelStore: accessibilityLabelStore) { payload.content } } private func applyAccessibilityPayload() { - self.setAccessibilityLabel(self.rowPayload.accessibilityLabel) + self.accessibilityLabelStore.label = self.rowPayload.accessibilityLabel + self.setAccessibilityUserInputLabels(self.rowPayload.accessibilityUserInputLabels) self.setAccessibilityHelp(self.rowPayload.accessibilityHelp) } @@ -832,6 +873,7 @@ struct MenuCardSectionContainerView: View { let layoutDirectionOverride: LayoutDirection? var refreshMonitor: MenuCardRefreshMonitor? var interactiveRegionStore: MenuCardInteractiveRegionStore? + var accessibilityLabelStore: MenuCardAccessibilityLabelStore? @ViewBuilder let content: () -> Content @Environment(\.layoutDirection) private var inheritedLayoutDirection @@ -843,6 +885,7 @@ struct MenuCardSectionContainerView: View { layoutDirection: LayoutDirection? = nil, refreshMonitor: MenuCardRefreshMonitor?, interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, + accessibilityLabelStore: MenuCardAccessibilityLabelStore? = nil, @ViewBuilder content: @escaping () -> Content) { self.highlightState = highlightState @@ -852,6 +895,7 @@ struct MenuCardSectionContainerView: View { self.layoutDirectionOverride = layoutDirection self.refreshMonitor = refreshMonitor self.interactiveRegionStore = interactiveRegionStore + self.accessibilityLabelStore = accessibilityLabelStore self.content = content } @@ -863,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 { @@ -909,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+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index dbf92c5f3e..8649c16adc 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -110,21 +110,33 @@ struct OverviewMenuCardRowView: View { self.detailedContent case .compact: if let compactLayout = self.compactLayout { + let projection = self.compactProjection CompactOverviewLabeledContent( - projection: self.compactProjection, + projection: projection, layout: compactLayout) + .preference( + key: MenuCardAccessibilityLabelPreferenceKey.self, + value: projection.accessibilityLabel) } case .providerBars: if let compactLayout = self.compactLayout { + let projection = self.compactProjection CompactOverviewProviderBarsContent( - projection: self.compactProjection, + projection: projection, layout: compactLayout) + .preference( + key: MenuCardAccessibilityLabelPreferenceKey.self, + value: projection.accessibilityLabel) } case .barsOnly: if let compactLayout = self.compactLayout { + let projection = self.compactProjection CompactOverviewBarsOnlyContent( - projection: self.compactProjection, + projection: projection, layout: compactLayout) + .preference( + key: MenuCardAccessibilityLabelPreferenceKey.self, + value: projection.accessibilityLabel) } } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c54e3e2eb7..85de6859d6 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -667,44 +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 - } - let overviewObservation = MergedOverviewMenuObservation(settings: self.settings) - if overviewObservation != self.lastMergedOverviewMenuObservation { - self.lastMergedOverviewMenuObservation = overviewObservation - shouldRefresh = true - } - if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature { - shouldRefresh = true - } - return shouldRefresh - } - private func handleSettingsChange(reason: String) { #if DEBUG guard !self.isReleasedForTesting else { return } @@ -965,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/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift index a06be717fc..ee1a1e3e47 100644 --- a/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift +++ b/Tests/CodexBarTests/CompactOverviewMenuIntegrationTests.swift @@ -13,6 +13,66 @@ struct CompactOverviewMenuIntegrationTests { 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() } @@ -28,6 +88,11 @@ struct CompactOverviewMenuIntegrationTests { "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])) @@ -53,7 +118,10 @@ struct CompactOverviewMenuIntegrationTests { #expect(cursorRow.submenu == nil) #expect(try NSStringFromSelector(#require(cursorRow.action)) == "selectOverviewProvider:") #expect((cursorRow.target as AnyObject?) === fixture.controller) - #expect(cursorRow.view?.accessibilityLabel() == cursorModel.providerName) + #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] @@ -62,7 +130,10 @@ struct CompactOverviewMenuIntegrationTests { #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() == claudeModel.providerName) + #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) @@ -78,6 +149,85 @@ struct CompactOverviewMenuIntegrationTests { #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) @@ -133,7 +283,7 @@ struct CompactOverviewMenuIntegrationTests { #expect(barsOnlyHeight < providerBarsHeight) #expect(providerBarsHeight < compactHeight) #expect(compactHeight < detailedHeight) - let expectedBarsOnlyHeight: CGFloat = provider == .cursor ? 24 : 60 + let expectedBarsOnlyHeight: CGFloat = provider == .cursor ? 30 : 66 let expectedProviderBarsHeight: CGFloat = provider == .cursor ? 57 : 93 #expect(abs(barsOnlyHeight - expectedBarsOnlyHeight) <= 1) #expect(abs(providerBarsHeight - expectedProviderBarsHeight) <= 1) @@ -250,9 +400,9 @@ struct CompactOverviewMenuIntegrationTests { let cursorFirstHeight = try rowHeight(.cursor, in: threeProviderMenu) let codexInteriorHeight = try rowHeight(.codex, in: threeProviderMenu) let claudeLastHeight = try rowHeight(.claude, in: threeProviderMenu) - #expect(abs(cursorFirstHeight - 24) <= 1) - #expect(abs(codexInteriorHeight - 24) <= 1) - #expect(abs(claudeLastHeight - 24) <= 1) + #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) @@ -264,7 +414,7 @@ struct CompactOverviewMenuIntegrationTests { activeProviders: activeProviders) let twoProviderMenu = self.renderOverviewMenu(fixture.controller) let codexFirstHeight = try rowHeight(.codex, in: twoProviderMenu) - #expect(abs(codexFirstHeight - 24) <= 1) + #expect(abs(codexFirstHeight - 30) <= 1) #expect(Self.barsOnlySpacers(in: twoProviderMenu).count == 2) let firstFingerprints = fingerprints(.codex) #expect(firstFingerprints == interiorFingerprints) @@ -275,7 +425,7 @@ struct CompactOverviewMenuIntegrationTests { activeProviders: activeProviders) let oneProviderMenu = self.renderOverviewMenu(fixture.controller) let codexOnlyHeight = try rowHeight(.codex, in: oneProviderMenu) - #expect(abs(codexOnlyHeight - 24) <= 1) + #expect(abs(codexOnlyHeight - 30) <= 1) #expect(Self.barsOnlySpacers(in: oneProviderMenu).count == 2) let soleFingerprints = fingerprints(.codex) #expect(soleFingerprints == firstFingerprints) @@ -415,6 +565,15 @@ struct CompactOverviewMenuIntegrationTests { #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 } diff --git a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift index fa39ac8c86..01c9f16672 100644 --- a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift +++ b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift @@ -122,7 +122,10 @@ struct CompactOverviewProjectionTests { #expect(lane.title == "Full Metric Name") #expect(lane.percent == 0.4) #expect(lane.percentStyle.rawValue == UsageMenuCardView.Model.PercentStyle.used.rawValue) - #expect(lane.accessibilityLabel == L("Usage used")) + #expect(lane.barAccessibilityLabel == L("Usage used")) + #expect(lane.accessibilitySummary == "Full Metric Name, <1% used") + #expect(projection.accessibilitySummary == lane.accessibilitySummary) + #expect(projection.accessibilityLabel == "Full Provider Name. Full Metric Name, <1% used") #expect(lane.tint == tint) #expect(lane.pacePercent == 61) #expect(lane.paceOnTop == false) @@ -208,6 +211,9 @@ struct CompactOverviewProjectionTests { let wider = CompactOverviewLayout.resolveForMenu( menuWidth: 360, layoutDirection: .leftToRight) + let undersized = CompactOverviewLayout.resolveForMenu( + menuWidth: 200, + layoutDirection: .leftToRight) #expect(minimum.menuWidth == CompactOverviewLayout.minimumMenuWidth) #expect(minimum.contentWidth == 270) @@ -221,12 +227,14 @@ struct CompactOverviewProjectionTests { #expect(wider.labeledBarWidth == minimum.labeledBarWidth + 50) #expect(wider.providerBarsBarWidth == minimum.providerBarsBarWidth + 50) #expect(wider.barsOnlyBarWidth == minimum.barsOnlyBarWidth + 50) + #expect(undersized.menuWidth == CompactOverviewLayout.minimumMenuWidth) + #expect(undersized.signature == minimum.signature) #expect(CompactOverviewLayout.barHeight == UsageProgressBar.defaultHeight) #expect(CompactOverviewLayout.providerBarsLaneSpacing == 12) #expect(CompactOverviewLayout.barsOnlyLaneSpacing == 12) - #expect(CompactOverviewLayout.barsOnlyInterProviderSpacing == 18) - #expect(CompactOverviewLayout.barsOnlySectionOuterSpacing == 12) - #expect(CompactOverviewLayout.barsOnlyVerticalPadding == 5.5) + #expect(CompactOverviewLayout.barsOnlyInterProviderSpacing == 24) + #expect(CompactOverviewLayout.barsOnlySectionOuterSpacing == 15) + #expect(CompactOverviewLayout.barsOnlyVerticalPadding == 8.5) #expect(CompactOverviewLayout.barsOnlySectionSpacerHeight == 3) let interProviderSpacing = MenuCardItemSizing.measuredHeightPadding + CompactOverviewLayout.barsOnlyVerticalPadding * 2 @@ -427,7 +435,7 @@ struct CompactOverviewProjectionTests { #expect(providerBarsHeight < labeledHeight) } - for (count, target) in [(1, 17.0), (2, 35.0), (3, 53.0)] { + for (count, target) in [(1, 23.0), (2, 41.0), (3, 59.0)] { let barsOnlyHeight = try #require(barsOnlyHeights[count]) #expect(abs(barsOnlyHeight - target) <= 1) } @@ -465,7 +473,7 @@ struct CompactOverviewProjectionTests { layout: layout)) let height = host.sizeThatFits(in: CGSize(width: layout.menuWidth, height: 10000)).height - #expect(abs(height - 17) <= 1) + #expect(abs(height - 23) <= 1) #expect(CompactOverviewLayout.barsOnlySectionSpacerHeight == 3) } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 83be640eb3..5bf8a0c4db 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -127,8 +127,6 @@ struct LocalizationLanguageCatalogTests { "overview_layout_compact": "Providers, metrics & bars", "overview_layout_provider_bars": "Providers & bars", "overview_layout_bars_only": "Bars only", - "overview_compact_title": "Compact Overview", - "overview_compact_subtitle": "Show provider names and usage bars in a space-saving layout.", "overview_compact_no_bars": "No usage bars", ] From 1c42622a4e3dd35620f439f6c62564fe18728242 Mon Sep 17 00:00:00 2001 From: Trim Date: Mon, 3 Aug 2026 15:50:09 -0400 Subject: [PATCH 4/4] Preserve Doubao plan labels --- Sources/CodexBar/CompactOverviewRow.swift | 29 +++++++++++++++--- .../CompactOverviewProjectionTests.swift | 30 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/CompactOverviewRow.swift b/Sources/CodexBar/CompactOverviewRow.swift index 8fd288ae3e..8f8ba5b544 100644 --- a/Sources/CodexBar/CompactOverviewRow.swift +++ b/Sources/CodexBar/CompactOverviewRow.swift @@ -97,12 +97,17 @@ struct CompactOverviewProjection { 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: UsageMenuCardView.popupMetricTitle(provider: model.provider, metric: metric), + title: Self.metricTitle( + provider: model.provider, + metric: metric, + disclosesDoubaoPlanFamily: disclosesDoubaoPlanFamily), percent: metric.percent, percentStyle: metric.percentStyle, tint: model.progressColor, @@ -116,7 +121,8 @@ struct CompactOverviewProjection { self.fallback = Self.makeFallback( model: model, loadingText: loadingText, - noBarsText: noBarsText) + noBarsText: noBarsText, + disclosesDoubaoPlanFamily: disclosesDoubaoPlanFamily) } else { self.fallback = nil } @@ -126,7 +132,8 @@ struct CompactOverviewProjection { private static func makeFallback( model: UsageMenuCardView.Model, loadingText: String, - noBarsText: String) -> Fallback + noBarsText: String, + disclosesDoubaoPlanFamily: Bool) -> Fallback { if case .loading = model.subtitleStyle { return .loading(text: loadingText) @@ -139,12 +146,26 @@ struct CompactOverviewProjection { else { continue } return .status( metricID: metric.id, - title: UsageMenuCardView.popupMetricTitle(provider: model.provider, metric: metric), + 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" diff --git a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift index 01c9f16672..98ab8fc710 100644 --- a/Tests/CodexBarTests/CompactOverviewProjectionTests.swift +++ b/Tests/CodexBarTests/CompactOverviewProjectionTests.swift @@ -58,6 +58,36 @@ struct CompactOverviewProjectionTests { #expect(status.fallback?.metricTitle == L("API key limit")) } + @Test + func `doubao projection preserves plan family for colliding metric titles`() { + let codingTitle = "\(L("Coding Plan")) — 5-hour" + let agentTitle = "\(L("Agent Plan")) — 5-hour" + let projection = CompactOverviewProjection(model: Self.model( + provider: .doubao, + providerName: "Doubao", + metrics: [ + Self.metric(id: "primary", title: "5-hour", percent: 25), + Self.metric(id: "doubao-agent-session", title: "5-hour", percent: 75), + ])) + let fallback = CompactOverviewProjection(model: Self.model( + provider: .doubao, + providerName: "Doubao", + metrics: [ + Self.metric(id: "primary", title: "5-hour", statusText: "Unavailable"), + Self.metric(id: "doubao-agent-session", title: "5-hour", statusText: "Unavailable"), + ])) + let codingOnly = CompactOverviewProjection(model: Self.model( + provider: .doubao, + metrics: [Self.metric(id: "primary", title: "5-hour")])) + + #expect(projection.lanes.map(\.title) == [codingTitle, agentTitle]) + #expect(projection.accessibilityLabel == + "Doubao. \(codingTitle), 25% left. \(agentTitle), 75% left") + #expect(fallback.fallback?.metricTitle == codingTitle) + #expect(fallback.accessibilityLabel == "Doubao. \(codingTitle): Unavailable") + #expect(codingOnly.lanes.first?.title == "5-hour") + } + @Test func `loading status and generic fallback precedence is deterministic`() { let statusMetrics = [