bc3c4b304e
* Extend menu bar conditionals beyond usage percentages Conditional predicates could only compare four percent-used windows. They now compare 18 metrics across four units: percent windows, the direct primary/secondary/tertiary lanes, four reset countdowns, three pace deltas, run-out, credit balance, and today/30-day cost. Metrics with two readings (percent windows, lanes, balance) gain a used/remaining select, so "session > 50% used and session resets in < 2h" is expressible. Pace, run-out, balance and cost were only carried as display strings, which cannot be compared, so MenuBarLayoutRenderMetrics carries their numeric twins pre-rounded to the same granularity as the text they mirror. Three refresh gates needed widening for the new data dependencies: - The title cache key had no component that moves with the clock, so a countdown predicate would have served its pre-flip title indefinitely. It now keys on the per-conditional outcome, evaluated once per render. - The four observation signatures gated on display tokens; a predicate on cost or balance has no token. They now also read the conditionals' metrics, which additionally fixes lane tokens inside conditional branches being invisible to the lane signature. - A reset-countdown predicate flips at an instant nothing else ticks on, so the countdown scheduler wakes at `resetsAt - threshold`. The conditional library is now decoded element-wise: this change makes forward-incompatible metric values possible for the first time, and one unknown value would otherwise have wiped the whole library on a downgrade. Ships an "Auto % / Resets in" default that renders the automatic percentage while the lane has headroom and the reset countdown once it is spent. * Sign the readings conditional predicates actually compare Three observation-signature gaps let a predicate flip without a redraw: - Cost signatures recorded only the currency-formatted string, so two token-cost updates could cross a threshold while both formatted to the same cent. A referenced cost metric now signs the unrounded amount losslessly. - The balance signature recorded only the rendered "Remaining" row, so a `balance used` predicate — which reads the "Used" row no token surfaces — was entirely unsigned. Both amounts are now signed. - The lane signature recorded the displayed reading, which follows `usageBarsShowUsed` and clamps remaining at zero, while `RateWindow.usedPercent` deliberately preserves over-quota values. A used-direction predicate such as `primaryLane > 105%` could move 104% -> 106% against a constant `0.000`. The lane signature is now scoped to what the layout renders, and a new conditional-window signature covers what conditionals read: the raw used percent (which remaining derives from, so it covers both directions) plus `resetsAt`, which countdown predicates depend on and no display token contributes. * Tick clock-derived predicates that no token schedules `menuBarWeeklyPaceRefreshDelays` is gated on a placed `.pace(.weekly)` token and only wakes once, at the pace-eligibility boundary. Excluding `runsOutIn` from the conditional reset schedule on the assumption that scheduler covered it therefore left a hole: a layout whose only pace or run-out reference is a predicate got no clock wake-up at all, so it kept rendering the branch that was true when the value last moved. Referenced weekly-pace predicates now also trigger the eligibility wake-up, and any referenced pace or run-out predicate schedules a minute tick. Both numbers are pre-rounded to the granularity the menu bar shows -- whole percentage points and whole minutes -- so a minute tick is exactly enough, and it is the cadence a `.resetCountdown` token already costs. Money predicates deliberately schedule nothing: they move only when new provider data arrives. * Keep older releases' conditional libraries readable on downgrade Decoding the library element-wise only helps builds that already have the lenient decoder. The build a user actually downgrades to decodes `menuBarLayoutConditionals` strictly and falls back to `[]`, so one saved rule using a new metric would empty the entire library there. The conditional library now dual-writes the way layouts already do: `menuBarLayoutConditionalsV2` keeps full fidelity, and the original key keeps an older-readable projection. `loadLibrary` mirrors `preferredLayout` — the current key wins unless the legacy key disagrees with its own projection, which only happens when an older release wrote it, and that edit must survive. The projection drops an entry when any clause uses a metric outside the original four, and also when any clause uses a non-`.used` direction. The second case is the subtler one: an older release's synthesized decoder ignores the unknown `direction` key, so `session remaining > 80` would come back as `session used > 80` and render the opposite branch. A missing rule is visibly missing; an inverted one is not. * Drop cost metrics that could not be converted to USD `UsageFormatter.convertedCost` returns the source amount unchanged when it has no rate for the provider's currency, and both cost producers passed that value straight through as `costTodayUSD`/`cost30dUSD`. A `Cost today > 5 USD` rule would then compare, say, €6 against a $5 threshold and pick the wrong branch. Both producers now keep the amount only when the conversion actually landed in USD. Otherwise the predicate sees no value and evaluates false, which is the existing contract for a metric the provider does not report. The rendered text is untouched and still shows the provider's own currency. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
411 lines
16 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|