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

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

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

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

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

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

* Sign the readings conditional predicates actually compare

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

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

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

* Tick clock-derived predicates that no token schedules

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

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

* Keep older releases' conditional libraries readable on downgrade

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

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

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

* Drop cost metrics that could not be converted to USD

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

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

---------

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

411 lines
16 KiB
Swift

import CodexBarCore
import SwiftUI
struct MenuBarLayoutConditionalDraft: Identifiable {
enum Mode: Hashable {
case create
case edit(UUID)
}
/// Sheet-presentation identity only, unrelated to `conditional.id`.
let id: UUID
let mode: Mode
var conditional: MenuBarLayoutConditional
init(mode: Mode, conditional: MenuBarLayoutConditional) {
self.id = UUID()
self.mode = mode
self.conditional = conditional
}
}
@MainActor
struct MenuBarLayoutConditionalEditorSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var conditional: MenuBarLayoutConditional
let draft: MenuBarLayoutConditionalDraft
let provider: UsageProvider?
let existingNames: Set<String>
let onSave: (MenuBarLayoutConditionalDraft) -> Void
init(
draft: MenuBarLayoutConditionalDraft,
provider: UsageProvider?,
existingNames: Set<String>,
onSave: @escaping (MenuBarLayoutConditionalDraft) -> Void)
{
self.draft = draft
self.provider = provider
self.existingNames = existingNames
self.onSave = onSave
self._conditional = State(initialValue: draft.conditional)
}
private var trimmedName: String {
self.conditional.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var reservedNames: Set<String> {
var names = self.existingNames
// The entry's own current name is allowed so an edit can be saved unchanged.
names.remove(self.draft.conditional.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
return names
}
private var nameIsValid: Bool {
!self.trimmedName.isEmpty && !self.reservedNames.contains(self.trimmedName.lowercased())
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(L("menu_bar_layout_conditional_name"))
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
TextField(L("menu_bar_layout_conditional_name_placeholder"), text: self.$conditional.name)
.textFieldStyle(.roundedBorder)
if !self.nameIsValid {
Text(L("menu_bar_layout_conditional_name_error"))
.font(.caption)
.foregroundStyle(.red)
}
}
Text(L("menu_bar_layout_conditional_if"))
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
ForEach(self.conditional.clauses.indices, id: \.self) { index in
self.clauseRow(index: index)
}
Button(L("menu_bar_layout_conditional_add_condition")) {
self.conditional.clauses.append(
MenuBarConditionalClause(
combinator: .and,
predicate: MenuBarConditionalPredicate(
metric: .automatic,
comparison: .greaterThan,
threshold: 0)))
}
.disabled(self.conditional.clauses.count >= 4)
.buttonStyle(.link)
HStack {
Text(L("menu_bar_layout_conditional_then"))
self.tokenMenu(selection: self.thenBinding)
}
HStack {
Text(L("menu_bar_layout_conditional_else"))
self.tokenMenu(selection: self.elseBinding)
}
Text(self.conditional.editorSummary(provider: self.provider))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Divider()
HStack {
Spacer()
Button(L("Cancel"), role: .cancel) {
self.dismiss()
}
Button(L("menu_bar_layout_conditional_save")) {
self.onSave(
MenuBarLayoutConditionalDraft(
mode: self.draft.mode,
conditional: self.conditional))
self.dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(!self.nameIsValid)
}
}
.padding(16)
// 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> {
Binding(
get: {
guard self.conditional.clauses.indices.contains(index) else { return .and }
return self.conditional.clauses[index].combinator ?? .and
},
set: {
guard self.conditional.clauses.indices.contains(index) else { return }
self.conditional.clauses[index].combinator = $0
})
}
private func metricBinding(_ index: Int) -> Binding<MenuBarConditionalMetric> {
Binding(
get: {
guard self.conditional.clauses.indices.contains(index) else { return .session }
return self.conditional.clauses[index].predicate.metric
},
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
})
}
private func comparisonBinding(_ index: Int) -> Binding<MenuBarConditionalComparison> {
Binding(
get: {
guard self.conditional.clauses.indices.contains(index) else { return .greaterThan }
return self.conditional.clauses[index].predicate.comparison
},
set: {
guard self.conditional.clauses.indices.contains(index) else { return }
self.conditional.clauses[index].predicate.comparison = $0
})
}
private func thresholdBinding(_ index: Int) -> Binding<Double> {
Binding(
get: {
guard self.conditional.clauses.indices.contains(index) else { return 0 }
return self.conditional.clauses[index].predicate.threshold
},
set: {
guard self.conditional.clauses.indices.contains(index) else { return }
let metric = self.conditional.clauses[index].predicate.metric
self.conditional.clauses[index].predicate.threshold = $0.clamped(to: metric.thresholdRange)
})
}
private var thenBinding: Binding<MenuBarLayoutToken> {
Binding(
get: { self.conditional.thenToken },
set: { self.conditional.thenToken = $0 })
}
private var elseBinding: Binding<MenuBarLayoutToken> {
Binding(
get: { self.conditional.elseToken },
set: { self.conditional.elseToken = $0 })
}
private func tokenMenu(selection: Binding<MenuBarLayoutToken>) -> some View {
Menu {
ForEach(Self.selectableTokens, id: \.self) { token in
Button {
selection.wrappedValue = token
} label: {
Label(
token.editorLabel(provider: self.provider),
systemImage: token.editorSystemImage)
}
}
} label: {
MenuBarLayoutChipLabel(
title: selection.wrappedValue.editorLabel(provider: self.provider),
systemImage: selection.wrappedValue.editorSystemImage,
isSelected: false)
}
}
private static let selectableTokens: [MenuBarLayoutToken] = [
.icon,
.providerName,
.accountLabel,
.percent(window: .session),
.percent(window: .weekly),
.percent(window: .scopedWeekly),
.percent(window: .automatic),
.usageBar,
.pace(window: .session),
.pace(window: .weekly),
.pace(window: .automatic),
.resetCountdown,
.resetAbsolute,
.runsOut,
.runsOutCompact,
.balance,
.costToday,
.cost30d,
.separatorDot,
.space,
.hidden,
]
}
extension MenuBarLayoutConditional {
/// The chip label: the required name, falling back to a generic label if somehow empty.
var displayName: String {
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? L("menu_bar_layout_token_conditional") : trimmed
}
/// 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(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, 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, provider: provider)
text = mixed ? "(\(text)) \(joiner) \(pred)" : "\(text) \(joiner) \(pred)"
}
return text
}
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(provider: provider)
return L(
"menu_bar_layout_conditional_summary",
condition,
self.thenToken.editorLabel(provider: provider),
self.elseToken.editorLabel(provider: provider))
}
/// Generates a unique copy name that avoids collisions with existing library entries.
static func uniqueCopyName(basedOn name: String, existingNames: Set<String>) -> String {
let base = name.trimmingCharacters(in: .whitespacesAndNewlines)
let stem = base.isEmpty ? L("menu_bar_layout_token_conditional") : base
var candidate = L("menu_bar_layout_conditional_copy_name", stem)
var n = 2
while existingNames.contains(candidate.lowercased()) {
candidate = L("menu_bar_layout_conditional_copy_name_numbered", stem, n)
n += 1
}
return candidate
}
}
extension MenuBarConditionalMetric {
/// 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")
}
}
}