Files
steipete--codexbar/Sources/CodexBar/StatusItemController+MenuBarLayout.swift
William Mitchell bc3c4b304e Extend menu bar conditionals beyond usage percentages (#3088)
* Extend menu bar conditionals beyond usage percentages

Conditional predicates could only compare four percent-used windows. They now
compare 18 metrics across four units: percent windows, the direct
primary/secondary/tertiary lanes, four reset countdowns, three pace deltas,
run-out, credit balance, and today/30-day cost. Metrics with two readings
(percent windows, lanes, balance) gain a used/remaining select, so
"session > 50% used and session resets in < 2h" is expressible.

Pace, run-out, balance and cost were only carried as display strings, which
cannot be compared, so MenuBarLayoutRenderMetrics carries their numeric twins
pre-rounded to the same granularity as the text they mirror.

Three refresh gates needed widening for the new data dependencies:
- The title cache key had no component that moves with the clock, so a
  countdown predicate would have served its pre-flip title indefinitely. It now
  keys on the per-conditional outcome, evaluated once per render.
- The four observation signatures gated on display tokens; a predicate on cost
  or balance has no token. They now also read the conditionals' metrics, which
  additionally fixes lane tokens inside conditional branches being invisible to
  the lane signature.
- A reset-countdown predicate flips at an instant nothing else ticks on, so the
  countdown scheduler wakes at `resetsAt - threshold`.

The conditional library is now decoded element-wise: this change makes
forward-incompatible metric values possible for the first time, and one unknown
value would otherwise have wiped the whole library on a downgrade.

Ships an "Auto % / Resets in" default that renders the automatic percentage
while the lane has headroom and the reset countdown once it is spent.

* Sign the readings conditional predicates actually compare

Three observation-signature gaps let a predicate flip without a redraw:

- Cost signatures recorded only the currency-formatted string, so two token-cost
  updates could cross a threshold while both formatted to the same cent. A
  referenced cost metric now signs the unrounded amount losslessly.
- The balance signature recorded only the rendered "Remaining" row, so a
  `balance used` predicate — which reads the "Used" row no token surfaces — was
  entirely unsigned. Both amounts are now signed.
- The lane signature recorded the displayed reading, which follows
  `usageBarsShowUsed` and clamps remaining at zero, while `RateWindow.usedPercent`
  deliberately preserves over-quota values. A used-direction predicate such as
  `primaryLane > 105%` could move 104% -> 106% against a constant `0.000`.

The lane signature is now scoped to what the layout renders, and a new
conditional-window signature covers what conditionals read: the raw used percent
(which remaining derives from, so it covers both directions) plus `resetsAt`,
which countdown predicates depend on and no display token contributes.

* Tick clock-derived predicates that no token schedules

`menuBarWeeklyPaceRefreshDelays` is gated on a placed `.pace(.weekly)` token and
only wakes once, at the pace-eligibility boundary. Excluding `runsOutIn` from the
conditional reset schedule on the assumption that scheduler covered it therefore
left a hole: a layout whose only pace or run-out reference is a predicate got no
clock wake-up at all, so it kept rendering the branch that was true when the
value last moved.

Referenced weekly-pace predicates now also trigger the eligibility wake-up, and
any referenced pace or run-out predicate schedules a minute tick. Both numbers
are pre-rounded to the granularity the menu bar shows -- whole percentage points
and whole minutes -- so a minute tick is exactly enough, and it is the cadence a
`.resetCountdown` token already costs. Money predicates deliberately schedule
nothing: they move only when new provider data arrives.

* Keep older releases' conditional libraries readable on downgrade

Decoding the library element-wise only helps builds that already have the
lenient decoder. The build a user actually downgrades to decodes
`menuBarLayoutConditionals` strictly and falls back to `[]`, so one saved rule
using a new metric would empty the entire library there.

The conditional library now dual-writes the way layouts already do:
`menuBarLayoutConditionalsV2` keeps full fidelity, and the original key keeps an
older-readable projection. `loadLibrary` mirrors `preferredLayout` — the current
key wins unless the legacy key disagrees with its own projection, which only
happens when an older release wrote it, and that edit must survive.

The projection drops an entry when any clause uses a metric outside the original
four, and also when any clause uses a non-`.used` direction. The second case is
the subtler one: an older release's synthesized decoder ignores the unknown
`direction` key, so `session remaining > 80` would come back as
`session used > 80` and render the opposite branch. A missing rule is visibly
missing; an inverted one is not.

* Drop cost metrics that could not be converted to USD

`UsageFormatter.convertedCost` returns the source amount unchanged when it has no
rate for the provider's currency, and both cost producers passed that value
straight through as `costTodayUSD`/`cost30dUSD`. A `Cost today > 5 USD` rule
would then compare, say, €6 against a $5 threshold and pick the wrong branch.

Both producers now keep the amount only when the conversion actually landed in
USD. Otherwise the predicate sees no value and evaluates false, which is the
existing contract for a metric the provider does not report. The rendered text is
untouched and still shows the provider's own currency.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-20 03:18:14 -07:00

297 lines
13 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
struct MenuBarLayoutWindows {
let primary: RateWindow?
let secondary: RateWindow?
let tertiary: RateWindow?
let session: RateWindow?
let weekly: RateWindow?
let automatic: RateWindow?
}
/// Menu-bar cost values resolved in one pass: the display strings in the user's preferred currency plus
/// the same amounts in USD, which conditional predicates compare so a threshold does not shift when the
/// display currency does.
struct MenuBarLayoutCostValues {
let today: String?
let last30Days: String?
let todayUSD: Double?
let last30DaysUSD: Double?
}
extension StatusItemController {
func applyStoredMenuBarLayoutIfNeeded(
provider: UsageProvider,
snapshot: UsageSnapshot?,
icon: NSImage?,
warningFlash: Bool,
statusItem: NSStatusItem,
now: Date = .init())
-> Bool?
{
let resolution = self.settings.menuBarLayoutResolution(for: provider)
guard !resolution.usesLegacyRendering,
self.settings.menuBarIconStyle == .iconAndPercent,
let button = statusItem.button
else {
statusItem.length = NSStatusItem.variableLength
return nil
}
let renderedIcon = icon.map { warningFlash ? Self.quotaWarningFlashImage(base: $0) : $0 }
let data = self.menuBarLayoutRenderData(
provider: provider,
snapshot: snapshot,
warningFlash: warningFlash,
now: now)
let appearanceName = button.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])?.rawValue ?? "default"
let options = MenuBarLayoutRenderOptions(
size: self.settings.menuBarLayoutSize,
highContrast: self.shouldUseHighContrastStatusItemContent,
showUsed: self.settings.usageBarsShowUsed,
conditionals: self.settings.menuBarLayoutConditionals,
appearanceName: appearanceName,
isDebugApp: Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier),
isStale: self.store.isStale(provider: provider),
now: now,
verticalAdjustment: self.settings.menuBarLayoutVerticalAdjustment)
let rendered = self.menuBarLayoutRenderer.render(
layout: resolution.layout,
data: data,
icon: renderedIcon,
options: options)
let expectedImagePosition: NSControl.ImagePosition = if rendered.leadingIcon != nil {
rendered.attributedTitle.length > 0 ? .imageLeft : .imageOnly
} else {
.noImage
}
let wasCached = button.image === rendered.leadingIcon
&& button.imagePosition == expectedImagePosition
&& button.attributedTitle.isEqual(to: rendered.attributedTitle)
self.setButtonLayoutContent(rendered, for: button, statusItem: statusItem)
return wasCached
}
func menuBarLayoutRenderData(
provider: UsageProvider,
snapshot: UsageSnapshot?,
warningFlash: Bool,
now: Date = .init())
-> MenuBarLayoutRenderData
{
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now)
let scopedNamed = MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot)
let paceWindow = windows.weekly ?? windows.automatic
// Bind the pace itself rather than only its label: `etaSeconds` is the numeric run-out that
// conditional predicates compare, and resolving it twice would score the window twice.
let pace = paceWindow.flatMap {
self.store.weeklyPace(
provider: provider,
window: $0,
now: now)
}
let runsOut = pace
.flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel }
let costs = self.menuBarLayoutCosts(provider: provider, now: now)
let balanceAmounts = MenuBarLayoutBalanceResolver.balanceAmountsUSD(
provider: provider,
snapshot: snapshot)
let providerName = L(self.store.metadata(for: provider).displayName)
let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot)
let automatic = MenuBarLayoutRenderWindow(windows.automatic)
return MenuBarLayoutRenderData(
provider: provider,
iconKey: "\(provider.rawValue):\(warningFlash ? "warning" : "normal")",
providerName: providerName,
accountLabel: accountLabel,
laneLabels: MenuBarLayoutLaneLabels(provider: provider, snapshot: snapshot),
primary: MenuBarLayoutRenderWindow(windows.primary),
secondary: MenuBarLayoutRenderWindow(windows.secondary),
tertiary: MenuBarLayoutRenderWindow(windows.tertiary),
session: MenuBarLayoutRenderWindow(windows.session),
weekly: MenuBarLayoutRenderWindow(windows.weekly),
scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window),
scopedWeeklyTitle: scopedNamed?.title,
automatic: automatic,
// Provider-specific by design: Mistral uses spend text when its automatic lane has no percentage window.
automaticText: provider == .mistral && automatic == nil
? Self.mistralSpendDisplayText(snapshot: snapshot)
: nil,
sessionPace: self.store.menuBarLayoutPaceText(
provider: provider,
window: windows.session,
now: now),
weeklyPace: self.store.menuBarLayoutPaceText(
provider: provider,
window: windows.weekly,
now: now,
minimumElapsedPercent: 1),
automaticPace: self.store.menuBarLayoutPaceText(
provider: provider,
window: windows.automatic,
now: now),
runsOut: runsOut,
balance: MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot),
costToday: costs.today,
cost30d: costs.last30Days,
metrics: MenuBarLayoutRenderMetrics(
sessionPaceDelta: self.store.menuBarLayoutPaceDelta(
provider: provider,
window: windows.session,
now: now),
weeklyPaceDelta: self.store.menuBarLayoutPaceDelta(
provider: provider,
window: windows.weekly,
now: now,
minimumElapsedPercent: 1),
automaticPaceDelta: self.store.menuBarLayoutPaceDelta(
provider: provider,
window: windows.automatic,
now: now),
runsOutMinutes: pace?.etaSeconds.map { Int(($0 / 60).rounded()) },
balanceRemainingUSD: balanceAmounts.remaining,
balanceUsedUSD: balanceAmounts.used,
costTodayUSD: costs.todayUSD,
cost30dUSD: costs.last30DaysUSD))
}
func menuBarLayoutAccountLabel(provider: UsageProvider, snapshot: UsageSnapshot?) -> String? {
let rawAccountLabel = snapshot?.accountEmail(for: provider)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return self.settings.hidePersonalInfo || rawAccountLabel?.isEmpty != false
? nil
: rawAccountLabel
}
func menuBarLayoutCosts(
provider: UsageProvider,
now: Date = .init())
-> MenuBarLayoutCostValues
{
let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot
let sourceCurrencyCode = snapshot?.currencyCode ?? "USD"
let preferredCurrencyCode = self.settings.preferredCurrencyCode
let todayAmount = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now)
let last30DaysAmount = snapshot?.last30DaysCostUSD
let display = { (value: Double) in
UsageFormatter.convertedCostString(
value,
preferredCurrency: preferredCurrencyCode,
providerCurrency: sourceCurrencyCode)
}
// Thresholds are USD. `convertedCost` hands back the source amount unchanged when no rate exists,
// so trusting its value alone would compare €6 against a $5 threshold. Keep the datum only when
// the conversion actually landed in USD; otherwise the predicate sees no value and evaluates
// false, which is the same contract as a metric the provider does not report.
let toUSD = { (value: Double) -> Double? in
let converted = UsageFormatter.convertedCost(
value,
preferredCurrency: "USD",
providerCurrency: sourceCurrencyCode)
return converted.currencyCode == "USD" ? converted.value : nil
}
return MenuBarLayoutCostValues(
today: todayAmount.map(display),
last30Days: last30DaysAmount.map(display),
todayUSD: todayAmount.flatMap(toUSD),
last30DaysUSD: last30DaysAmount.flatMap(toUSD))
}
func menuBarLayoutWindows(
provider: UsageProvider,
snapshot: UsageSnapshot?,
now: Date)
-> MenuBarLayoutWindows
{
if provider == .codex,
let projection = self.store.codexConsumerProjectionIfNeeded(
for: provider,
surface: .menuBar,
snapshotOverride: snapshot,
now: now)
{
let session = projection.menuBarSelectableRateWindow(for: .session)
let weekly = projection.menuBarSelectableRateWindow(for: .weekly)
let automatic = projection.automaticMenuBarWindow()
return MenuBarLayoutWindows(
primary: session,
secondary: weekly,
tertiary: snapshot?.tertiary,
session: session,
weekly: weekly,
automatic: automatic)
}
let semanticWindows = MenuBarLayoutSemanticWindowResolver.windows(
provider: provider,
snapshot: snapshot)
// Provider-specific by design: Mistral's automatic lane can explicitly select its Monthly Plan window.
let automaticPreference = provider == .mistral
? self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)
: .automatic
let automatic = MenuBarMetricWindowResolver.rateWindow(
preference: automaticPreference,
provider: provider,
snapshot: snapshot,
supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider),
antigravityPrioritizeExhaustedQuotas: self.settings.antigravityPrioritizeExhaustedQuotas,
now: now)
return MenuBarLayoutWindows(
primary: snapshot?.primary,
secondary: snapshot?.secondary,
tertiary: snapshot?.tertiary,
session: semanticWindows.session,
weekly: semanticWindows.weekly,
automatic: MenuBarLayoutAutomaticWindowDisplayNormalizer.normalized(
provider: provider,
snapshot: snapshot,
window: automatic))
}
private func setButtonLayoutContent(
_ rendered: MenuBarLayoutRenderedTitle,
for button: NSStatusBarButton,
statusItem: NSStatusItem)
{
// A leading icon token is surfaced as the status item image so AppKit applies the
// system's inactive-display tinting to it, matching how other menu bar icons behave.
// Text tokens keep rendering through the attributed title.
if let icon = rendered.leadingIcon {
if button.image !== icon {
button.image = icon
}
let position: NSControl.ImagePosition = rendered.attributedTitle.length > 0 ? .imageLeft : .imageOnly
if button.imagePosition != position {
button.imagePosition = position
}
} else {
if button.image != nil {
button.image = nil
}
if button.imagePosition != .noImage {
button.imagePosition = .noImage
}
}
if !button.attributedTitle.isEqual(to: rendered.attributedTitle) {
button.attributedTitle = rendered.attributedTitle
}
if button.accessibilityTitle() != rendered.accessibilityLabel {
button.setAccessibilityTitle(rendered.accessibilityLabel)
}
// AppKit exposes no content-inset API on NSStatusBarButton. Explicit item length is the actual
// status-item padding mechanism: tight removes most edge space; regular keeps the native breathing room.
var bounds = rendered.attributedTitle.boundingRect(
with: NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading])
if let icon = rendered.leadingIcon {
bounds.size.width += icon.size.width
}
let horizontalPadding: CGFloat = self.settings.menuBarLayoutGap == .tight ? 3 : 10
statusItem.length = max(18, ceil(bounds.width) + horizontalPadding)
}
}