bc3c4b304e
* 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>
297 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|