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>
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 218 KiB |
@@ -4,6 +4,7 @@
|
||||
|
||||
- Fixed custom menu bar line breaks so provider icons stay above stacked usage percentages in the menu bar and layout preview (#3089).
|
||||
- Fixed the native blue selection highlight reappearing on provider cards after cached provider switches: cross-class cached rows now replace the item shell so the highlight override survives (#2998, #3091, #3093). Thanks @kiranmagic7!
|
||||
- Menu bar conditionals can now test every comparable block, not just usage percentages: time to reset, run-out estimate, pace, credit balance, today/30-day cost, and the direct primary/secondary/tertiary lanes, with a used/remaining select wherever both readings exist (so "session > 50% used **and** session resets in < 2h" or "balance remaining >= 5" are expressible). Ships an "Auto % / Resets in" default that shows the percentage while the lane has headroom and the reset countdown once it is spent (#3076).
|
||||
- Added conditional tokens to the menu bar layout editor: named, reusable if/then/else rules (1–4 AND/OR clauses over Session/Weekly/Scoped/Auto thresholds) that swap or hide tokens based on live usage, downgrade-safe and localized across all 23 catalogs (#3076). Thanks @wdmitchelluk!
|
||||
- Fixed inconsistent German localization of "About" ("Um" → "Über") (#3077). Thanks @dwt!
|
||||
- Localized provider usage details in Simplified Chinese: DeepSeek detailed usage/balance, z.ai/GLM quota details, token charts, and the 5-hour reset text (#3084). Thanks @haixing23!
|
||||
|
||||
@@ -8,13 +8,122 @@ enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable {
|
||||
case automatic
|
||||
}
|
||||
|
||||
/// Deliberately mirrors `PercentWindow` but stays a separate type so predicate persistence
|
||||
/// is decoupled from render-window naming.
|
||||
/// Comparison unit of a conditional metric: drives the threshold range, the stepper increment, and the
|
||||
/// unit label shown next to the threshold field.
|
||||
enum MenuBarConditionalMetricKind: Sendable {
|
||||
case percent
|
||||
case signedPercent
|
||||
case hours
|
||||
case currencyUSD
|
||||
}
|
||||
|
||||
/// What a conditional predicate measures. Persistence keys off the case names, so the first four keep
|
||||
/// their original spelling: a library written before the metric set grew still decodes unchanged.
|
||||
/// Declaration order is the editor picker's order: percentages, direct lanes, time to reset, pace,
|
||||
/// run-out, money.
|
||||
enum MenuBarConditionalMetric: String, CaseIterable, Codable, Hashable, Sendable {
|
||||
case session
|
||||
case weekly
|
||||
case scopedWeekly
|
||||
case automatic
|
||||
case primaryLane
|
||||
case secondaryLane
|
||||
case tertiaryLane
|
||||
case sessionResetsIn
|
||||
case weeklyResetsIn
|
||||
case scopedWeeklyResetsIn
|
||||
case automaticResetsIn
|
||||
case sessionPace
|
||||
case weeklyPace
|
||||
case automaticPace
|
||||
case runsOutIn
|
||||
case balance
|
||||
case costToday
|
||||
case cost30d
|
||||
|
||||
var kind: MenuBarConditionalMetricKind {
|
||||
switch self {
|
||||
case .session, .weekly, .scopedWeekly, .automatic,
|
||||
.primaryLane, .secondaryLane, .tertiaryLane:
|
||||
.percent
|
||||
case .sessionPace, .weeklyPace, .automaticPace:
|
||||
.signedPercent
|
||||
case .sessionResetsIn, .weeklyResetsIn, .scopedWeeklyResetsIn, .automaticResetsIn, .runsOutIn:
|
||||
.hours
|
||||
case .balance, .costToday, .cost30d:
|
||||
.currencyUSD
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a used/remaining select applies. Percent windows and lanes expose both readings of the
|
||||
/// same window; balance exposes spend against remaining credit. Pace is already signed, and a reset
|
||||
/// countdown or a cost total has no complement.
|
||||
var supportsDirection: Bool {
|
||||
switch self {
|
||||
case .session, .weekly, .scopedWeekly, .automatic,
|
||||
.primaryLane, .secondaryLane, .tertiaryLane, .balance:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the metric is read straight off a `RateWindow`, so refresh gates know to sign that
|
||||
/// window's raw values. Pace, run-out and money metrics come from upstream-resolved numbers instead.
|
||||
var readsRateWindow: Bool {
|
||||
switch self {
|
||||
case .session, .weekly, .scopedWeekly, .automatic,
|
||||
.primaryLane, .secondaryLane, .tertiaryLane,
|
||||
.sessionResetsIn, .weeklyResetsIn, .scopedWeeklyResetsIn, .automaticResetsIn:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the metric's value moves with the clock rather than only with new provider data, so
|
||||
/// refresh scheduling must tick it. Pace compares actual use against elapsed time, and the run-out
|
||||
/// estimate counts down; reset countdowns are handled by their own exact wake-up instead.
|
||||
var isClockDerivedRate: Bool {
|
||||
switch self {
|
||||
case .sessionPace, .weeklyPace, .automaticPace, .runsOutIn: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a 0.54.0-era decoder has a case for this metric at all. That release shipped the
|
||||
/// conditional editor with only the four percent windows, and its synthesized `Codable` throws on
|
||||
/// any other raw value — which would take the whole persisted library down with it. The legacy
|
||||
/// projection written alongside the current library drops entries this returns `false` for.
|
||||
var hasLegacyRepresentation: Bool {
|
||||
switch self {
|
||||
case .session, .weekly, .scopedWeekly, .automatic: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var thresholdRange: ClosedRange<Double> {
|
||||
switch self.kind {
|
||||
case .percent: 0...100
|
||||
case .signedPercent: -100...100
|
||||
// One year, so no realistic reset or run-out window is clamped.
|
||||
case .hours: 0...8760
|
||||
case .currencyUSD: 0...1_000_000
|
||||
}
|
||||
}
|
||||
|
||||
var thresholdStep: Double {
|
||||
self.kind == .hours ? 0.5 : 1
|
||||
}
|
||||
|
||||
/// Unit shown beside the threshold field and appended in the conditional summary.
|
||||
var thresholdUnit: String {
|
||||
switch self.kind {
|
||||
case .percent, .signedPercent: "%"
|
||||
case .hours: "h"
|
||||
case .currencyUSD: "USD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MenuBarConditionalComparison: String, CaseIterable, Codable, Hashable, Sendable {
|
||||
@@ -70,10 +179,57 @@ enum MenuBarConditionalCombinator: String, CaseIterable, Codable, Hashable, Send
|
||||
case or
|
||||
}
|
||||
|
||||
/// Which reading of a metric a predicate compares.
|
||||
enum MenuBarConditionalDirection: String, CaseIterable, Codable, Hashable, Sendable {
|
||||
case used
|
||||
case remaining
|
||||
}
|
||||
|
||||
struct MenuBarConditionalPredicate: Codable, Hashable, Sendable {
|
||||
var metric: MenuBarConditionalMetric
|
||||
/// Which reading of `metric` to compare. Normalized back to `.used` when the metric has no
|
||||
/// complement, so a stored direction can never contradict the metric.
|
||||
var direction: MenuBarConditionalDirection
|
||||
var comparison: MenuBarConditionalComparison
|
||||
var threshold: Double
|
||||
|
||||
init(
|
||||
metric: MenuBarConditionalMetric,
|
||||
direction: MenuBarConditionalDirection = .used,
|
||||
comparison: MenuBarConditionalComparison,
|
||||
threshold: Double)
|
||||
{
|
||||
self.metric = metric
|
||||
self.direction = direction
|
||||
self.comparison = comparison
|
||||
self.threshold = threshold
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case metric, direction, comparison, threshold
|
||||
}
|
||||
|
||||
/// Predicates persisted before `direction` existed compared used percentages, so a missing key
|
||||
/// decodes as `.used` and keeps its original meaning. The synthesized decoder would instead reject
|
||||
/// the whole predicate.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.metric = try container.decode(MenuBarConditionalMetric.self, forKey: .metric)
|
||||
self.direction = try container.decodeIfPresent(MenuBarConditionalDirection.self, forKey: .direction)
|
||||
?? .used
|
||||
self.comparison = try container.decode(MenuBarConditionalComparison.self, forKey: .comparison)
|
||||
self.threshold = try container.decode(Double.self, forKey: .threshold)
|
||||
}
|
||||
|
||||
/// Clamps the threshold into the metric's unit range and drops a direction the metric cannot use.
|
||||
func normalized() -> Self {
|
||||
var copy = self
|
||||
copy.threshold = self.threshold.clamped(to: self.metric.thresholdRange)
|
||||
if !self.metric.supportsDirection {
|
||||
copy.direction = .used
|
||||
}
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarConditionalClause: Codable, Hashable, Sendable {
|
||||
@@ -107,7 +263,7 @@ struct MenuBarLayoutConditional: Codable, Hashable, Sendable {
|
||||
private mutating func normalize() {
|
||||
var normalized = self.clauses.prefix(4).map { clause in
|
||||
var clause = clause
|
||||
clause.predicate.threshold = min(max(clause.predicate.threshold, 0), 100)
|
||||
clause.predicate = clause.predicate.normalized()
|
||||
return clause
|
||||
}
|
||||
if normalized.isEmpty {
|
||||
@@ -159,7 +315,8 @@ struct MenuBarLayoutConditional: Codable, Hashable, Sendable {
|
||||
/// resolving across launches, and once the user edits or clears the library the stored array wins,
|
||||
/// so a deleted entry is never reseeded.
|
||||
///
|
||||
/// Thresholds compare the window's **used** percentage, matching `evaluatesTrue`.
|
||||
/// Percent thresholds compare the window's **used** percentage and countdown thresholds compare hours
|
||||
/// until the window resets, both matching `evaluatesTrue`.
|
||||
static func shippedLibrary() -> [MenuBarLayoutConditional] {
|
||||
[
|
||||
MenuBarLayoutConditional(
|
||||
@@ -195,13 +352,36 @@ struct MenuBarLayoutConditional: Codable, Hashable, Sendable {
|
||||
clauses: [self.clause(.scopedWeekly, .greaterThan, 60)],
|
||||
thenToken: .percent(window: .scopedWeekly),
|
||||
elseToken: .hidden),
|
||||
MenuBarLayoutConditional(
|
||||
id: self.fixedID("98257E78-8E87-4BE4-A917-73F98310143C"),
|
||||
// Composed from the two palette token labels it switches between, so the chip always
|
||||
// reads in the same words as the tokens themselves in every language.
|
||||
name: "\(L("menu_bar_layout_token_auto")) / \(L("menu_bar_layout_token_resets_in"))",
|
||||
clauses: [self.clause(.automatic, .greaterThanOrEqual, 1, direction: .remaining)],
|
||||
thenToken: .percent(window: .automatic),
|
||||
elseToken: .resetCountdown),
|
||||
]
|
||||
}
|
||||
|
||||
/// This conditional as a 0.54.0-era release can read it, or nil when it cannot be represented.
|
||||
///
|
||||
/// Two things make an entry unreadable there. A metric outside the original four throws on decode
|
||||
/// and takes the whole array with it. A non-`.used` direction is worse than unreadable: the extra
|
||||
/// key is silently ignored by that release's synthesized decoder, so `session remaining > 80` would
|
||||
/// come back as `session used > 80` and render the opposite branch. Dropping the entry is the honest
|
||||
/// projection in both cases — a missing rule is visibly missing, an inverted one is not.
|
||||
var legacyCompatible: MenuBarLayoutConditional? {
|
||||
let readable = self.clauses.allSatisfy { clause in
|
||||
clause.predicate.metric.hasLegacyRepresentation && clause.predicate.direction == .used
|
||||
}
|
||||
return readable ? self : nil
|
||||
}
|
||||
|
||||
private static func clause(
|
||||
_ metric: MenuBarConditionalMetric,
|
||||
_ comparison: MenuBarConditionalComparison,
|
||||
_ threshold: Double,
|
||||
direction: MenuBarConditionalDirection = .used,
|
||||
combinator: MenuBarConditionalCombinator? = nil)
|
||||
-> MenuBarConditionalClause
|
||||
{
|
||||
@@ -209,6 +389,7 @@ struct MenuBarLayoutConditional: Codable, Hashable, Sendable {
|
||||
combinator: combinator,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: metric,
|
||||
direction: direction,
|
||||
comparison: comparison,
|
||||
threshold: threshold))
|
||||
}
|
||||
@@ -223,6 +404,18 @@ struct MenuBarLayoutConditional: Codable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Array element wrapper that tolerates one undecodable conditional instead of failing the whole
|
||||
/// library. A conditional using a metric this build does not recognize — a downgrade reading a library
|
||||
/// written by a newer release — must not take every other entry down with it; layouts referencing a
|
||||
/// dropped entry already render the dangling-conditional placeholder.
|
||||
struct LenientMenuBarLayoutConditional: Decodable {
|
||||
let value: MenuBarLayoutConditional?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
self.value = try? MenuBarLayoutConditional(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarLayoutLaneLabels: Hashable {
|
||||
let primary: String
|
||||
let secondary: String
|
||||
@@ -340,6 +533,27 @@ enum MenuBarLayoutBalanceResolver {
|
||||
guard provider == .openrouter else { return nil }
|
||||
return snapshot?.detailRow(label: "Remaining")?.value
|
||||
}
|
||||
|
||||
/// Numeric USD amounts behind OpenRouter's "Credits" detail rows. The plugin formats both rows as
|
||||
/// `$` + `toFixed(2)` (`Sources/CodexBarCore/Resources/Plugins/openrouter.js`), so the amounts are
|
||||
/// USD with no grouping separators; the plugin never populates `providerCost`, so there is nothing
|
||||
/// structured to read instead.
|
||||
static func balanceAmountsUSD(
|
||||
provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?)
|
||||
-> (remaining: Double?, used: Double?)
|
||||
{
|
||||
// Provider-specific by design: only OpenRouter reports credit amounts in its "Credits" detail rows.
|
||||
guard provider == .openrouter else { return (nil, nil) }
|
||||
return (
|
||||
self.amount(snapshot?.detailRow(label: "Remaining")?.value),
|
||||
self.amount(snapshot?.detailRow(label: "Used")?.value))
|
||||
}
|
||||
|
||||
private static func amount(_ text: String?) -> Double? {
|
||||
guard let text else { return nil }
|
||||
return Double(text.filter { $0.isNumber || $0 == "." || $0 == "-" })
|
||||
}
|
||||
}
|
||||
|
||||
enum MenuBarLayoutCostResolver {
|
||||
@@ -414,6 +628,8 @@ enum MenuBarLayoutUserDefaultsKey {
|
||||
static let layoutCurrent = "menuBarLayoutV2"
|
||||
static let overrides = "menuBarLayoutOverrides"
|
||||
static let overridesCurrent = "menuBarLayoutOverridesV2"
|
||||
static let conditionals = "menuBarLayoutConditionals"
|
||||
static let conditionalsCurrent = "menuBarLayoutConditionalsV2"
|
||||
}
|
||||
|
||||
enum MenuBarLayoutPreset: String, CaseIterable, Identifiable, Sendable {
|
||||
@@ -703,6 +919,71 @@ enum MenuBarLayoutPersistence {
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
/// Library projection an older conditional-capable release can read, dropping entries it would
|
||||
/// misread or choke on.
|
||||
static func legacyCompatibleLibrary(
|
||||
_ conditionals: [MenuBarLayoutConditional])
|
||||
-> [MenuBarLayoutConditional]
|
||||
{
|
||||
conditionals.compactMap(\.legacyCompatible)
|
||||
}
|
||||
|
||||
/// Mirrors `preferredLayout`: the full-fidelity 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.
|
||||
static func preferredLibrary(
|
||||
current: [MenuBarLayoutConditional]?,
|
||||
legacy: [MenuBarLayoutConditional]?)
|
||||
-> [MenuBarLayoutConditional]?
|
||||
{
|
||||
if let current {
|
||||
if let legacy, self.legacyCompatibleLibrary(current) != legacy {
|
||||
return legacy
|
||||
}
|
||||
return current
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
static func needsStartupDualWrite(
|
||||
current: [MenuBarLayoutConditional]?,
|
||||
legacy: [MenuBarLayoutConditional]?)
|
||||
-> Bool
|
||||
{
|
||||
switch (current, legacy) {
|
||||
case (nil, .some), (.some, nil): true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
static func encodedLibrary(
|
||||
_ conditionals: [MenuBarLayoutConditional])
|
||||
throws -> (current: Data, legacy: Data)
|
||||
{
|
||||
let encoder = JSONEncoder()
|
||||
return try (
|
||||
encoder.encode(conditionals),
|
||||
encoder.encode(self.legacyCompatibleLibrary(conditionals)))
|
||||
}
|
||||
|
||||
/// Pre-V2 installs only have the legacy key, so materialize both at load: an immediate downgrade
|
||||
/// then reads a projection that was never written by an older release rather than nothing.
|
||||
static func loadLibrary(
|
||||
current: [MenuBarLayoutConditional]?,
|
||||
legacy: [MenuBarLayoutConditional]?,
|
||||
into userDefaults: UserDefaults)
|
||||
-> [MenuBarLayoutConditional]?
|
||||
{
|
||||
let preferred = self.preferredLibrary(current: current, legacy: legacy)
|
||||
if let preferred,
|
||||
self.needsStartupDualWrite(current: current, legacy: legacy),
|
||||
let blobs = try? self.encodedLibrary(preferred)
|
||||
{
|
||||
userDefaults.set(blobs.current, forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)
|
||||
userDefaults.set(blobs.legacy, forKey: MenuBarLayoutUserDefaultsKey.conditionals)
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarLayout {
|
||||
@@ -716,6 +997,21 @@ extension MenuBarLayout {
|
||||
return tokens
|
||||
}
|
||||
|
||||
/// Every predicate reachable from the conditionals this layout places, including nested branches
|
||||
/// (depth-capped by `flattenedTokens`). Data-dependency gates use this to see what the conditionals
|
||||
/// read: a predicate on cost or time to reset has no matching display token to detect.
|
||||
func referencedConditionalPredicates(
|
||||
conditionals: [MenuBarLayoutConditional])
|
||||
-> [MenuBarConditionalPredicate]
|
||||
{
|
||||
let byID = Dictionary(conditionals.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
return self.flattenedTokens(conditionals: conditionals)
|
||||
.flatMap { token -> [MenuBarConditionalPredicate] in
|
||||
guard case let .conditional(id) = token, let conditional = byID[id] else { return [] }
|
||||
return conditional.clauses.map(\.predicate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a layout with every `.conditional(id:)` token matching `id` removed from both lines,
|
||||
/// or nil when nothing referenced it (so callers never materialize an unchanged stored layout).
|
||||
func removingConditional(id: UUID) -> MenuBarLayout? {
|
||||
|
||||
@@ -79,49 +79,7 @@ struct MenuBarLayoutConditionalEditorSheet: View {
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(self.conditional.clauses.indices, id: \.self) { index in
|
||||
HStack(spacing: 6) {
|
||||
if index > 0 {
|
||||
Picker("", selection: self.combinatorBinding(index)) {
|
||||
Text(L("menu_bar_layout_conditional_and")).tag(MenuBarConditionalCombinator.and)
|
||||
Text(L("menu_bar_layout_conditional_or")).tag(MenuBarConditionalCombinator.or)
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
Picker("", selection: self.metricBinding(index)) {
|
||||
ForEach(MenuBarConditionalMetric.allCases, id: \.self) { metric in
|
||||
Text(metric.editorLabel).tag(metric)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
Picker("", selection: self.comparisonBinding(index)) {
|
||||
ForEach(MenuBarConditionalComparison.allCases, id: \.self) { comparison in
|
||||
Text(comparison.symbol).tag(comparison)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
TextField("", value: self.thresholdBinding(index), format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 44)
|
||||
.monospacedDigit()
|
||||
Stepper(value: self.thresholdBinding(index), in: 0...100, step: 1) {
|
||||
EmptyView()
|
||||
}
|
||||
.labelsHidden()
|
||||
Text("%")
|
||||
|
||||
if self.conditional.clauses.count > 1 {
|
||||
Button {
|
||||
self.conditional.clauses.remove(at: index)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
self.clauseRow(index: index)
|
||||
}
|
||||
|
||||
Button(L("menu_bar_layout_conditional_add_condition")) {
|
||||
@@ -169,7 +127,71 @@ struct MenuBarLayoutConditionalEditorSheet: View {
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 460)
|
||||
// Wide enough for combinator + metric + direction + comparison + value + stepper + unit + remove.
|
||||
.frame(width: 620)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func clauseRow(index: Int) -> some View {
|
||||
let metric = self.conditional.clauses.indices.contains(index)
|
||||
? self.conditional.clauses[index].predicate.metric
|
||||
: MenuBarConditionalMetric.session
|
||||
HStack(spacing: 6) {
|
||||
if index > 0 {
|
||||
Picker("", selection: self.combinatorBinding(index)) {
|
||||
Text(L("menu_bar_layout_conditional_and")).tag(MenuBarConditionalCombinator.and)
|
||||
Text(L("menu_bar_layout_conditional_or")).tag(MenuBarConditionalCombinator.or)
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
Picker("", selection: self.metricBinding(index)) {
|
||||
ForEach(MenuBarConditionalMetric.allCases, id: \.self) { metric in
|
||||
Text(metric.editorLabel(provider: self.provider)).tag(metric)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
if metric.supportsDirection {
|
||||
Picker("", selection: self.directionBinding(index)) {
|
||||
Text(L("menu_bar_layout_conditional_used")).tag(MenuBarConditionalDirection.used)
|
||||
Text(L("menu_bar_layout_conditional_remaining")).tag(MenuBarConditionalDirection.remaining)
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
Picker("", selection: self.comparisonBinding(index)) {
|
||||
ForEach(MenuBarConditionalComparison.allCases, id: \.self) { comparison in
|
||||
Text(comparison.symbol).tag(comparison)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
TextField("", value: self.thresholdBinding(index), format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 52)
|
||||
.monospacedDigit()
|
||||
Stepper(
|
||||
value: self.thresholdBinding(index),
|
||||
in: metric.thresholdRange,
|
||||
step: metric.thresholdStep)
|
||||
{
|
||||
EmptyView()
|
||||
}
|
||||
.labelsHidden()
|
||||
Text(metric.thresholdUnit)
|
||||
|
||||
if self.conditional.clauses.count > 1 {
|
||||
Button {
|
||||
self.conditional.clauses.remove(at: index)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func combinatorBinding(_ index: Int) -> Binding<MenuBarConditionalCombinator> {
|
||||
@@ -193,6 +215,22 @@ struct MenuBarLayoutConditionalEditorSheet: View {
|
||||
set: {
|
||||
guard self.conditional.clauses.indices.contains(index) else { return }
|
||||
self.conditional.clauses[index].predicate.metric = $0
|
||||
// Re-normalize so switching metric families cannot leave a threshold outside the new
|
||||
// unit's range or a direction the new metric has no reading for.
|
||||
self.conditional.clauses[index].predicate =
|
||||
self.conditional.clauses[index].predicate.normalized()
|
||||
})
|
||||
}
|
||||
|
||||
private func directionBinding(_ index: Int) -> Binding<MenuBarConditionalDirection> {
|
||||
Binding(
|
||||
get: {
|
||||
guard self.conditional.clauses.indices.contains(index) else { return .used }
|
||||
return self.conditional.clauses[index].predicate.direction
|
||||
},
|
||||
set: {
|
||||
guard self.conditional.clauses.indices.contains(index) else { return }
|
||||
self.conditional.clauses[index].predicate.direction = $0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +254,8 @@ struct MenuBarLayoutConditionalEditorSheet: View {
|
||||
},
|
||||
set: {
|
||||
guard self.conditional.clauses.indices.contains(index) else { return }
|
||||
self.conditional.clauses[index].predicate.threshold = min(max($0, 0), 100)
|
||||
let metric = self.conditional.clauses[index].predicate.metric
|
||||
self.conditional.clauses[index].predicate.threshold = $0.clamped(to: metric.thresholdRange)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,26 +324,41 @@ extension MenuBarLayoutConditional {
|
||||
|
||||
/// Produces a summary string reflecting the left-fold AND/OR evaluation order. Mixed
|
||||
/// combinators parenthesize their accumulator so the reading matches `evaluatesTrue`.
|
||||
private func conditionText() -> String {
|
||||
private func conditionText(provider: UsageProvider?) -> String {
|
||||
guard let first = self.clauses.first else { return "" }
|
||||
let mixed = Set(self.clauses.dropFirst().compactMap(\.combinator)).count > 1
|
||||
var text = Self.predicateText(first.predicate)
|
||||
var text = Self.predicateText(first.predicate, provider: provider)
|
||||
for clause in self.clauses.dropFirst() {
|
||||
let joiner = (clause.combinator ?? .and) == .and
|
||||
? L("menu_bar_layout_conditional_and")
|
||||
: L("menu_bar_layout_conditional_or")
|
||||
let pred = Self.predicateText(clause.predicate)
|
||||
let pred = Self.predicateText(clause.predicate, provider: provider)
|
||||
text = mixed ? "(\(text)) \(joiner) \(pred)" : "\(text) \(joiner) \(pred)"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func predicateText(_ predicate: MenuBarConditionalPredicate) -> String {
|
||||
"\(predicate.metric.editorLabel) \(predicate.comparison.symbol) \(Int(predicate.threshold.rounded()))%"
|
||||
private static func predicateText(
|
||||
_ predicate: MenuBarConditionalPredicate,
|
||||
provider: UsageProvider?)
|
||||
-> String
|
||||
{
|
||||
var metric = predicate.metric.editorLabel(provider: provider)
|
||||
if predicate.metric.supportsDirection {
|
||||
metric += " " + (predicate.direction == .used
|
||||
? L("menu_bar_layout_conditional_used")
|
||||
: L("menu_bar_layout_conditional_remaining"))
|
||||
}
|
||||
let value = predicate.threshold
|
||||
// Whole hours read as "2h"; a half-hour step needs the decimal to stay truthful.
|
||||
let number = value == value.rounded() ? String(Int(value.rounded())) : String(format: "%.1f", value)
|
||||
let unit = predicate.metric.thresholdUnit
|
||||
let amount = predicate.metric.kind == .currencyUSD ? "\(number) \(unit)" : "\(number)\(unit)"
|
||||
return "\(metric) \(predicate.comparison.symbol) \(amount)"
|
||||
}
|
||||
|
||||
func editorSummary(provider: UsageProvider?) -> String {
|
||||
let condition = self.conditionText()
|
||||
let condition = self.conditionText(provider: provider)
|
||||
return L(
|
||||
"menu_bar_layout_conditional_summary",
|
||||
condition,
|
||||
@@ -327,12 +381,30 @@ extension MenuBarLayoutConditional {
|
||||
}
|
||||
|
||||
extension MenuBarConditionalMetric {
|
||||
var editorLabel: String {
|
||||
/// Reuses the palette token labels so a metric and the block it measures always read the same, and
|
||||
/// resolves lane names through the provider's own labels rather than hard-coding "Primary".
|
||||
func editorLabel(provider: UsageProvider?) -> String {
|
||||
switch self {
|
||||
case .session: L("menu_bar_layout_token_session")
|
||||
case .weekly: L("menu_bar_layout_token_weekly")
|
||||
case .scopedWeekly: L("menu_bar_layout_token_scoped_weekly")
|
||||
case .automatic: L("menu_bar_layout_token_auto")
|
||||
case .primaryLane: MenuBarLayoutToken.lanePercent(lane: .primary).editorLabel(provider: provider)
|
||||
case .secondaryLane: MenuBarLayoutToken.lanePercent(lane: .secondary).editorLabel(provider: provider)
|
||||
case .tertiaryLane: MenuBarLayoutToken.lanePercent(lane: .tertiary).editorLabel(provider: provider)
|
||||
case .sessionResetsIn: L("menu_bar_layout_conditional_metric_resets_in", L("Session"))
|
||||
case .weeklyResetsIn: L("menu_bar_layout_conditional_metric_resets_in", L("Weekly"))
|
||||
case .scopedWeeklyResetsIn: L(
|
||||
"menu_bar_layout_conditional_metric_resets_in",
|
||||
L("menu_bar_layout_conditional_metric_scoped_weekly"))
|
||||
case .automaticResetsIn: L("menu_bar_layout_conditional_metric_resets_in", L("Auto"))
|
||||
case .sessionPace: L("menu_bar_layout_token_session_pace")
|
||||
case .weeklyPace: L("menu_bar_layout_token_weekly_pace")
|
||||
case .automaticPace: L("menu_bar_layout_token_auto_pace")
|
||||
case .runsOutIn: L("menu_bar_layout_token_runs_out")
|
||||
case .balance: L("Balance")
|
||||
case .costToday: L("menu_bar_layout_token_cost_today")
|
||||
case .cost30d: L("menu_bar_layout_token_cost_30d")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,16 +941,29 @@ struct MenuBarLayoutPreview: View {
|
||||
window: rawAutomatic)
|
||||
let scopedNamed = MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot)
|
||||
let paceWindow = weekly ?? automatic
|
||||
let runsOut = paceWindow
|
||||
.flatMap {
|
||||
self.store.weeklyPace(
|
||||
provider: provider,
|
||||
window: $0,
|
||||
now: now)
|
||||
}
|
||||
// Bind the pace itself: `etaSeconds` is the numeric run-out conditional predicates compare.
|
||||
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 cost = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot
|
||||
let costToday = MenuBarLayoutCostResolver.todayCostUSD(snapshot: cost, now: now)
|
||||
let balanceAmounts = MenuBarLayoutBalanceResolver.balanceAmountsUSD(
|
||||
provider: provider,
|
||||
snapshot: snapshot)
|
||||
// Thresholds are USD, and `convertedCost` returns the source amount unchanged when no rate
|
||||
// exists, so keep the datum only when the conversion actually landed in USD.
|
||||
let toUSD = { (value: Double) -> Double? in
|
||||
let converted = UsageFormatter.convertedCost(
|
||||
value,
|
||||
preferredCurrency: "USD",
|
||||
providerCurrency: cost?.currencyCode)
|
||||
return converted.currencyCode == "USD" ? converted.value : nil
|
||||
}
|
||||
let automaticRenderWindow = MenuBarLayoutRenderWindow(automatic)
|
||||
return MenuBarLayoutRenderData(
|
||||
provider: provider,
|
||||
@@ -984,7 +997,26 @@ struct MenuBarLayoutPreview: View {
|
||||
},
|
||||
cost30d: cost?.last30DaysCostUSD.map {
|
||||
UsageFormatter.currencyString($0, currencyCode: cost?.currencyCode ?? "USD")
|
||||
})
|
||||
},
|
||||
metrics: MenuBarLayoutRenderMetrics(
|
||||
sessionPaceDelta: self.store.menuBarLayoutPaceDelta(
|
||||
provider: provider,
|
||||
window: session,
|
||||
now: now),
|
||||
weeklyPaceDelta: self.store.menuBarLayoutPaceDelta(
|
||||
provider: provider,
|
||||
window: weekly,
|
||||
now: now,
|
||||
minimumElapsedPercent: 1),
|
||||
automaticPaceDelta: self.store.menuBarLayoutPaceDelta(
|
||||
provider: provider,
|
||||
window: automatic,
|
||||
now: now),
|
||||
runsOutMinutes: pace?.etaSeconds.map { Int(($0 / 60).rounded()) },
|
||||
balanceRemainingUSD: balanceAmounts.remaining,
|
||||
balanceUsedUSD: balanceAmounts.used,
|
||||
costTodayUSD: costToday.flatMap(toUSD),
|
||||
cost30dUSD: cost?.last30DaysCostUSD.flatMap(toUSD)))
|
||||
}
|
||||
|
||||
private func representativeData(provider: UsageProvider) -> MenuBarLayoutRenderData {
|
||||
@@ -1009,6 +1041,9 @@ struct MenuBarLayoutPreview: View {
|
||||
let samplePace = { (window: RateWindow) -> String? in
|
||||
MenuBarDisplayText.paceText(pace: UsagePace.weekly(window: window, now: now))
|
||||
}
|
||||
let samplePaceDelta = { (window: RateWindow) -> Double? in
|
||||
UsagePace.weekly(window: window, now: now)?.deltaPercent.rounded()
|
||||
}
|
||||
return MenuBarLayoutRenderData(
|
||||
provider: provider,
|
||||
iconKey: "\(provider.rawValue)-representative",
|
||||
@@ -1031,7 +1066,17 @@ struct MenuBarLayoutPreview: View {
|
||||
// Provider-specific by design: only OpenRouter previews the Balance palette token.
|
||||
balance: provider == .openrouter ? "$12.34" : nil,
|
||||
costToday: "$1.25",
|
||||
cost30d: "$20.00")
|
||||
cost30d: "$20.00",
|
||||
metrics: MenuBarLayoutRenderMetrics(
|
||||
sessionPaceDelta: samplePaceDelta(session),
|
||||
weeklyPaceDelta: samplePaceDelta(weekly),
|
||||
automaticPaceDelta: samplePaceDelta(session),
|
||||
// 1d 16h == 40h, matching the sample `runsOut` text above.
|
||||
runsOutMinutes: 2400,
|
||||
balanceRemainingUSD: provider == .openrouter ? 12.34 : nil,
|
||||
balanceUsedUSD: provider == .openrouter ? 7.66 : nil,
|
||||
costTodayUSD: 1.25,
|
||||
cost30dUSD: 20))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,37 @@ struct MenuBarLayoutRenderWindow: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Numeric values behind the display strings on `MenuBarLayoutRenderData`. The strings are formatted
|
||||
/// for the menu bar and cannot be compared, so conditional predicates read these instead.
|
||||
///
|
||||
/// Pace deltas and `runsOutMinutes` are deliberately pre-rounded to the same granularity as the text
|
||||
/// they mirror (`MenuBarDisplayText.paceText` rounds to whole percentage points): an unrounded value
|
||||
/// drifts on every clock tick and would defeat `MenuBarLayoutTitleCache`, which keys on this struct.
|
||||
struct MenuBarLayoutRenderMetrics: Hashable {
|
||||
/// Signed pace delta in whole percentage points, matching the rendered `+11%` / `-8%` text.
|
||||
let sessionPaceDelta: Double?
|
||||
let weeklyPaceDelta: Double?
|
||||
let automaticPaceDelta: Double?
|
||||
/// Whole minutes until the projected run-out (`UsagePace.etaSeconds`).
|
||||
let runsOutMinutes: Int?
|
||||
/// USD amounts mirroring `balance` / `costToday` / `cost30d`. Provider amounts reported in another
|
||||
/// currency are converted to USD so thresholds do not move when the user's display currency does.
|
||||
let balanceRemainingUSD: Double?
|
||||
let balanceUsedUSD: Double?
|
||||
let costTodayUSD: Double?
|
||||
let cost30dUSD: Double?
|
||||
|
||||
static let unavailable = MenuBarLayoutRenderMetrics(
|
||||
sessionPaceDelta: nil,
|
||||
weeklyPaceDelta: nil,
|
||||
automaticPaceDelta: nil,
|
||||
runsOutMinutes: nil,
|
||||
balanceRemainingUSD: nil,
|
||||
balanceUsedUSD: nil,
|
||||
costTodayUSD: nil,
|
||||
cost30dUSD: nil)
|
||||
}
|
||||
|
||||
struct MenuBarLayoutRenderData: Hashable {
|
||||
let provider: UsageProvider
|
||||
let iconKey: String
|
||||
@@ -49,6 +80,8 @@ struct MenuBarLayoutRenderData: Hashable {
|
||||
let balance: String?
|
||||
let costToday: String?
|
||||
let cost30d: String?
|
||||
/// Numeric twins of the display strings above, for conditional predicates.
|
||||
let metrics: MenuBarLayoutRenderMetrics
|
||||
}
|
||||
|
||||
struct MenuBarLayoutRenderOptions: Hashable {
|
||||
@@ -103,6 +136,10 @@ struct MenuBarLayoutRenderKey: Hashable {
|
||||
let isStale: Bool
|
||||
let verticalAdjustment: Int
|
||||
let resetText: MenuBarLayoutResetText
|
||||
/// Truth value per conditional id. Predicates can read the clock (time to reset), so two renders
|
||||
/// with identical data and reset text can still need different branches; keying on the outcomes
|
||||
/// keeps the cache correct without putting `now` — which ticks constantly — into the key.
|
||||
let conditionalOutcomes: [UUID: Bool]
|
||||
}
|
||||
|
||||
struct MenuBarLayoutResetText: Hashable {
|
||||
@@ -191,6 +228,11 @@ final class MenuBarLayoutRenderer {
|
||||
-> MenuBarLayoutRenderedTitle
|
||||
{
|
||||
let resetText = MenuBarLayoutResetText(window: data.automatic, now: options.now)
|
||||
// Evaluate each conditional exactly once per render: the outcome is both a cache-key component
|
||||
// and what the token resolver needs, so re-testing per placement would only duplicate work.
|
||||
let outcomes = Dictionary(
|
||||
options.conditionals.map { ($0.id, $0.evaluatesTrue(data: data, now: options.now)) },
|
||||
uniquingKeysWith: { first, _ in first })
|
||||
let key = MenuBarLayoutRenderKey(
|
||||
layout: layout,
|
||||
data: data,
|
||||
@@ -202,9 +244,15 @@ final class MenuBarLayoutRenderer {
|
||||
isDebugApp: options.isDebugApp,
|
||||
isStale: options.isStale,
|
||||
verticalAdjustment: options.verticalAdjustment,
|
||||
resetText: resetText)
|
||||
resetText: resetText,
|
||||
conditionalOutcomes: outcomes)
|
||||
return self.cache.value(for: key) {
|
||||
Self.renderUncached(layout: layout, data: data, icon: icon, options: options)
|
||||
Self.renderUncached(
|
||||
layout: layout,
|
||||
data: data,
|
||||
icon: icon,
|
||||
options: options,
|
||||
outcomes: outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +264,8 @@ final class MenuBarLayoutRenderer {
|
||||
layout: MenuBarLayout,
|
||||
data: MenuBarLayoutRenderData,
|
||||
icon: NSImage?,
|
||||
options: MenuBarLayoutRenderOptions)
|
||||
options: MenuBarLayoutRenderOptions,
|
||||
outcomes: [UUID: Bool])
|
||||
-> MenuBarLayoutRenderedTitle
|
||||
{
|
||||
let conditionalsByID = Dictionary(
|
||||
@@ -229,7 +278,13 @@ final class MenuBarLayoutRenderer {
|
||||
// and announce a blank line to VoiceOver.
|
||||
let renderedLines = layout.lines
|
||||
.map { line in
|
||||
line.compactMap { Self.resolvedDisplayToken($0, data: data, conditionals: conditionalsByID) }
|
||||
line.compactMap {
|
||||
Self.resolvedDisplayToken(
|
||||
$0,
|
||||
data: data,
|
||||
conditionals: conditionalsByID,
|
||||
outcomes: outcomes)
|
||||
}
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
@@ -330,6 +385,7 @@ final class MenuBarLayoutRenderer {
|
||||
_ token: MenuBarLayoutToken,
|
||||
data: MenuBarLayoutRenderData,
|
||||
conditionals: [UUID: MenuBarLayoutConditional],
|
||||
outcomes: [UUID: Bool],
|
||||
depth: Int = 0)
|
||||
-> MenuBarLayoutToken?
|
||||
{
|
||||
@@ -337,10 +393,16 @@ final class MenuBarLayoutRenderer {
|
||||
case .hidden: return nil
|
||||
case let .conditional(id):
|
||||
guard depth < MenuBarLayoutToken.maxConditionalDepth,
|
||||
let conditional = conditionals[id]
|
||||
let conditional = conditionals[id],
|
||||
let isTrue = outcomes[id]
|
||||
else { return token }
|
||||
let branch = conditional.evaluatesTrue(data: data) ? conditional.thenToken : conditional.elseToken
|
||||
return self.resolvedDisplayToken(branch, data: data, conditionals: conditionals, depth: depth + 1)
|
||||
let branch = isTrue ? conditional.thenToken : conditional.elseToken
|
||||
return self.resolvedDisplayToken(
|
||||
branch,
|
||||
data: data,
|
||||
conditionals: conditionals,
|
||||
outcomes: outcomes,
|
||||
depth: depth + 1)
|
||||
default: return token
|
||||
}
|
||||
}
|
||||
@@ -742,12 +804,15 @@ final class MenuBarLayoutRenderer {
|
||||
}
|
||||
|
||||
extension MenuBarLayoutConditional {
|
||||
/// Left-fold over clauses; a predicate on a missing window (nil) evaluates false.
|
||||
func evaluatesTrue(data: MenuBarLayoutRenderData) -> Bool {
|
||||
/// Left-fold over clauses; a predicate whose datum is unavailable evaluates false.
|
||||
///
|
||||
/// `now` is a parameter rather than a field on `MenuBarLayoutRenderData` because the render data is
|
||||
/// a cache key: putting a constantly ticking clock in it would defeat `MenuBarLayoutTitleCache`.
|
||||
func evaluatesTrue(data: MenuBarLayoutRenderData, now: Date) -> Bool {
|
||||
guard let first = self.clauses.first else { return false }
|
||||
var result = Self.test(first.predicate, data: data)
|
||||
var result = Self.test(first.predicate, data: data, now: now)
|
||||
for clause in self.clauses.dropFirst() {
|
||||
let value = Self.test(clause.predicate, data: data)
|
||||
let value = Self.test(clause.predicate, data: data, now: now)
|
||||
switch clause.combinator {
|
||||
case .or: result = result || value
|
||||
case .and, .none: result = result && value
|
||||
@@ -756,17 +821,65 @@ extension MenuBarLayoutConditional {
|
||||
return result
|
||||
}
|
||||
|
||||
private static func test(_ predicate: MenuBarConditionalPredicate, data: MenuBarLayoutRenderData) -> Bool {
|
||||
guard let value = Self.value(for: predicate.metric, in: data) else { return false }
|
||||
private static func test(
|
||||
_ predicate: MenuBarConditionalPredicate,
|
||||
data: MenuBarLayoutRenderData,
|
||||
now: Date)
|
||||
-> Bool
|
||||
{
|
||||
guard let value = Self.value(for: predicate, in: data, now: now) else { return false }
|
||||
return predicate.comparison.evaluate(value, predicate.threshold)
|
||||
}
|
||||
|
||||
private static func value(for metric: MenuBarConditionalMetric, in data: MenuBarLayoutRenderData) -> Double? {
|
||||
switch metric {
|
||||
case .session: data.session?.usedPercent
|
||||
case .weekly: data.weekly?.usedPercent
|
||||
case .scopedWeekly: data.scopedWeekly?.usedPercent
|
||||
case .automatic: data.automatic?.usedPercent
|
||||
/// nil == the datum is unavailable, so the predicate evaluates false instead of comparing a
|
||||
/// fabricated zero. Percent and reset readings come from the render windows; pace, run-out, balance
|
||||
/// and cost come from `data.metrics`, whose display strings cannot be compared numerically.
|
||||
private static func value(
|
||||
for predicate: MenuBarConditionalPredicate,
|
||||
in data: MenuBarLayoutRenderData,
|
||||
now: Date)
|
||||
-> Double?
|
||||
{
|
||||
switch predicate.metric {
|
||||
case .session: self.percent(data.session, predicate.direction)
|
||||
case .weekly: self.percent(data.weekly, predicate.direction)
|
||||
case .scopedWeekly: self.percent(data.scopedWeekly, predicate.direction)
|
||||
case .automatic: self.percent(data.automatic, predicate.direction)
|
||||
case .primaryLane: self.percent(data.primary, predicate.direction)
|
||||
case .secondaryLane: self.percent(data.secondary, predicate.direction)
|
||||
case .tertiaryLane: self.percent(data.tertiary, predicate.direction)
|
||||
case .sessionResetsIn: self.hoursUntilReset(data.session, now: now)
|
||||
case .weeklyResetsIn: self.hoursUntilReset(data.weekly, now: now)
|
||||
case .scopedWeeklyResetsIn: self.hoursUntilReset(data.scopedWeekly, now: now)
|
||||
case .automaticResetsIn: self.hoursUntilReset(data.automatic, now: now)
|
||||
case .sessionPace: data.metrics.sessionPaceDelta
|
||||
case .weeklyPace: data.metrics.weeklyPaceDelta
|
||||
case .automaticPace: data.metrics.automaticPaceDelta
|
||||
case .runsOutIn: data.metrics.runsOutMinutes.map { Double($0) / 60 }
|
||||
case .balance: predicate.direction == .used
|
||||
? data.metrics.balanceUsedUSD
|
||||
: data.metrics.balanceRemainingUSD
|
||||
case .costToday: data.metrics.costTodayUSD
|
||||
case .cost30d: data.metrics.cost30dUSD
|
||||
}
|
||||
}
|
||||
|
||||
private static func percent(
|
||||
_ window: MenuBarLayoutRenderWindow?,
|
||||
_ direction: MenuBarConditionalDirection)
|
||||
-> Double?
|
||||
{
|
||||
guard let window else { return nil }
|
||||
return direction == .used ? window.usedPercent : window.remainingPercent
|
||||
}
|
||||
|
||||
/// Hours until the window resets. A window with no reset timestamp — or one whose reset already
|
||||
/// passed, meaning the snapshot has not caught up yet — has no countdown to compare, so the
|
||||
/// predicate evaluates false rather than firing a "resets soon" branch off stale data.
|
||||
private static func hoursUntilReset(_ window: MenuBarLayoutRenderWindow?, now: Date) -> Double? {
|
||||
guard let resetsAt = window?.resetsAt else { return nil }
|
||||
let seconds = resetsAt.timeIntervalSince(now)
|
||||
guard seconds > 0 else { return nil }
|
||||
return seconds / 3600
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,6 +1514,10 @@
|
||||
"menu_bar_layout_conditional_name" = "الاسم";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "مثال: فحص الجلسة";
|
||||
"menu_bar_layout_conditional_name_error" = "أدخل اسماً فريداً";
|
||||
"menu_bar_layout_conditional_used" = "مستخدم";
|
||||
"menu_bar_layout_conditional_remaining" = "متبقٍ";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "تتم إعادة تعيين %@ خلال";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "أسبوعي محدد النطاق";
|
||||
"menu_bar_layout_conditional_duplicate" = "تكرار";
|
||||
"menu_bar_layout_conditional_or" = "أو";
|
||||
"menu_bar_layout_conditional_add_condition" = "إضافة شرط";
|
||||
|
||||
@@ -1513,6 +1513,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nom";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "p. ex. Comprovació de la sessió";
|
||||
"menu_bar_layout_conditional_name_error" = "Introdueix un nom únic";
|
||||
"menu_bar_layout_conditional_used" = "utilitzat";
|
||||
"menu_bar_layout_conditional_remaining" = "restant";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ es reinicia en";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Setmanal amb àmbit";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplica";
|
||||
"menu_bar_layout_conditional_or" = "o";
|
||||
"menu_bar_layout_conditional_add_condition" = "Afegeix una condició";
|
||||
|
||||
@@ -1512,6 +1512,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Name";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "z. B. Sitzungsprüfung";
|
||||
"menu_bar_layout_conditional_name_error" = "Geben Sie einen eindeutigen Namen ein";
|
||||
"menu_bar_layout_conditional_used" = "genutzt";
|
||||
"menu_bar_layout_conditional_remaining" = "übrig";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ zurückgesetzt in";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Wöchentlich nach Bereich";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplizieren";
|
||||
"menu_bar_layout_conditional_summary" = "Wenn %1$@ dann %2$@ sonst %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (Kopie)";
|
||||
|
||||
@@ -1516,6 +1516,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Name";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "e.g. Session check";
|
||||
"menu_bar_layout_conditional_name_error" = "Enter a unique name";
|
||||
"menu_bar_layout_conditional_used" = "used";
|
||||
"menu_bar_layout_conditional_remaining" = "remaining";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ resets in";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Scoped weekly";
|
||||
"menu_bar_layout_conditional_summary" = "If %1$@ then %2$@ else %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (copy)";
|
||||
"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copy %2$d)";
|
||||
|
||||
@@ -1510,6 +1510,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nombre";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "p. ej. Comprobación de sesión";
|
||||
"menu_bar_layout_conditional_name_error" = "Introduce un nombre único";
|
||||
"menu_bar_layout_conditional_used" = "usado";
|
||||
"menu_bar_layout_conditional_remaining" = "restante";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ se reinicia en";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Semanal con ámbito";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplicar";
|
||||
"menu_bar_layout_conditional_summary" = "Si %1$@ entonces %2$@ si no %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (copia)";
|
||||
|
||||
@@ -1514,6 +1514,10 @@
|
||||
"menu_bar_layout_conditional_name" = "نام";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "مثلاً: بررسی جلسه";
|
||||
"menu_bar_layout_conditional_name_error" = "نامی منحصربهفرد وارد کنید";
|
||||
"menu_bar_layout_conditional_used" = "استفادهشده";
|
||||
"menu_bar_layout_conditional_remaining" = "باقیمانده";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "بازنشانی %@ در";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "هفتگی با دامنه";
|
||||
"menu_bar_layout_conditional_duplicate" = "تکرار";
|
||||
"menu_bar_layout_conditional_or" = "یا";
|
||||
"menu_bar_layout_conditional_add_condition" = "افزودن شرط";
|
||||
|
||||
@@ -1511,6 +1511,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nom";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "p. ex. Vérification de session";
|
||||
"menu_bar_layout_conditional_name_error" = "Saisissez un nom unique";
|
||||
"menu_bar_layout_conditional_used" = "utilisé";
|
||||
"menu_bar_layout_conditional_remaining" = "restant";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ réinitialisé dans";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Hebdomadaire ciblé";
|
||||
"menu_bar_layout_conditional_duplicate" = "Dupliquer";
|
||||
"menu_bar_layout_conditional_summary" = "Si %1$@ alors %2$@ sinon %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (copie)";
|
||||
|
||||
@@ -1511,6 +1511,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nome";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "p. ex. Comprobación de sesión";
|
||||
"menu_bar_layout_conditional_name_error" = "Introduce un nome único";
|
||||
"menu_bar_layout_conditional_used" = "usado";
|
||||
"menu_bar_layout_conditional_remaining" = "restante";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ reiníciase en";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Semanal con ámbito";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplicar";
|
||||
"menu_bar_layout_conditional_summary" = "Se %1$@ entón %2$@ se non %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (copia)";
|
||||
|
||||
@@ -1513,6 +1513,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nama";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "mis. Pemeriksaan sesi";
|
||||
"menu_bar_layout_conditional_name_error" = "Masukkan nama yang unik";
|
||||
"menu_bar_layout_conditional_used" = "terpakai";
|
||||
"menu_bar_layout_conditional_remaining" = "tersisa";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ disetel ulang dalam";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Mingguan terbatas";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplikat";
|
||||
"menu_bar_layout_conditional_or" = "atau";
|
||||
"menu_bar_layout_conditional_add_condition" = "Tambah Kondisi";
|
||||
|
||||
@@ -1515,6 +1515,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nome";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "ad es. Controllo sessione";
|
||||
"menu_bar_layout_conditional_name_error" = "Inserisci un nome univoco";
|
||||
"menu_bar_layout_conditional_used" = "usato";
|
||||
"menu_bar_layout_conditional_remaining" = "rimanente";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ si azzera in";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Settimanale con ambito";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplica";
|
||||
"menu_bar_layout_conditional_summary" = "Se %1$@ allora %2$@ altrimenti %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (copia)";
|
||||
|
||||
@@ -1510,6 +1510,10 @@
|
||||
"menu_bar_layout_conditional_name" = "名前";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "例:セッションチェック";
|
||||
"menu_bar_layout_conditional_name_error" = "一意の名前を入力してください";
|
||||
"menu_bar_layout_conditional_used" = "使用";
|
||||
"menu_bar_layout_conditional_remaining" = "残り";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ のリセットまで";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "スコープ別週間";
|
||||
"menu_bar_layout_conditional_duplicate" = "複製";
|
||||
"menu_bar_layout_conditional_or" = "または";
|
||||
"menu_bar_layout_conditional_add_condition" = "条件を追加";
|
||||
|
||||
@@ -1481,6 +1481,10 @@
|
||||
"menu_bar_layout_conditional_name" = "이름";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "예: 세션 확인";
|
||||
"menu_bar_layout_conditional_name_error" = "고유한 이름을 입력하세요";
|
||||
"menu_bar_layout_conditional_used" = "사용";
|
||||
"menu_bar_layout_conditional_remaining" = "남음";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ 재설정까지";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "범위별 주간";
|
||||
"menu_bar_layout_conditional_duplicate" = "복제";
|
||||
"menu_bar_layout_conditional_summary" = "만약 %1$@이면 %2$@을 표시하고, 그렇지 않으면 %3$@을 표시";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (복사본)";
|
||||
|
||||
@@ -1511,6 +1511,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Naam";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "bijv. Sessiecontrole";
|
||||
"menu_bar_layout_conditional_name_error" = "Voer een unieke naam in";
|
||||
"menu_bar_layout_conditional_used" = "gebruikt";
|
||||
"menu_bar_layout_conditional_remaining" = "resterend";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ reset over";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Wekelijks per bereik";
|
||||
"menu_bar_layout_conditional_duplicate" = "Dupliceren";
|
||||
"menu_bar_layout_conditional_summary" = "Als %1$@ dan %2$@ anders %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (kopie)";
|
||||
|
||||
@@ -1513,6 +1513,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nazwa";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "np. Sprawdzenie sesji";
|
||||
"menu_bar_layout_conditional_name_error" = "Wprowadź unikalną nazwę";
|
||||
"menu_bar_layout_conditional_used" = "użyte";
|
||||
"menu_bar_layout_conditional_remaining" = "pozostałe";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ zeruje się za";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Tygodniowe wg zakresu";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplikuj";
|
||||
"menu_bar_layout_conditional_or" = "lub";
|
||||
"menu_bar_layout_conditional_add_condition" = "Dodaj warunek";
|
||||
|
||||
@@ -1512,6 +1512,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Nome";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "ex.: Verificação de sessão";
|
||||
"menu_bar_layout_conditional_name_error" = "Digite um nome exclusivo";
|
||||
"menu_bar_layout_conditional_used" = "usado";
|
||||
"menu_bar_layout_conditional_remaining" = "restante";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ reinicia em";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Semanal por escopo";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplicar";
|
||||
"menu_bar_layout_conditional_summary" = "Se %1$@ então %2$@, senão %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (cópia)";
|
||||
|
||||
@@ -1513,6 +1513,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Название";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "напр., Проверка сеанса";
|
||||
"menu_bar_layout_conditional_name_error" = "Введите уникальное название";
|
||||
"menu_bar_layout_conditional_used" = "использовано";
|
||||
"menu_bar_layout_conditional_remaining" = "осталось";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "сброс %@ через";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Недельный по области";
|
||||
"menu_bar_layout_conditional_duplicate" = "Дублировать";
|
||||
"menu_bar_layout_conditional_summary" = "Если %1$@, то показать %2$@, иначе показать %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (копия)";
|
||||
|
||||
@@ -1511,6 +1511,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Namn";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "t.ex. Sessionskontroll";
|
||||
"menu_bar_layout_conditional_name_error" = "Ange ett unikt namn";
|
||||
"menu_bar_layout_conditional_used" = "använt";
|
||||
"menu_bar_layout_conditional_remaining" = "återstår";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ återställs om";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Avgränsad vecka";
|
||||
"menu_bar_layout_conditional_duplicate" = "Duplicera";
|
||||
"menu_bar_layout_conditional_summary" = "Om %1$@ visa %2$@ annars %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (kopia)";
|
||||
|
||||
@@ -1516,6 +1516,10 @@
|
||||
"menu_bar_layout_conditional_name" = "ชื่อ";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "เช่น ตรวจสอบเซสชัน";
|
||||
"menu_bar_layout_conditional_name_error" = "ป้อนชื่อที่ไม่ซ้ำกัน";
|
||||
"menu_bar_layout_conditional_used" = "ที่ใช้";
|
||||
"menu_bar_layout_conditional_remaining" = "ที่เหลือ";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ รีเซ็ตใน";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "รายสัปดาห์ตามขอบเขต";
|
||||
"menu_bar_layout_conditional_duplicate" = "ทำสำเนา";
|
||||
"menu_bar_layout_conditional_summary" = "ถ้า %1$@ จากนั้น %2$@ มิฉะนั้น %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (สำเนา)";
|
||||
|
||||
@@ -1514,6 +1514,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Ad";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "örn. Oturum kontrolü";
|
||||
"menu_bar_layout_conditional_name_error" = "Benzersiz bir ad girin";
|
||||
"menu_bar_layout_conditional_used" = "kullanılan";
|
||||
"menu_bar_layout_conditional_remaining" = "kalan";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ sıfırlanma süresi";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Kapsama göre haftalık";
|
||||
"menu_bar_layout_conditional_duplicate" = "Çoğalt";
|
||||
"menu_bar_layout_conditional_summary" = "Eğer %1$@ ise %2$@, değilse %3$@ göster";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (kopya)";
|
||||
|
||||
@@ -1512,6 +1512,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Назва";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "напр., Перевірка сеансу";
|
||||
"menu_bar_layout_conditional_name_error" = "Введіть унікальну назву";
|
||||
"menu_bar_layout_conditional_used" = "використано";
|
||||
"menu_bar_layout_conditional_remaining" = "залишилось";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "скидання %@ через";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Тижневий за областю";
|
||||
"menu_bar_layout_conditional_duplicate" = "Дублювати";
|
||||
"menu_bar_layout_conditional_summary" = "Якщо %1$@ тоді %2$@ інакше %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (копія)";
|
||||
|
||||
@@ -1513,6 +1513,10 @@
|
||||
"menu_bar_layout_conditional_name" = "Tên";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "ví dụ: Kiểm tra phiên";
|
||||
"menu_bar_layout_conditional_name_error" = "Nhập tên duy nhất";
|
||||
"menu_bar_layout_conditional_used" = "đã dùng";
|
||||
"menu_bar_layout_conditional_remaining" = "còn lại";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ đặt lại sau";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "Hàng tuần theo phạm vi";
|
||||
"menu_bar_layout_conditional_duplicate" = "Nhân bản";
|
||||
"menu_bar_layout_conditional_summary" = "Nếu %1$@ thì %2$@ ngược lại %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@ (bản sao)";
|
||||
|
||||
@@ -1490,6 +1490,10 @@
|
||||
"menu_bar_layout_conditional_name" = "名称";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "例如:会话检查";
|
||||
"menu_bar_layout_conditional_name_error" = "请输入唯一名称";
|
||||
"menu_bar_layout_conditional_used" = "已用";
|
||||
"menu_bar_layout_conditional_remaining" = "剩余";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ 重置倒计时";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "范围每周";
|
||||
"menu_bar_layout_conditional_duplicate" = "复制";
|
||||
"menu_bar_layout_conditional_summary" = "如果 %1$@ 则 %2$@ 否则 %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@(副本)";
|
||||
|
||||
@@ -1543,6 +1543,10 @@
|
||||
"menu_bar_layout_conditional_name" = "名稱";
|
||||
"menu_bar_layout_conditional_name_placeholder" = "例如:工作階段檢查";
|
||||
"menu_bar_layout_conditional_name_error" = "請輸入唯一名稱";
|
||||
"menu_bar_layout_conditional_used" = "已用";
|
||||
"menu_bar_layout_conditional_remaining" = "剩餘";
|
||||
"menu_bar_layout_conditional_metric_resets_in" = "%@ 重置倒數";
|
||||
"menu_bar_layout_conditional_metric_scoped_weekly" = "範圍每週";
|
||||
"menu_bar_layout_conditional_duplicate" = "複製";
|
||||
"menu_bar_layout_conditional_summary" = "如果 %1$@ 則 %2$@ 否則 %3$@";
|
||||
"menu_bar_layout_conditional_copy_name" = "%@(副本)";
|
||||
|
||||
@@ -527,8 +527,11 @@ extension SettingsStore {
|
||||
}
|
||||
|
||||
private func persistMenuBarLayoutConditionals() {
|
||||
guard let data = try? JSONEncoder().encode(self.defaultsState.menuBarLayoutConditionals) else { return }
|
||||
self.userDefaults.set(data, forKey: "menuBarLayoutConditionals")
|
||||
guard let blobs = try? MenuBarLayoutPersistence
|
||||
.encodedLibrary(self.defaultsState.menuBarLayoutConditionals)
|
||||
else { return }
|
||||
self.userDefaults.set(blobs.current, forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)
|
||||
self.userDefaults.set(blobs.legacy, forKey: MenuBarLayoutUserDefaultsKey.conditionals)
|
||||
}
|
||||
|
||||
private func persistMenuBarLayoutOverrides() {
|
||||
|
||||
@@ -875,12 +875,23 @@ extension SettingsStore {
|
||||
}
|
||||
|
||||
private static func loadMenuBarLayoutConditionals(userDefaults: UserDefaults) -> [MenuBarLayoutConditional] {
|
||||
// A missing key means a fresh install, so hand back the shipped library. Any edit, add, or
|
||||
// removal writes the key, so a library the user deliberately emptied is never reseeded.
|
||||
guard let data = userDefaults.data(forKey: "menuBarLayoutConditionals") else {
|
||||
return MenuBarLayoutConditional.shippedLibrary()
|
||||
}
|
||||
return (try? JSONDecoder().decode([MenuBarLayoutConditional].self, from: data)) ?? []
|
||||
// Neither key present means a fresh install, so hand back the shipped library. Any edit, add, or
|
||||
// removal writes both keys, so a library the user deliberately emptied is never reseeded.
|
||||
MenuBarLayoutPersistence.loadLibrary(
|
||||
current: self.decodeMenuBarLayoutConditionals(
|
||||
userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)),
|
||||
legacy: self.decodeMenuBarLayoutConditionals(
|
||||
userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionals)),
|
||||
into: userDefaults)
|
||||
?? MenuBarLayoutConditional.shippedLibrary()
|
||||
}
|
||||
|
||||
/// Element-wise so one entry this build cannot understand — a library written by a newer release —
|
||||
/// is dropped on its own instead of emptying the whole array.
|
||||
private static func decodeMenuBarLayoutConditionals(_ data: Data?) -> [MenuBarLayoutConditional]? {
|
||||
guard let data else { return nil }
|
||||
return (try? JSONDecoder().decode([LenientMenuBarLayoutConditional].self, from: data))?
|
||||
.compactMap(\.value)
|
||||
}
|
||||
|
||||
private static func loadMenuBarLayoutOverrides(userDefaults: UserDefaults) -> [String: MenuBarLayout] {
|
||||
|
||||
@@ -30,6 +30,14 @@ extension StatusItemController {
|
||||
if tokens.contains(.resetAbsolute) {
|
||||
absoluteResetDates.append(contentsOf: resetDates)
|
||||
}
|
||||
delays += self.menuBarConditionalResetDelays(
|
||||
provider: provider,
|
||||
resolution: resolution,
|
||||
now: now)
|
||||
delays += self.menuBarConditionalElapsedDelays(
|
||||
provider: provider,
|
||||
resolution: resolution,
|
||||
now: now)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -112,6 +120,41 @@ extension StatusItemController {
|
||||
}.min()
|
||||
}
|
||||
|
||||
/// Wake at `resetsAt - threshold` for every placed reset-countdown predicate. Nothing else in the
|
||||
/// layout changes at that instant, so without this the branch would only flip on the next unrelated
|
||||
/// refresh. Run-out predicates are deliberately excluded: their estimate drifts with usage rather
|
||||
/// than crossing a fixed instant, and `menuBarWeeklyPaceRefreshDelays` already covers that lane.
|
||||
private func menuBarConditionalResetDelays(
|
||||
provider: UsageProvider,
|
||||
resolution: MenuBarLayoutResolution,
|
||||
now: Date)
|
||||
-> [TimeInterval]
|
||||
{
|
||||
let predicates = resolution.layout
|
||||
.referencedConditionalPredicates(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.filter { $0.metric.kind == .hours && $0.metric != .runsOutIn }
|
||||
guard !predicates.isEmpty else { return [] }
|
||||
|
||||
let snapshot = self.store.menuBarSnapshot(for: provider.instanceID)
|
||||
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now)
|
||||
let scopedWeekly = MenuBarLayoutSemanticWindowResolver
|
||||
.scopedWeeklyNamedWindow(snapshot: snapshot)?.window
|
||||
return predicates.compactMap { predicate -> TimeInterval? in
|
||||
let window: RateWindow? = switch predicate.metric {
|
||||
case .sessionResetsIn: windows.session
|
||||
case .weeklyResetsIn: windows.weekly
|
||||
case .scopedWeeklyResetsIn: scopedWeekly
|
||||
case .automaticResetsIn: windows.automatic
|
||||
default: nil
|
||||
}
|
||||
guard let resetsAt = window?.resetsAt else { return nil }
|
||||
let flipAt = resetsAt.addingTimeInterval(-predicate.threshold * 3600)
|
||||
let delay = flipAt.timeIntervalSince(now)
|
||||
guard delay > 0 else { return nil }
|
||||
return delay + Self.menuBarCountdownRefreshEpsilon
|
||||
}
|
||||
}
|
||||
|
||||
private func menuBarRefreshProviders() -> [UsageProvider] {
|
||||
if self.shouldMergeIcons {
|
||||
return [self.primaryProviderForUnifiedIcon()]
|
||||
@@ -151,14 +194,18 @@ extension StatusItemController {
|
||||
providers.compactMap { provider in
|
||||
let resolution = self.settings.menuBarLayoutResolution(for: provider)
|
||||
guard !resolution.usesLegacyRendering,
|
||||
self.settings.menuBarIconStyle == .iconAndPercent,
|
||||
resolution.layout
|
||||
.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.contains(where: {
|
||||
if case .pace(window: .weekly) = $0 { return true }
|
||||
return false
|
||||
})
|
||||
self.settings.menuBarIconStyle == .iconAndPercent
|
||||
else { return nil }
|
||||
let showsWeeklyPace = resolution.layout
|
||||
.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.contains(where: {
|
||||
if case .pace(window: .weekly) = $0 { return true }
|
||||
return false
|
||||
})
|
||||
// A predicate reads the same pace value with no token to detect, so it needs the same
|
||||
// eligibility wake-up.
|
||||
|| self.referencedConditionalMetrics(resolution: resolution).contains(.weeklyPace)
|
||||
guard showsWeeklyPace else { return nil }
|
||||
let snapshot = self.store.menuBarSnapshot(for: provider.instanceID)
|
||||
guard let window = self.menuBarLayoutWindows(
|
||||
provider: provider,
|
||||
@@ -170,6 +217,37 @@ extension StatusItemController {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pace or run-out predicate compares a clock-derived value, so it needs a tick even when no token
|
||||
/// does. `menuBarWeeklyPaceRefreshDelays` only wakes on the one-shot pace-eligibility boundary, so a
|
||||
/// predicate-only layout would otherwise keep the branch that was true when the value last moved.
|
||||
///
|
||||
/// 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.
|
||||
private func menuBarConditionalElapsedDelays(
|
||||
provider: UsageProvider,
|
||||
resolution: MenuBarLayoutResolution,
|
||||
now: Date)
|
||||
-> [TimeInterval]
|
||||
{
|
||||
let metrics = self.referencedConditionalMetrics(resolution: resolution)
|
||||
guard metrics.contains(where: \.isClockDerivedRate) else { return [] }
|
||||
let secondsIntoMinute = now.timeIntervalSince1970.truncatingRemainder(dividingBy: 60)
|
||||
return [max(
|
||||
Self.menuBarCountdownRefreshEpsilon,
|
||||
60 - secondsIntoMinute + Self.menuBarCountdownRefreshEpsilon)]
|
||||
}
|
||||
|
||||
/// Metrics every conditional the layout places reads.
|
||||
func referencedConditionalMetrics(
|
||||
resolution: MenuBarLayoutResolution)
|
||||
-> Set<MenuBarConditionalMetric>
|
||||
{
|
||||
Set(resolution.layout
|
||||
.referencedConditionalPredicates(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.map(\.metric))
|
||||
}
|
||||
|
||||
func observeMenuBarTimeEnvironmentChanges() {
|
||||
for name in [
|
||||
Notification.Name.NSSystemClockDidChange,
|
||||
|
||||
@@ -60,6 +60,9 @@ extension StatusItemController {
|
||||
let layoutLaneSignature = showBrandPercent
|
||||
? self.storedMenuBarLayoutLaneSignature(for: provider, snapshot: snapshot)
|
||||
: nil
|
||||
let layoutConditionalWindowSignature = showBrandPercent
|
||||
? self.storedMenuBarLayoutConditionalWindowSignature(for: provider, snapshot: snapshot)
|
||||
: nil
|
||||
|
||||
return [
|
||||
provider.rawValue,
|
||||
@@ -79,6 +82,7 @@ extension StatusItemController {
|
||||
"layoutPace=\(layoutPaceSignature ?? "nil")",
|
||||
"layoutBalance=\(layoutBalanceSignature ?? "nil")",
|
||||
"layoutLanes=\(layoutLaneSignature ?? "nil")",
|
||||
"layoutCondWindows=\(layoutConditionalWindowSignature ?? "nil")",
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
@@ -104,14 +108,19 @@ extension StatusItemController {
|
||||
guard !resolution.usesLegacyRendering else { return nil }
|
||||
|
||||
let tokens = resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
let showsToday = tokens.contains(.costToday)
|
||||
let showsLast30Days = tokens.contains(.cost30d)
|
||||
let metrics = self.referencedConditionalMetrics(resolution: resolution)
|
||||
let showsToday = tokens.contains(.costToday) || metrics.contains(.costToday)
|
||||
let showsLast30Days = tokens.contains(.cost30d) || metrics.contains(.cost30d)
|
||||
guard showsToday || showsLast30Days else { return nil }
|
||||
|
||||
let costs = self.menuBarLayoutCostStrings(provider: provider)
|
||||
let costs = self.menuBarLayoutCosts(provider: provider)
|
||||
return [
|
||||
"today=\(showsToday ? costs.today ?? "nil" : "unused")",
|
||||
"last30Days=\(showsLast30Days ? costs.last30Days ?? "nil" : "unused")",
|
||||
// Predicates compare the unrounded amounts, and two token-cost updates can cross a
|
||||
// threshold while both format to the same cent, so a conditional also signs the numbers.
|
||||
"todayUSD=\(metrics.contains(.costToday) ? Self.exactSignatureValue(costs.todayUSD) : "unused")",
|
||||
"last30DaysUSD=\(metrics.contains(.cost30d) ? Self.exactSignatureValue(costs.last30DaysUSD) : "unused")",
|
||||
].joined(separator: ",")
|
||||
}
|
||||
|
||||
@@ -121,17 +130,30 @@ extension StatusItemController {
|
||||
-> String?
|
||||
{
|
||||
let resolution = self.settings.menuBarLayoutResolution(for: provider)
|
||||
guard !resolution.usesLegacyRendering,
|
||||
resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.contains(.balance)
|
||||
else { return nil }
|
||||
return MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot)
|
||||
guard !resolution.usesLegacyRendering else { return nil }
|
||||
let showsBalance = resolution.layout
|
||||
.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.contains(.balance)
|
||||
|| self.referencedConditionalMetrics(resolution: resolution).contains(.balance)
|
||||
guard showsBalance else { return nil }
|
||||
// The rendered text only carries the remaining row. A `balance used` predicate reads the "Used"
|
||||
// row instead, which no display token surfaces, so sign both amounts exactly.
|
||||
let amounts = MenuBarLayoutBalanceResolver.balanceAmountsUSD(provider: provider, snapshot: snapshot)
|
||||
return [
|
||||
"text=\(MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot) ?? "nil")",
|
||||
"remaining=\(Self.exactSignatureValue(amounts.remaining))",
|
||||
"used=\(Self.exactSignatureValue(amounts.used))",
|
||||
].joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Pace tokens change with the historical dataset, the work-day setting, and the clock — none of
|
||||
/// which move the percent fields above. Without this contribution a `historicalPaceRevision` bump
|
||||
/// wakes the observer but leaves the signature unchanged, so a custom pace token would keep its
|
||||
/// stale value until an unrelated icon change forces a redraw.
|
||||
///
|
||||
/// Conditional predicates on pace and run-out have the same dependency with no token to detect, so
|
||||
/// they widen the window set and contribute the run-out estimate itself: `runsOutMinutes` moves at
|
||||
/// minute granularity while the pace text only moves at whole-percent granularity.
|
||||
private func storedMenuBarLayoutPaceSignature(
|
||||
for provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?)
|
||||
@@ -140,16 +162,22 @@ extension StatusItemController {
|
||||
let resolution = self.settings.menuBarLayoutResolution(for: provider)
|
||||
guard !resolution.usesLegacyRendering else { return nil }
|
||||
|
||||
let paceWindows = Set(resolution.layout
|
||||
let metrics = self.referencedConditionalMetrics(resolution: resolution)
|
||||
var paceWindows = Set(resolution.layout
|
||||
.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.compactMap { token -> PercentWindow? in
|
||||
guard case let .pace(window) = token else { return nil }
|
||||
return window
|
||||
})
|
||||
guard !paceWindows.isEmpty else { return nil }
|
||||
if metrics.contains(.sessionPace) { paceWindows.insert(.session) }
|
||||
if metrics.contains(.weeklyPace) { paceWindows.insert(.weekly) }
|
||||
if metrics.contains(.automaticPace) { paceWindows.insert(.automatic) }
|
||||
let needsRunsOut = metrics.contains(.runsOutIn)
|
||||
guard !paceWindows.isEmpty || needsRunsOut else { return nil }
|
||||
|
||||
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date())
|
||||
return PercentWindow.allCases
|
||||
let now = Date()
|
||||
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now)
|
||||
var components = PercentWindow.allCases
|
||||
.filter(paceWindows.contains)
|
||||
.map { percentWindow in
|
||||
let window: RateWindow? = switch percentWindow {
|
||||
@@ -161,16 +189,27 @@ extension StatusItemController {
|
||||
let pace = self.store.menuBarLayoutPaceText(
|
||||
provider: provider,
|
||||
window: window,
|
||||
now: now,
|
||||
minimumElapsedPercent: percentWindow == .weekly ? 1 : nil)
|
||||
return "\(percentWindow.rawValue)=\(pace ?? "nil")"
|
||||
}
|
||||
.joined(separator: ",")
|
||||
if needsRunsOut {
|
||||
let runsOutMinutes = (windows.weekly ?? windows.automatic)
|
||||
.flatMap { self.store.weeklyPace(provider: provider, window: $0, now: now) }
|
||||
.flatMap(\.etaSeconds)
|
||||
.map { Int(($0 / 60).rounded()) }
|
||||
components.append("runsOut=\(runsOutMinutes.map { String($0) } ?? "nil")")
|
||||
}
|
||||
return components.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Direct lane tokens read `snapshot.tertiary` independently of the legacy icon percent
|
||||
/// resolver. Without this contribution a Third Party (or equivalent) lane can move while the
|
||||
/// observation signature stays put, so the custom token keeps a stale percent until an
|
||||
/// unrelated icon change forces a redraw.
|
||||
///
|
||||
/// This covers what the layout *renders*, so it signs the displayed reading.
|
||||
/// `storedMenuBarLayoutConditionalWindowSignature` covers what conditionals *read*.
|
||||
private func storedMenuBarLayoutLaneSignature(
|
||||
for provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?)
|
||||
@@ -179,7 +218,11 @@ extension StatusItemController {
|
||||
let resolution = self.settings.menuBarLayoutResolution(for: provider)
|
||||
guard !resolution.usesLegacyRendering else { return nil }
|
||||
|
||||
let lanes = resolution.layout.selectedLanes
|
||||
// `selectedLanes` never walks conditional branches, so read the flattened tokens instead: a
|
||||
// `lanePercent` inside a then/else branch renders and must be signed like any placed token.
|
||||
let lanes = Set(resolution.layout
|
||||
.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals)
|
||||
.compactMap(\.selectedLane))
|
||||
guard !lanes.isEmpty else { return nil }
|
||||
|
||||
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date())
|
||||
@@ -187,14 +230,68 @@ extension StatusItemController {
|
||||
return MenuBarLayoutLane.allCases
|
||||
.filter(lanes.contains)
|
||||
.map { lane in
|
||||
let window: RateWindow? = switch lane {
|
||||
case .primary: windows.primary
|
||||
case .secondary: windows.secondary
|
||||
case .tertiary: windows.tertiary
|
||||
}
|
||||
let percent = showUsed ? window?.usedPercent : window?.remainingPercent
|
||||
let percent = showUsed
|
||||
? Self.laneWindow(lane, in: windows)?.usedPercent
|
||||
: Self.laneWindow(lane, in: windows)?.remainingPercent
|
||||
return "\(lane.rawValue)=\(Self.iconSignatureValue(percent))"
|
||||
}
|
||||
.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Window readings conditional predicates depend on but no display token exposes.
|
||||
///
|
||||
/// The rendered percent follows `usageBarsShowUsed` and `remainingPercent` clamps at zero, while
|
||||
/// `RateWindow.usedPercent` deliberately preserves raw over-quota values — so a used-direction
|
||||
/// predicate such as `primaryLane > 105%` can flip from 104% to 106% while the displayed reading
|
||||
/// stays pinned at `0.000`. Countdown predicates depend on `resetsAt`, which no token contributes at
|
||||
/// all. Signing the raw used percent covers both directions, since remaining is derived from it.
|
||||
private func storedMenuBarLayoutConditionalWindowSignature(
|
||||
for provider: UsageProvider,
|
||||
snapshot: UsageSnapshot?)
|
||||
-> String?
|
||||
{
|
||||
let resolution = self.settings.menuBarLayoutResolution(for: provider)
|
||||
guard !resolution.usesLegacyRendering else { return nil }
|
||||
let metrics = self.referencedConditionalMetrics(resolution: resolution)
|
||||
.filter(\.readsRateWindow)
|
||||
guard !metrics.isEmpty else { return nil }
|
||||
|
||||
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date())
|
||||
let scopedWeekly = MenuBarLayoutSemanticWindowResolver
|
||||
.scopedWeeklyNamedWindow(snapshot: snapshot)?.window
|
||||
return MenuBarConditionalMetric.allCases
|
||||
.filter(metrics.contains)
|
||||
.map { metric in
|
||||
let window: RateWindow? = switch metric {
|
||||
case .session, .sessionResetsIn: windows.session
|
||||
case .weekly, .weeklyResetsIn: windows.weekly
|
||||
case .scopedWeekly, .scopedWeeklyResetsIn: scopedWeekly
|
||||
case .automatic, .automaticResetsIn: windows.automatic
|
||||
case .primaryLane: windows.primary
|
||||
case .secondaryLane: windows.secondary
|
||||
case .tertiaryLane: windows.tertiary
|
||||
default: nil
|
||||
}
|
||||
let resetsAt = window?.resetsAt?.timeIntervalSince1970
|
||||
return "\(metric.rawValue)=\(Self.iconSignatureValue(window?.usedPercent))" +
|
||||
"@\(Self.exactSignatureValue(resetsAt))"
|
||||
}
|
||||
.joined(separator: ",")
|
||||
}
|
||||
|
||||
private static func laneWindow(_ lane: MenuBarLayoutLane, in windows: MenuBarLayoutWindows) -> RateWindow? {
|
||||
switch lane {
|
||||
case .primary: windows.primary
|
||||
case .secondary: windows.secondary
|
||||
case .tertiary: windows.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
/// Lossless signature component. `iconSignatureValue` rounds to three decimals, which is right for a
|
||||
/// rendered percentage but can hide a threshold crossing in an unrounded currency amount or an
|
||||
/// epoch timestamp.
|
||||
private static func exactSignatureValue(_ value: Double?) -> String {
|
||||
guard let value else { return "nil" }
|
||||
return String(value.bitPattern)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ struct MenuBarLayoutWindows {
|
||||
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,
|
||||
@@ -74,15 +84,20 @@ extension StatusItemController {
|
||||
let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now)
|
||||
let scopedNamed = MenuBarLayoutSemanticWindowResolver.scopedWeeklyNamedWindow(snapshot: snapshot)
|
||||
let paceWindow = windows.weekly ?? windows.automatic
|
||||
let runsOut = paceWindow
|
||||
.flatMap {
|
||||
self.store.weeklyPace(
|
||||
provider: provider,
|
||||
window: $0,
|
||||
now: now)
|
||||
}
|
||||
// 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 costStrings = self.menuBarLayoutCostStrings(provider: provider, now: now)
|
||||
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)
|
||||
@@ -120,8 +135,27 @@ extension StatusItemController {
|
||||
now: now),
|
||||
runsOut: runsOut,
|
||||
balance: MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot),
|
||||
costToday: costStrings.today,
|
||||
cost30d: costStrings.last30Days)
|
||||
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? {
|
||||
@@ -132,28 +166,38 @@ extension StatusItemController {
|
||||
: rawAccountLabel
|
||||
}
|
||||
|
||||
func menuBarLayoutCostStrings(
|
||||
func menuBarLayoutCosts(
|
||||
provider: UsageProvider,
|
||||
now: Date = .init())
|
||||
-> (today: String?, last30Days: String?)
|
||||
-> MenuBarLayoutCostValues
|
||||
{
|
||||
let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot
|
||||
let sourceCurrencyCode = snapshot?.currencyCode ?? "USD"
|
||||
let preferredCurrencyCode = self.settings.preferredCurrencyCode
|
||||
|
||||
let today = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now).map {
|
||||
let todayAmount = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now)
|
||||
let last30DaysAmount = snapshot?.last30DaysCostUSD
|
||||
let display = { (value: Double) in
|
||||
UsageFormatter.convertedCostString(
|
||||
$0,
|
||||
value,
|
||||
preferredCurrency: preferredCurrencyCode,
|
||||
providerCurrency: sourceCurrencyCode)
|
||||
}
|
||||
let last30Days = snapshot?.last30DaysCostUSD.map {
|
||||
UsageFormatter.convertedCostString(
|
||||
$0,
|
||||
preferredCurrency: preferredCurrencyCode,
|
||||
// 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 (today, last30Days)
|
||||
return MenuBarLayoutCostValues(
|
||||
today: todayAmount.map(display),
|
||||
last30Days: last30DaysAmount.map(display),
|
||||
todayUSD: todayAmount.flatMap(toUSD),
|
||||
last30DaysUSD: last30DaysAmount.flatMap(toUSD))
|
||||
}
|
||||
|
||||
func menuBarLayoutWindows(
|
||||
|
||||
@@ -84,6 +84,28 @@ extension UsageStore {
|
||||
.flatMap { MenuBarDisplayText.paceText(pace: $0) }
|
||||
}
|
||||
|
||||
/// Numeric twin of `menuBarLayoutPaceText`, rounded to whole percentage points like the text so a
|
||||
/// conditional predicate always compares exactly the value the menu bar shows.
|
||||
func menuBarLayoutPaceDelta(
|
||||
provider: UsageProvider,
|
||||
window: RateWindow?,
|
||||
now: Date = .init(),
|
||||
minimumExpectedPercent: Double = 3,
|
||||
minimumElapsedPercent: Double? = nil)
|
||||
-> Double?
|
||||
{
|
||||
window
|
||||
.flatMap {
|
||||
self.weeklyPace(
|
||||
provider: provider,
|
||||
window: $0,
|
||||
now: now,
|
||||
minimumExpectedPercent: minimumExpectedPercent,
|
||||
minimumElapsedPercent: minimumElapsedPercent)
|
||||
}
|
||||
.map { $0.deltaPercent.rounded() }
|
||||
}
|
||||
|
||||
/// A learned Codex curve can stay flat near the start of a weekly window even as the window
|
||||
/// itself advances. The weekly menu token uses elapsed progress as an eligibility fallback,
|
||||
/// while the returned pace still retains the learned expected-use value.
|
||||
|
||||
@@ -512,6 +512,99 @@ struct MenuBarCountdownRefreshTests {
|
||||
#expect(controller._test_isMenuBarCountdownRefreshScheduled())
|
||||
}
|
||||
|
||||
/// A pace or run-out predicate compares a clock-derived value, so it needs a tick even when the
|
||||
/// layout carries no pace or reset token to trigger the token-gated schedulers.
|
||||
@Test(arguments: [MenuBarConditionalMetric.runsOutIn, .weeklyPace, .sessionPace, .automaticPace])
|
||||
func `predicate-only clock-derived conditional schedules a refresh`(metric: MenuBarConditionalMetric) {
|
||||
let controller = Self.makePredicateOnlyController(
|
||||
suite: "MenuBarCountdownRefreshTests-predicate-only-\(metric.rawValue)",
|
||||
metric: metric)
|
||||
defer { controller.releaseStatusItemsForTesting() }
|
||||
#expect(controller._test_isMenuBarCountdownRefreshScheduled())
|
||||
}
|
||||
|
||||
/// Money predicates move only when new provider data arrives, so they must not pin a clock tick.
|
||||
@Test
|
||||
func `predicate-only cost conditional schedules nothing`() {
|
||||
let controller = Self.makePredicateOnlyController(
|
||||
suite: "MenuBarCountdownRefreshTests-predicate-only-cost",
|
||||
metric: .costToday)
|
||||
defer { controller.releaseStatusItemsForTesting() }
|
||||
#expect(!controller._test_isMenuBarCountdownRefreshScheduled())
|
||||
}
|
||||
|
||||
/// A reset-countdown predicate gets an exact wake-up at `resetsAt - threshold` rather than a tick.
|
||||
@Test
|
||||
func `predicate-only reset countdown conditional schedules its flip instant`() {
|
||||
let controller = Self.makePredicateOnlyController(
|
||||
suite: "MenuBarCountdownRefreshTests-predicate-only-reset",
|
||||
metric: .sessionResetsIn,
|
||||
threshold: 0.25)
|
||||
defer { controller.releaseStatusItemsForTesting() }
|
||||
#expect(controller._test_isMenuBarCountdownRefreshScheduled())
|
||||
}
|
||||
|
||||
/// Places a single conditional whose only clause reads `metric`, with no pace, reset, or countdown
|
||||
/// token anywhere in the layout, so the token-gated schedulers cannot be what fires.
|
||||
private static func makePredicateOnlyController(
|
||||
suite: String,
|
||||
metric: MenuBarConditionalMetric,
|
||||
threshold: Double = 1)
|
||||
-> StatusItemController
|
||||
{
|
||||
let settings = testSettingsStore(suiteName: suite)
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.menuBarShowsBrandIconWithPercent = true
|
||||
if let metadata = ProviderRegistry.shared.metadata[.codex] {
|
||||
settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true)
|
||||
}
|
||||
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
name: "gate",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: metric,
|
||||
comparison: .lessThan,
|
||||
threshold: threshold))],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .hidden)
|
||||
settings.menuBarLayoutConditionals = [conditional]
|
||||
settings.menuBarLayout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]])
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
let controller = StatusItemController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fetcher.loadAccountInfo(),
|
||||
updater: DisabledUpdaterController(),
|
||||
preferencesSelection: PreferencesSelection(),
|
||||
statusBar: testStatusBar())
|
||||
|
||||
let now = Date()
|
||||
store._setSnapshotForTesting(
|
||||
UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 42,
|
||||
windowMinutes: 300,
|
||||
resetsAt: now.addingTimeInterval(3600),
|
||||
resetDescription: nil),
|
||||
secondary: RateWindow(
|
||||
usedPercent: 60,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: now.addingTimeInterval(3 * 24 * 60 * 60),
|
||||
resetDescription: nil),
|
||||
updatedAt: now),
|
||||
provider: .codex)
|
||||
controller.updateIcons()
|
||||
return controller
|
||||
}
|
||||
|
||||
@Test
|
||||
func `merged highest usage observes reset for noncurrent Codex candidate`() throws {
|
||||
let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest")
|
||||
|
||||
@@ -227,7 +227,8 @@ struct MenuBarLayoutRendererTests {
|
||||
runsOut: nil,
|
||||
balance: nil,
|
||||
costToday: nil,
|
||||
cost30d: nil)
|
||||
cost30d: nil,
|
||||
metrics: .unavailable)
|
||||
let layout = MenuBarLayout(lines: [[
|
||||
.icon,
|
||||
.providerName,
|
||||
@@ -318,7 +319,8 @@ struct MenuBarLayoutRendererTests {
|
||||
runsOut: nil,
|
||||
balance: nil,
|
||||
costToday: nil,
|
||||
cost30d: nil)
|
||||
cost30d: nil,
|
||||
metrics: .unavailable)
|
||||
|
||||
let output = renderer.render(
|
||||
layout: MenuBarLayout(lines: [[.percent(window: .session), .separatorDot, .pace(window: .session)]]),
|
||||
@@ -583,7 +585,8 @@ struct MenuBarLayoutRendererTests {
|
||||
runsOut: nil,
|
||||
balance: nil,
|
||||
costToday: nil,
|
||||
cost30d: nil)
|
||||
cost30d: nil,
|
||||
metrics: .unavailable)
|
||||
|
||||
let output = renderer.render(
|
||||
layout: MenuBarLayout(lines: [[.resetAbsolute]]),
|
||||
@@ -829,7 +832,8 @@ struct MenuBarLayoutRendererTests {
|
||||
runsOut: nil,
|
||||
balance: nil,
|
||||
costToday: nil,
|
||||
cost30d: nil)
|
||||
cost30d: nil,
|
||||
metrics: .unavailable)
|
||||
|
||||
let output = renderer.render(
|
||||
layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]),
|
||||
@@ -1106,22 +1110,245 @@ struct MenuBarLayoutRendererTests {
|
||||
#expect(output.attributedTitle.string == "in 2h")
|
||||
}
|
||||
|
||||
/// The fixture's session resets one hour out, so a `< 2h` countdown predicate holds. Rewinding the
|
||||
/// clock four hours puts the reset five hours out and the same predicate must stop holding.
|
||||
@Test
|
||||
func `resets-in predicate picks the then branch inside the threshold`() {
|
||||
let renderer = MenuBarLayoutRenderer()
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
clauses: [self.clause(metric: .sessionResetsIn, comparison: .lessThan, threshold: 2)],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .resetCountdown)
|
||||
let layout = MenuBarLayout(lines: [[.conditional(id: conditional.id)]])
|
||||
|
||||
let inside = renderer.render(
|
||||
layout: layout,
|
||||
data: self.data(),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [conditional]))
|
||||
#expect(inside.attributedTitle.string == "5h 25%")
|
||||
|
||||
let outside = renderer.render(
|
||||
layout: layout,
|
||||
data: self.data(),
|
||||
icon: nil,
|
||||
options: self.options(
|
||||
now: self.now.addingTimeInterval(-4 * 60 * 60),
|
||||
conditionals: [conditional]))
|
||||
#expect(outside.attributedTitle.string == "in 6h")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `session percent and resets-in combine with and`() {
|
||||
let renderer = MenuBarLayoutRenderer()
|
||||
let layout = { (conditional: MenuBarLayoutConditional) in
|
||||
MenuBarLayout(lines: [[.conditional(id: conditional.id)]])
|
||||
}
|
||||
let passing = MenuBarLayoutConditional(
|
||||
clauses: [
|
||||
self.clause(metric: .session, comparison: .greaterThan, threshold: 20),
|
||||
self.clause(
|
||||
metric: .sessionResetsIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 2,
|
||||
combinator: .and),
|
||||
],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .resetCountdown)
|
||||
let failing = MenuBarLayoutConditional(
|
||||
clauses: [
|
||||
self.clause(metric: .session, comparison: .greaterThan, threshold: 90),
|
||||
self.clause(
|
||||
metric: .sessionResetsIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 2,
|
||||
combinator: .and),
|
||||
],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .resetCountdown)
|
||||
|
||||
let then = renderer.render(
|
||||
layout: layout(passing),
|
||||
data: self.data(),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [passing]))
|
||||
#expect(then.attributedTitle.string == "5h 25%")
|
||||
|
||||
let otherwise = renderer.render(
|
||||
layout: layout(failing),
|
||||
data: self.data(),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [failing]))
|
||||
#expect(otherwise.attributedTitle.string == "in 2h")
|
||||
}
|
||||
|
||||
/// Session is 25% used, so 75% remains: the same threshold must flip with the direction.
|
||||
@Test
|
||||
func `remaining direction inverts the percent reading`() {
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .session,
|
||||
comparison: .greaterThan,
|
||||
threshold: 50,
|
||||
direction: .remaining)) == "5h 25%")
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .session,
|
||||
comparison: .greaterThan,
|
||||
threshold: 50,
|
||||
direction: .used)) == "in 2h")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `balance direction selects used or remaining amount`() {
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .balance,
|
||||
comparison: .greaterThan,
|
||||
threshold: 10,
|
||||
direction: .remaining)) == "5h 25%")
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .balance,
|
||||
comparison: .greaterThan,
|
||||
threshold: 10,
|
||||
direction: .used)) == "in 2h")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `pace run-out and cost predicates read numeric metrics`() {
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .weeklyPace,
|
||||
comparison: .greaterThan,
|
||||
threshold: 10)) == "5h 25%")
|
||||
// 2400 minutes == 40 hours.
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .runsOutIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 48)) == "5h 25%")
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .runsOutIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 12)) == "in 2h")
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .costToday,
|
||||
comparison: .greaterThanOrEqual,
|
||||
threshold: 1)) == "5h 25%")
|
||||
#expect(self.branchText(self.clause(
|
||||
metric: .tertiaryLane,
|
||||
comparison: .greaterThan,
|
||||
threshold: 10)) == "5h 25%")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `predicate on a metric with no datum evaluates false`() {
|
||||
let renderer = MenuBarLayoutRenderer()
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
clauses: [self.clause(metric: .cost30d, comparison: .greaterThanOrEqual, threshold: 0)],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .resetCountdown)
|
||||
let output = renderer.render(
|
||||
layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]),
|
||||
data: self.data(metrics: .unavailable),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [conditional]))
|
||||
#expect(output.attributedTitle.string == "in 2h")
|
||||
}
|
||||
|
||||
/// Regression guard for the title cache: a countdown predicate flips with nothing but the clock, and
|
||||
/// the automatic window's reset text — the only time-derived key component before this — does not
|
||||
/// distinguish the two renders here.
|
||||
@Test
|
||||
func `time based conditional flips when only the clock advances`() {
|
||||
let renderer = MenuBarLayoutRenderer()
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
clauses: [self.clause(metric: .weeklyResetsIn, comparison: .lessThan, threshold: 48)],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .hidden)
|
||||
let layout = MenuBarLayout(lines: [[.conditional(id: conditional.id)]])
|
||||
let data = self.data()
|
||||
|
||||
// Weekly resets 3 days out: 72h > 48h, so the else branch hides the token.
|
||||
let before = renderer.render(
|
||||
layout: layout,
|
||||
data: data,
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [conditional]))
|
||||
#expect(before.attributedTitle.string.isEmpty)
|
||||
|
||||
// Two days later the same weekly reset is 24h out and the then branch must win.
|
||||
let after = renderer.render(
|
||||
layout: layout,
|
||||
data: data,
|
||||
icon: nil,
|
||||
options: self.options(
|
||||
now: self.now.addingTimeInterval(2 * 24 * 60 * 60),
|
||||
conditionals: [conditional]))
|
||||
#expect(after.attributedTitle.string == "5h 25%")
|
||||
}
|
||||
|
||||
/// Renders a single-clause conditional whose then branch is the session percent and whose else
|
||||
/// branch is the automatic reset countdown, so a caller can assert which branch won by text.
|
||||
private func branchText(_ clause: MenuBarConditionalClause) -> String {
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
clauses: [clause],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .resetCountdown)
|
||||
return MenuBarLayoutRenderer().render(
|
||||
layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]),
|
||||
data: self.data(),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [conditional])).attributedTitle.string
|
||||
}
|
||||
|
||||
/// End-to-end proof for the shipped "Auto % / Resets in" default: while the automatic lane still has
|
||||
/// headroom it renders the percentage, and once the quota is spent it swaps to the reset countdown.
|
||||
@Test
|
||||
func `shipped auto default swaps percent for the countdown once the quota is spent`() {
|
||||
let renderer = MenuBarLayoutRenderer()
|
||||
let shipped = MenuBarLayoutConditional.shippedLibrary()
|
||||
guard let auto = shipped.first(where: { entry in
|
||||
entry.clauses.contains { $0.predicate.direction == .remaining }
|
||||
}) else {
|
||||
Issue.record("expected a shipped automatic remaining-direction conditional")
|
||||
return
|
||||
}
|
||||
#expect(auto.displayName == "Auto % / Resets in")
|
||||
let layout = MenuBarLayout(lines: [[.conditional(id: auto.id)]])
|
||||
|
||||
let withHeadroom = renderer.render(
|
||||
layout: layout,
|
||||
data: self.data(),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [auto]))
|
||||
#expect(withHeadroom.attributedTitle.string == "50%")
|
||||
|
||||
let spent = renderer.render(
|
||||
layout: layout,
|
||||
data: self.data(automaticUsedPercent: 100),
|
||||
icon: nil,
|
||||
options: self.options(conditionals: [auto]))
|
||||
#expect(spent.attributedTitle.string == "in 2h")
|
||||
}
|
||||
|
||||
private func clause(
|
||||
metric: MenuBarConditionalMetric,
|
||||
comparison: MenuBarConditionalComparison,
|
||||
threshold: Double,
|
||||
direction: MenuBarConditionalDirection = .used,
|
||||
combinator: MenuBarConditionalCombinator? = nil) -> MenuBarConditionalClause
|
||||
{
|
||||
MenuBarConditionalClause(
|
||||
combinator: combinator,
|
||||
predicate: MenuBarConditionalPredicate(metric: metric, comparison: comparison, threshold: threshold))
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: metric,
|
||||
direction: direction,
|
||||
comparison: comparison,
|
||||
threshold: threshold))
|
||||
}
|
||||
|
||||
private func data(
|
||||
automaticUsedPercent: Double = 50,
|
||||
provider: UsageProvider = .codex,
|
||||
laneLabels: MenuBarLayoutLaneLabels? = nil,
|
||||
automaticResetAt: Date? = nil)
|
||||
automaticResetAt: Date? = nil,
|
||||
metrics: MenuBarLayoutRenderMetrics? = nil)
|
||||
-> MenuBarLayoutRenderData
|
||||
{
|
||||
MenuBarLayoutRenderData(
|
||||
@@ -1173,7 +1400,17 @@ struct MenuBarLayoutRendererTests {
|
||||
runsOut: "Runs out in 1d 16h",
|
||||
balance: "$12.34",
|
||||
costToday: "$1.25",
|
||||
cost30d: "$20.00")
|
||||
cost30d: "$20.00",
|
||||
// Numeric twins of the strings above, so conditional predicates and rendered text agree.
|
||||
metrics: metrics ?? MenuBarLayoutRenderMetrics(
|
||||
sessionPaceDelta: -8,
|
||||
weeklyPaceDelta: 11,
|
||||
automaticPaceDelta: 0,
|
||||
runsOutMinutes: 2400,
|
||||
balanceRemainingUSD: 12.34,
|
||||
balanceUsedUSD: 7.66,
|
||||
costTodayUSD: 1.25,
|
||||
cost30dUSD: 20))
|
||||
}
|
||||
|
||||
private func options(
|
||||
|
||||
@@ -198,6 +198,143 @@ struct MenuBarLayoutTests {
|
||||
#expect(reloaded.menuBarLayout == layout)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `predicate without direction decodes as used`() throws {
|
||||
let predicate = MenuBarConditionalPredicate(
|
||||
metric: .session,
|
||||
direction: .remaining,
|
||||
comparison: .lessThan,
|
||||
threshold: 20)
|
||||
let data = try JSONEncoder().encode(predicate)
|
||||
#expect(try JSONDecoder().decode(MenuBarConditionalPredicate.self, from: data) == predicate)
|
||||
|
||||
// A predicate persisted before `direction` existed compared used percentages.
|
||||
guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
Issue.record("expected a JSON object")
|
||||
return
|
||||
}
|
||||
json.removeValue(forKey: "direction")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: json)
|
||||
let legacy = try JSONDecoder().decode(MenuBarConditionalPredicate.self, from: legacyData)
|
||||
#expect(legacy.direction == .used)
|
||||
#expect(legacy.metric == .session)
|
||||
#expect(legacy.threshold == 20)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `threshold clamps to the metric unit range`() {
|
||||
let clamped = { (metric: MenuBarConditionalMetric, threshold: Double) -> Double in
|
||||
MenuBarLayoutConditional(
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: metric,
|
||||
comparison: .lessThan,
|
||||
threshold: threshold))],
|
||||
thenToken: .hidden,
|
||||
elseToken: .hidden).clauses[0].predicate.threshold
|
||||
}
|
||||
#expect(clamped(.sessionResetsIn, 9000) == 8760)
|
||||
#expect(clamped(.sessionResetsIn, 2.5) == 2.5)
|
||||
#expect(clamped(.weeklyPace, -250) == -100)
|
||||
#expect(clamped(.costToday, -5) == 0)
|
||||
#expect(clamped(.session, 250) == 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `direction is dropped for metrics without a complement`() {
|
||||
let predicate = MenuBarConditionalPredicate(
|
||||
metric: .costToday,
|
||||
direction: .remaining,
|
||||
comparison: .greaterThan,
|
||||
threshold: 1)
|
||||
#expect(predicate.normalized().direction == .used)
|
||||
|
||||
let kept = MenuBarConditionalPredicate(
|
||||
metric: .balance,
|
||||
direction: .remaining,
|
||||
comparison: .greaterThan,
|
||||
threshold: 1)
|
||||
#expect(kept.normalized().direction == .remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `referenced conditional predicates include nested branches`() {
|
||||
let inner = MenuBarLayoutConditional(
|
||||
name: "inner",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .costToday,
|
||||
comparison: .greaterThan,
|
||||
threshold: 1))],
|
||||
thenToken: .costToday,
|
||||
elseToken: .hidden)
|
||||
let outer = MenuBarLayoutConditional(
|
||||
name: "outer",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .sessionResetsIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 2))],
|
||||
thenToken: .conditional(id: inner.id),
|
||||
elseToken: .hidden)
|
||||
let layout = MenuBarLayout(lines: [[.icon, .conditional(id: outer.id)]])
|
||||
|
||||
let metrics = Set(layout
|
||||
.referencedConditionalPredicates(conditionals: [outer, inner])
|
||||
.map(\.metric))
|
||||
#expect(metrics == [.sessionResetsIn, .costToday])
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `unrecognized conditional metric drops only its own entry`() throws {
|
||||
let suite = "MenuBarLayoutTests-conditional-unknown-metric"
|
||||
let settings = testSettingsStore(suiteName: suite)
|
||||
let valid = MenuBarLayoutConditional(
|
||||
name: "valid",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .session,
|
||||
comparison: .greaterThan,
|
||||
threshold: 30))],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .hidden)
|
||||
let future = MenuBarLayoutConditional(
|
||||
name: "future",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .weekly,
|
||||
comparison: .greaterThan,
|
||||
threshold: 40))],
|
||||
thenToken: .percent(window: .weekly),
|
||||
elseToken: .hidden)
|
||||
|
||||
// Rewrite the second entry's metric to a raw value this build has no case for, the way a newer
|
||||
// release would once the metric set grows again.
|
||||
let encoded = try JSONEncoder().encode([valid, future])
|
||||
guard var blob = try JSONSerialization.jsonObject(with: encoded) as? [[String: Any]],
|
||||
var clauses = blob[1]["clauses"] as? [[String: Any]],
|
||||
var predicate = clauses[0]["predicate"] as? [String: Any]
|
||||
else {
|
||||
Issue.record("expected an array of conditional objects")
|
||||
return
|
||||
}
|
||||
predicate["metric"] = "notAMetric"
|
||||
clauses[0]["predicate"] = predicate
|
||||
blob[1]["clauses"] = clauses
|
||||
try settings.userDefaults.set(
|
||||
JSONSerialization.data(withJSONObject: blob),
|
||||
forKey: "menuBarLayoutConditionals")
|
||||
|
||||
let reloaded = Self.reloadSettingsStore(settings)
|
||||
#expect(reloaded.menuBarLayoutConditionals == [valid])
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `a fresh install ships an editable conditionals library`() {
|
||||
@@ -215,6 +352,16 @@ struct MenuBarLayoutTests {
|
||||
#expect(Set(shipped.map { $0.name.lowercased() }).count == shipped.count)
|
||||
#expect(shipped.allSatisfy { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
|
||||
#expect(shipped.allSatisfy { !$0.clauses.isEmpty && $0.clauses[0].combinator == nil })
|
||||
|
||||
// The automatic default must keep exercising the remaining direction: it is the only shipped
|
||||
// entry proving a non-`used` reading survives a fresh install.
|
||||
let remainingDefaults = shipped.filter { entry in
|
||||
entry.clauses.contains { $0.predicate.direction == .remaining }
|
||||
}
|
||||
#expect(remainingDefaults.count == 1)
|
||||
#expect(remainingDefaults.first?.clauses.first?.predicate.metric == .automatic)
|
||||
#expect(remainingDefaults.first?.thenToken == .percent(window: .automatic))
|
||||
#expect(remainingDefaults.first?.elseToken == .resetCountdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -279,6 +426,86 @@ struct MenuBarLayoutTests {
|
||||
#expect(!uniform.editorSummary(provider: nil).contains("("))
|
||||
}
|
||||
|
||||
/// The conditional editor row is driven entirely by these three metric properties, so pinning them
|
||||
/// pins which controls appear: the direction picker is shown only when `supportsDirection`, and the
|
||||
/// label beside the threshold field is `thresholdUnit`.
|
||||
@Test
|
||||
func `metric drives the editor row controls and units`() {
|
||||
#expect(MenuBarConditionalMetric.allCases.count == 18)
|
||||
|
||||
let withDirection = MenuBarConditionalMetric.allCases.filter(\.supportsDirection)
|
||||
#expect(withDirection == [
|
||||
.session, .weekly, .scopedWeekly, .automatic,
|
||||
.primaryLane, .secondaryLane, .tertiaryLane, .balance,
|
||||
])
|
||||
|
||||
#expect(MenuBarConditionalMetric.session.thresholdUnit == "%")
|
||||
#expect(MenuBarConditionalMetric.weeklyPace.thresholdUnit == "%")
|
||||
#expect(MenuBarConditionalMetric.sessionResetsIn.thresholdUnit == "h")
|
||||
#expect(MenuBarConditionalMetric.runsOutIn.thresholdUnit == "h")
|
||||
#expect(MenuBarConditionalMetric.costToday.thresholdUnit == "USD")
|
||||
#expect(MenuBarConditionalMetric.balance.thresholdUnit == "USD")
|
||||
|
||||
#expect(MenuBarConditionalMetric.sessionResetsIn.thresholdStep == 0.5)
|
||||
#expect(MenuBarConditionalMetric.session.thresholdStep == 1)
|
||||
|
||||
// Every metric needs a label; an empty one would render a blank picker row.
|
||||
for metric in MenuBarConditionalMetric.allCases {
|
||||
#expect(!metric.editorLabel(provider: nil).isEmpty, "\(metric.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `summary spells out direction and unit for a mixed-unit condition`() {
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
name: "Session busy and about to reset",
|
||||
clauses: [
|
||||
MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .session,
|
||||
direction: .used,
|
||||
comparison: .greaterThan,
|
||||
threshold: 50)),
|
||||
MenuBarConditionalClause(
|
||||
combinator: .and,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .sessionResetsIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 2)),
|
||||
],
|
||||
thenToken: .resetCountdown,
|
||||
elseToken: .hidden)
|
||||
|
||||
let summary = conditional.editorSummary(provider: nil)
|
||||
#expect(summary == "If Session % used > 50% and Session resets in < 2h then Resets in else Hide")
|
||||
|
||||
// A half-hour threshold keeps its decimal rather than rounding away to "0h".
|
||||
let halfHour = MenuBarLayoutConditional(
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .automaticResetsIn,
|
||||
comparison: .lessThanOrEqual,
|
||||
threshold: 0.5))],
|
||||
thenToken: .resetCountdown,
|
||||
elseToken: .hidden)
|
||||
#expect(halfHour.editorSummary(provider: nil).contains("<= 0.5h"))
|
||||
|
||||
// Currency thresholds read with a separated unit; percent and hours stay tight against the number.
|
||||
let credit = MenuBarLayoutConditional(
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .balance,
|
||||
direction: .remaining,
|
||||
comparison: .greaterThanOrEqual,
|
||||
threshold: 5))],
|
||||
thenToken: .balance,
|
||||
elseToken: .hidden)
|
||||
#expect(credit.editorSummary(provider: nil).contains("Balance remaining >= 5 USD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `removing a library conditional strips references everywhere`() {
|
||||
@@ -979,6 +1206,147 @@ struct MenuBarLayoutTests {
|
||||
#expect(reloaded.menuBarLayout == current)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `conditional library dual-writes an older-readable projection`() throws {
|
||||
let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-downgrade")
|
||||
let readable = MenuBarLayoutConditional(
|
||||
name: "readable",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .session,
|
||||
comparison: .greaterThan,
|
||||
threshold: 50))],
|
||||
thenToken: .percent(window: .session),
|
||||
elseToken: .hidden)
|
||||
let newMetric = MenuBarLayoutConditional(
|
||||
name: "new metric",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .sessionResetsIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 2))],
|
||||
thenToken: .resetCountdown,
|
||||
elseToken: .hidden)
|
||||
// An older release ignores the unknown `direction` key, so this would come back inverted rather
|
||||
// than absent — worse than dropping it.
|
||||
let inverted = MenuBarLayoutConditional(
|
||||
name: "inverted",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .weekly,
|
||||
direction: .remaining,
|
||||
comparison: .greaterThan,
|
||||
threshold: 20))],
|
||||
thenToken: .percent(window: .weekly),
|
||||
elseToken: .hidden)
|
||||
settings.menuBarLayoutConditionals = [readable, newMetric, inverted]
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let current = try #require(
|
||||
settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent))
|
||||
let legacy = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionals))
|
||||
|
||||
#expect(try decoder.decode([MenuBarLayoutConditional].self, from: current) ==
|
||||
[readable, newMetric, inverted])
|
||||
|
||||
// The whole point: a 0.54.0 decoder reads the projection, and would have thrown on the full blob.
|
||||
let legacyEntries = try decoder.decode([PreExpandedConditional].self, from: legacy)
|
||||
#expect(legacyEntries.map(\.name) == ["readable"])
|
||||
#expect(legacyEntries.first?.clauses.first?.predicate.metric == .session)
|
||||
#expect(throws: DecodingError.self) {
|
||||
try decoder.decode([PreExpandedConditional].self, from: current)
|
||||
}
|
||||
|
||||
let reloaded = Self.reloadSettingsStore(settings)
|
||||
#expect(reloaded.menuBarLayoutConditionals == [readable, newMetric, inverted])
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `conditional library load prefers a legacy blob edited by an older release`() throws {
|
||||
let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-downgrade-edit")
|
||||
let current = MenuBarLayoutConditional(
|
||||
name: "current",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .runsOutIn,
|
||||
comparison: .lessThan,
|
||||
threshold: 6))],
|
||||
thenToken: .runsOut,
|
||||
elseToken: .hidden)
|
||||
settings.menuBarLayoutConditionals = [current]
|
||||
|
||||
// An older release rewrote the shared key with its own edit; that must win over our projection.
|
||||
let edited = MenuBarLayoutConditional(
|
||||
name: "edited by older release",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .weekly,
|
||||
comparison: .greaterThan,
|
||||
threshold: 75))],
|
||||
thenToken: .percent(window: .weekly),
|
||||
elseToken: .hidden)
|
||||
try settings.userDefaults.set(
|
||||
JSONEncoder().encode([edited]),
|
||||
forKey: MenuBarLayoutUserDefaultsKey.conditionals)
|
||||
|
||||
let reloaded = Self.reloadSettingsStore(settings)
|
||||
#expect(reloaded.menuBarLayoutConditionals == [edited])
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `conditional library load keeps new metrics when the legacy blob is its own projection`() {
|
||||
let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-legacy-echo")
|
||||
let newMetric = MenuBarLayoutConditional(
|
||||
name: "new metric",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .costToday,
|
||||
comparison: .greaterThan,
|
||||
threshold: 1))],
|
||||
thenToken: .costToday,
|
||||
elseToken: .hidden)
|
||||
settings.menuBarLayoutConditionals = [newMetric]
|
||||
|
||||
let reloaded = Self.reloadSettingsStore(settings)
|
||||
#expect(reloaded.menuBarLayoutConditionals == [newMetric])
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `startup materializes a missing conditional projection`() throws {
|
||||
let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-startup-dual-write")
|
||||
// A pre-upgrade install only has the legacy key.
|
||||
let existing = MenuBarLayoutConditional(
|
||||
name: "existing",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: .automatic,
|
||||
comparison: .greaterThan,
|
||||
threshold: 40))],
|
||||
thenToken: .percent(window: .automatic),
|
||||
elseToken: .hidden)
|
||||
settings.userDefaults.removeObject(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent)
|
||||
try settings.userDefaults.set(
|
||||
JSONEncoder().encode([existing]),
|
||||
forKey: MenuBarLayoutUserDefaultsKey.conditionals)
|
||||
|
||||
let reloaded = Self.reloadSettingsStore(settings)
|
||||
#expect(reloaded.menuBarLayoutConditionals == [existing])
|
||||
let materialized = try #require(
|
||||
reloaded.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.conditionalsCurrent))
|
||||
#expect(try JSONDecoder().decode([MenuBarLayoutConditional].self, from: materialized) == [existing])
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
func `lane override load prefers a legacy dictionary edited by an older release`() throws {
|
||||
@@ -1102,3 +1470,30 @@ private enum PreLanePercentMenuBarLayoutToken: Codable, Equatable {
|
||||
private struct PreLanePercentMenuBarLayout: Codable, Equatable {
|
||||
let lines: [[PreLanePercentMenuBarLayoutToken]]
|
||||
}
|
||||
|
||||
/// The 0.54.0 conditional surface: four percent metrics, no `direction`. Its synthesized `Codable`
|
||||
/// throws on any other metric raw value and silently ignores unknown keys, which is exactly why the
|
||||
/// older-readable projection has to drop those entries rather than hand them over.
|
||||
private enum PreExpandedConditionalMetric: String, Codable, Equatable {
|
||||
case session
|
||||
case weekly
|
||||
case scopedWeekly
|
||||
case automatic
|
||||
}
|
||||
|
||||
private struct PreExpandedConditionalPredicate: Codable, Equatable {
|
||||
let metric: PreExpandedConditionalMetric
|
||||
let comparison: String
|
||||
let threshold: Double
|
||||
}
|
||||
|
||||
private struct PreExpandedConditionalClause: Codable, Equatable {
|
||||
let combinator: String?
|
||||
let predicate: PreExpandedConditionalPredicate
|
||||
}
|
||||
|
||||
private struct PreExpandedConditional: Codable, Equatable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let clauses: [PreExpandedConditionalClause]
|
||||
}
|
||||
|
||||
@@ -1064,13 +1064,13 @@ struct ProviderArchitectureGatekeeperTests {
|
||||
reason: "This named provider resolver supplies its fixed provider identity to the shared presentation helper."),
|
||||
SuppressedProviderReference(
|
||||
path: "Sources/CodexBar/UsageStore+HistoricalPace.swift",
|
||||
line: 132,
|
||||
line: 154,
|
||||
anchor: "let ownership = self.codexOwnershipContext(preferredEmail: snapshot.accountEmail(for: .codex))",
|
||||
expectedProviderIDs: ["codex"],
|
||||
reason: "This provider-specific app branch passes its already-selected identity to a shared helper."),
|
||||
SuppressedProviderReference(
|
||||
path: "Sources/CodexBar/UsageStore+HistoricalPace.swift",
|
||||
line: 191,
|
||||
line: 213,
|
||||
anchor: "provider: .codex,",
|
||||
expectedProviderIDs: ["codex"],
|
||||
reason: "This provider-specific app branch passes its already-selected identity to a shared helper."),
|
||||
@@ -1765,7 +1765,7 @@ struct ProviderArchitectureGatekeeperTests {
|
||||
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
|
||||
AllowedProviderConstruct(
|
||||
path: "Sources/CodexBar/MenuBarLayout.swift",
|
||||
line: 572,
|
||||
line: 788,
|
||||
anchor: "ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow)",
|
||||
expectedProviderIDs: ["codex"],
|
||||
expectedReferenceCount: 2,
|
||||
@@ -2293,7 +2293,7 @@ struct ProviderArchitectureGatekeeperTests {
|
||||
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
|
||||
AllowedProviderConstruct(
|
||||
path: "Sources/CodexBar/SettingsStore.swift",
|
||||
line: 1174,
|
||||
line: 1185,
|
||||
anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {",
|
||||
expectedProviderIDs: ["factory", "minimax", "zai"],
|
||||
expectedReferenceCount: 8,
|
||||
@@ -2478,7 +2478,7 @@ struct ProviderArchitectureGatekeeperTests {
|
||||
reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."),
|
||||
AllowedProviderConstruct(
|
||||
path: "Sources/CodexBar/StatusItemController+CountdownRefresh.swift",
|
||||
line: 123,
|
||||
line: 166,
|
||||
anchor: "if providers.contains(.codex) {",
|
||||
expectedProviderIDs: ["codex"],
|
||||
expectedReferenceCount: 2,
|
||||
@@ -2518,7 +2518,7 @@ struct ProviderArchitectureGatekeeperTests {
|
||||
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
|
||||
AllowedProviderConstruct(
|
||||
path: "Sources/CodexBar/StatusItemController+MenuBarLayout.swift",
|
||||
line: 165,
|
||||
line: 209,
|
||||
anchor: "if provider == .codex,",
|
||||
expectedProviderIDs: ["codex"],
|
||||
expectedReferenceCount: 1,
|
||||
@@ -2646,7 +2646,7 @@ struct ProviderArchitectureGatekeeperTests {
|
||||
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
|
||||
AllowedProviderConstruct(
|
||||
path: "Sources/CodexBar/UsageStore+HistoricalPace.swift",
|
||||
line: 185,
|
||||
line: 207,
|
||||
anchor: "let codexSnapshot = self.snapshots[.codex]",
|
||||
expectedProviderIDs: ["codex"],
|
||||
expectedReferenceCount: 1,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
/// A conditional predicate can depend on data no display token exposes. When the observation signature
|
||||
/// misses that dependency the observer skips `updateIcons()` and the menu bar keeps rendering the branch
|
||||
/// that was true before the data moved, so each case here pins one such dependency.
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct StatusItemConditionalSignatureTests {
|
||||
@Test
|
||||
func `over-quota used-direction lane predicate moves the signature`() {
|
||||
let harness = Self.makeHarness(
|
||||
suite: "StatusItemConditionalSignatureTests-lane-over-quota",
|
||||
metric: .primaryLane,
|
||||
direction: .used,
|
||||
comparison: .greaterThan,
|
||||
threshold: 105)
|
||||
// Rendered lanes show remaining, which clamps at zero: both snapshots display 0% remaining.
|
||||
harness.settings.usageBarsShowUsed = false
|
||||
|
||||
let below = harness.signature(primaryUsedPercent: 104)
|
||||
let above = harness.signature(primaryUsedPercent: 106)
|
||||
|
||||
#expect(below.contains("layoutCondWindows=primaryLane="))
|
||||
#expect(below != above)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `countdown predicate follows a moved reset timestamp`() {
|
||||
let harness = Self.makeHarness(
|
||||
suite: "StatusItemConditionalSignatureTests-reset-moved",
|
||||
metric: .sessionResetsIn,
|
||||
direction: .used,
|
||||
comparison: .lessThan,
|
||||
threshold: 2)
|
||||
|
||||
// Identical usage, different reset instant: only the countdown predicate can tell them apart.
|
||||
let near = harness.signature(primaryUsedPercent: 30, sessionResetInHours: 1)
|
||||
let far = harness.signature(primaryUsedPercent: 30, sessionResetInHours: 5)
|
||||
|
||||
#expect(near.contains("layoutCondWindows=sessionResetsIn="))
|
||||
#expect(near != far)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost predicate signs the unrounded amount`() {
|
||||
let harness = Self.makeHarness(
|
||||
suite: "StatusItemConditionalSignatureTests-cost-subcent",
|
||||
metric: .costToday,
|
||||
direction: .used,
|
||||
comparison: .greaterThan,
|
||||
threshold: 1.2345)
|
||||
|
||||
// Both amounts format to "$1.23", so only the numeric component can separate them.
|
||||
let below = harness.signature(primaryUsedPercent: 30, todayCostUSD: 1.2344)
|
||||
let above = harness.signature(primaryUsedPercent: 30, todayCostUSD: 1.2346)
|
||||
|
||||
#expect(below.contains("todayUSD="))
|
||||
#expect(below != above)
|
||||
}
|
||||
|
||||
/// Thresholds are USD. `UsageFormatter.convertedCost` returns the source amount unchanged when it has
|
||||
/// no rate for the provider's currency, so handing that value over would compare a foreign amount
|
||||
/// against a USD threshold. The display string must still render in the provider's own currency.
|
||||
@Test
|
||||
func `cost in an unconvertible currency yields no USD metric`() {
|
||||
let harness = Self.makeHarness(
|
||||
suite: "StatusItemConditionalSignatureTests-cost-currency",
|
||||
metric: .costToday,
|
||||
direction: .used,
|
||||
comparison: .greaterThan,
|
||||
threshold: 5)
|
||||
|
||||
_ = harness.signature(primaryUsedPercent: 30, todayCostUSD: 6, currencyCode: "XXX")
|
||||
let unconvertible = harness.controller.menuBarLayoutCosts(provider: .claude)
|
||||
#expect(unconvertible.todayUSD == nil)
|
||||
#expect(unconvertible.last30DaysUSD == nil)
|
||||
// The rendered text is unaffected: it stays in the provider's reported currency.
|
||||
#expect(unconvertible.today != nil)
|
||||
|
||||
_ = harness.signature(primaryUsedPercent: 30, todayCostUSD: 6, currencyCode: "USD")
|
||||
let usd = harness.controller.menuBarLayoutCosts(provider: .claude)
|
||||
#expect(usd.todayUSD == 6)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct Harness {
|
||||
let settings: SettingsStore
|
||||
let store: UsageStore
|
||||
let controller: StatusItemController
|
||||
let now: Date
|
||||
|
||||
func signature(
|
||||
primaryUsedPercent: Double,
|
||||
sessionResetInHours: Double = 1,
|
||||
todayCostUSD: Double? = nil,
|
||||
currencyCode: String = "USD")
|
||||
-> String
|
||||
{
|
||||
self.store._setSnapshotForTesting(
|
||||
UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: primaryUsedPercent,
|
||||
windowMinutes: 300,
|
||||
resetsAt: self.now.addingTimeInterval(sessionResetInHours * 60 * 60),
|
||||
resetDescription: nil),
|
||||
secondary: RateWindow(
|
||||
usedPercent: 60,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: self.now.addingTimeInterval(3 * 24 * 60 * 60),
|
||||
resetDescription: nil),
|
||||
updatedAt: self.now),
|
||||
provider: .claude)
|
||||
if let todayCostUSD {
|
||||
self.store._setTokenSnapshotForTesting(
|
||||
Self.tokenSnapshot(
|
||||
todayCostUSD: todayCostUSD,
|
||||
currencyCode: currencyCode,
|
||||
now: self.now),
|
||||
provider: .claude)
|
||||
}
|
||||
return self.controller.storeIconObservationSignature()
|
||||
}
|
||||
|
||||
private static func tokenSnapshot(
|
||||
todayCostUSD: Double,
|
||||
currencyCode: String,
|
||||
now: Date)
|
||||
-> CostUsageTokenSnapshot
|
||||
{
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = .current
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return CostUsageTokenSnapshot(
|
||||
sessionTokens: nil,
|
||||
sessionCostUSD: nil,
|
||||
last30DaysTokens: nil,
|
||||
last30DaysCostUSD: todayCostUSD,
|
||||
currencyCode: currencyCode,
|
||||
daily: [
|
||||
CostUsageDailyReport.Entry(
|
||||
date: formatter.string(from: now),
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
costUSD: todayCostUSD,
|
||||
modelsUsed: nil,
|
||||
modelBreakdowns: nil),
|
||||
],
|
||||
updatedAt: now)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeHarness(
|
||||
suite: String,
|
||||
metric: MenuBarConditionalMetric,
|
||||
direction: MenuBarConditionalDirection,
|
||||
comparison: MenuBarConditionalComparison,
|
||||
threshold: Double)
|
||||
-> Harness
|
||||
{
|
||||
let settings = testSettingsStore(suiteName: suite)
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.menuBarShowsBrandIconWithPercent = true
|
||||
|
||||
let conditional = MenuBarLayoutConditional(
|
||||
name: "gate",
|
||||
clauses: [MenuBarConditionalClause(
|
||||
combinator: nil,
|
||||
predicate: MenuBarConditionalPredicate(
|
||||
metric: metric,
|
||||
direction: direction,
|
||||
comparison: comparison,
|
||||
threshold: threshold))],
|
||||
thenToken: .resetCountdown,
|
||||
elseToken: .hidden)
|
||||
settings.menuBarLayoutConditionals = [conditional]
|
||||
settings.menuBarLayout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]])
|
||||
|
||||
if let claudeMeta = ProviderRegistry.shared.metadata[.claude] {
|
||||
settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true)
|
||||
}
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
||||
let controller = StatusItemController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fetcher.loadAccountInfo(),
|
||||
updater: DisabledUpdaterController(),
|
||||
preferencesSelection: PreferencesSelection(),
|
||||
statusBar: testStatusBar())
|
||||
return Harness(settings: settings, store: store, controller: controller, now: Date())
|
||||
}
|
||||
}
|
||||
@@ -439,7 +439,7 @@ struct StatusItemIconObservationSignatureTests {
|
||||
#expect(store.snapshot(for: .codex)?.primary?.usedPercent == usagePrimaryPercent)
|
||||
#expect(controller.lastObservedStoreIconWorkSignature != baseline)
|
||||
#expect(
|
||||
controller.menuBarLayoutCostStrings(provider: .codex).last30Days ==
|
||||
controller.menuBarLayoutCosts(provider: .codex).last30Days ==
|
||||
UsageFormatter.currencyString(12.50, currencyCode: "USD"))
|
||||
}
|
||||
|
||||
|
||||