项目文件夹

文件
2026-07-13 12:22:33 +08:00

683 行
26 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import AppKit
import CodexBarCore
import SwiftUI
import WidgetKit
// MARK: - Entry View
struct BurnDownWidgetView: View {
let entry: BurnDownEntry
var body: some View {
let state = BurnDownState(
snapshot: self.entry.snapshot,
provider: self.entry.provider,
selection: self.entry.window)
Group {
if let state, let window = state.selectedWindow {
BurnDownLayout(
window: window,
provider: self.entry.provider,
blankChart: state.blankPrimaryChart,
resetsAtOverride: state.selectedResetOverride)
} else {
self.emptyState
}
}
.containerBackground(for: .widget) {
BurnWidgetBackground()
}
}
private var emptyState: some View {
VStack(spacing: 6) {
Text("Open CodexBar")
.font(.body)
.fontWeight(.semibold)
Text("Usage data will appear once the app refreshes.")
.font(.caption)
.multilineTextAlignment(.center)
.opacity(0.55)
}
.padding(12)
}
}
// MARK: - Main Layout
private struct BurnDownLayout: View {
@Environment(\.widgetRenderingMode) private var renderingMode
@Environment(\.colorScheme) private var colorScheme
let window: RateWindow
let provider: UsageProvider
/// True when the session window is blocked because the weekly budget is exhausted:
/// suppress the chart and retarget "Resets in" to the weekly reset.
var blankChart = false
/// When set, "Resets in" counts down to this date (the weekly reset) instead of the
/// session window's own reset.
var resetsAtOverride: Date?
var body: some View {
let dark = self.colorScheme == .dark
let isMonochrome = self.renderingMode != .fullColor
let geom = BurnGeom(window: self.window)
let theme = BurnTheme(provider: self.provider, geom: geom, dark: dark, isMonochrome: isMonochrome)
let windowMins = self.window.windowMinutes ?? 300
let isDailyWindow = windowMins >= 1440
let now = Date()
let estimatedResetMinutes = self.blankChart || geom.tNow >= 1
? nil
: (1 - geom.tNow) * Double(windowMins)
let explicitReset = self.blankChart
? self.resetsAtOverride
: self.resetsAtOverride ?? self.window.resetsAt
let effectiveResetAt = burnEffectiveResetDate(
explicitResetAt: explicitReset,
estimatedResetMinutes: estimatedResetMinutes,
now: now)
let resetsIn = effectiveResetAt.map { max(0, $0.timeIntervalSince(now) / 60) } ?? 0
let outInMins = geom.slope < -0.01 ? (geom.vNow / -geom.slope) * Double(windowMins) : Double.infinity
// Very early in the window a single sample can't forecast a credible run-out: a
// tiny burst right after reset extrapolates to "runs dry in minutes" even at ~99%
// remaining. Match the design's fresh-window behaviour ("Runs out: after reset")
// and only surface the estimate once enough of the window has elapsed to trust the
// average burn rate.
let windowEstablished = geom.tNow >= 0.08
let runsDryBefore = geom.runsOut && outInMins < resetsIn && windowEstablished
let sign = geom.margin >= 0 ? "+" : ""
let badgeNum = "\(sign)\(abs(Int(geom.margin.rounded())))%"
// Per the design's edge states, "fresh" and "spent" show only the glyph + word
// (◆ full / ■ spent) with no pace number — the margin is meaningless once the
// budget is full or gone.
let showBadgeNumber = !geom.depleted && !geom.fresh
let statusWord: String = geom.depleted ? "spent" : geom.fresh ? "full"
: geom.status == .ahead ? "conserving" : geom.status == .behind ? "over pace" : "on pace"
let arrow: String = geom.depleted ? "■" : geom.fresh ? "◆"
: geom.status == .ahead ? "▲" : geom.status == .behind ? "▼" : "●"
let axisDates = burnAxisDateRange(
effectiveResetAt: effectiveResetAt,
windowMinutes: windowMins,
now: now)
let startLabel = burnAxisLabel(axisDates.start, isDailyWindow: isDailyWindow)
let resetLabel = burnAxisLabel(axisDates.reset, isDailyWindow: isDailyWindow)
VStack(spacing: 0) {
// Header: brand + pace badge
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Circle()
.fill(theme.brandDot)
.frame(width: 7, height: 7)
.shadow(color: theme.brandDot.opacity(0.7), radius: 3.5)
Text(burnProviderName(self.provider))
.font(.system(size: 14.5, weight: .semibold))
.foregroundStyle(theme.text)
.lineLimit(1)
}
Text(burnWindowLabel(self.window.windowMinutes))
.font(.system(size: 11))
.foregroundStyle(theme.sub)
.kerning(0.2)
}
Spacer()
VStack(alignment: .trailing, spacing: 1) {
if showBadgeNumber {
Text(badgeNum)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(theme.statusColor)
.monospacedDigit()
}
HStack(spacing: 3) {
Text(arrow)
.font(.system(size: 8))
.foregroundStyle(theme.statusColor)
Text(statusWord)
.font(.system(size: 10.5))
.foregroundStyle(theme.sub)
}
}
}
// Body: stats + hero / chart
HStack(alignment: .bottom, spacing: 13) {
// Left: stats + hero %
VStack(alignment: .leading, spacing: 0) {
VStack(spacing: 5) {
BurnResetStatRow(resetAt: effectiveResetAt, theme: theme)
BurnStatRow(
label: geom.depleted ? "Ran out" : runsDryBefore ? "Runs out in" : "Runs out",
value: geom
.depleted ? "budget spent" : runsDryBefore ? "~\(burnFmtDuration(outInMins))" :
"after reset",
theme: theme,
danger: geom.depleted || runsDryBefore)
}
.padding(.top, 8)
Spacer()
HStack(alignment: .lastTextBaseline, spacing: 5) {
Text("\(Int(geom.vNow.rounded()))")
.font(.system(size: 41, weight: .semibold))
.foregroundStyle(geom.depleted ? theme.danger : theme.text)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.75)
Text("%")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(theme.sub)
Text("left")
.font(.system(size: 11))
.foregroundStyle(theme.sub)
}
}
.frame(width: 143, alignment: .leading)
// Right: chart + axis. Blanked when the session window is blocked by the
// weekly cap — there's no session burn to chart until the weekly resets.
VStack(spacing: 2) {
if self.blankChart {
Color.clear.frame(height: 84)
Color.clear.frame(height: 13)
} else {
BurnChartCanvas(
geom: geom,
theme: theme)
.frame(height: 84)
BurnAxisRow(
startLabel: startLabel,
resetLabel: resetLabel,
tNow: geom.tNow,
theme: theme)
.frame(height: 13)
}
}
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 15)
.padding(.top, 13)
.padding(.bottom, 12)
}
}
// MARK: - Stat Row
private struct BurnStatRow: View {
let label: String
let value: String
let theme: BurnTheme
let danger: Bool
var body: some View {
HStack(alignment: .firstTextBaseline) {
Text(self.label)
.font(.system(size: 11.5))
.foregroundStyle(self.theme.sub)
Spacer()
Text(self.value)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(self.danger ? self.theme.danger : self.theme.text)
.monospacedDigit()
.lineLimit(1)
}
}
}
private struct BurnResetStatRow: View {
let resetAt: Date?
let theme: BurnTheme
var body: some View {
HStack(alignment: .firstTextBaseline) {
Text("Resets in")
.font(.system(size: 11.5))
.foregroundStyle(self.theme.sub)
Spacer()
if let resetAt {
Text(resetAt, style: .relative)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(self.theme.text)
.monospacedDigit()
.lineLimit(1)
} else {
Text("—")
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(self.theme.text)
}
}
}
}
// MARK: - Axis Row
private struct BurnAxisRow: View {
let startLabel: String
let resetLabel: String
let tNow: Double
let theme: BurnTheme
var body: some View {
GeometryReader { geo in
// Hide "now" when it would collide with the start/reset labels. The edge labels
// are anchored to the ends, so estimate their widths (≈9.5pt monospaced digits)
// and only show "now" when it clears both with a small gap — otherwise the
// now-dot on the chart already conveys position. Matches the design's rule that
// "now" hides near an end label.
let w = geo.size.width
let approxChar: CGFloat = 5.8
let nowX = self.tNow * w
let nowHalf: CGFloat = 13
let gap: CGFloat = 6
let clearsStart = nowX - nowHalf > CGFloat(self.startLabel.count) * approxChar + gap
let clearsReset = nowX + nowHalf < w - CGFloat(self.resetLabel.count) * approxChar - gap
let showNow = self.tNow > 0.05 && self.tNow < 0.95 && clearsStart && clearsReset
ZStack(alignment: .leading) {
Text(self.startLabel)
.font(.system(size: 9.5))
.foregroundStyle(self.theme.sub)
.monospacedDigit()
.frame(maxWidth: .infinity, alignment: .leading)
if showNow {
Text("now")
.font(.system(size: 9.5, weight: .semibold))
.foregroundStyle(self.theme.text)
.position(x: nowX, y: geo.size.height / 2)
}
Text(self.resetLabel)
.font(.system(size: 9.5))
.foregroundStyle(self.theme.sub)
.monospacedDigit()
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
}
// MARK: - Chart Canvas
private struct BurnChartCanvas: View {
let geom: BurnGeom
let theme: BurnTheme
var body: some View {
Canvas { context, size in
let w = size.width
let h = size.height
let padT: CGFloat = 8
let padB: CGFloat = 2
let padL: CGFloat = 1
let padR: CGFloat = 1
func X(_ t: Double) -> CGFloat {
padL + CGFloat(t) * (w - padL - padR)
}
func Y(_ v: Double) -> CGFloat {
padT + CGFloat(1 - v / 100) * (h - padT - padB)
}
let tNow = self.geom.tNow
let vNow = self.geom.vNow
// --- Now vertical hairline ---
do {
var p = Path()
p.move(to: CGPoint(x: X(tNow), y: Y(100)))
p.addLine(to: CGPoint(x: X(tNow), y: Y(0)))
context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1)
}
// --- Baseline ---
do {
var p = Path()
p.move(to: CGPoint(x: X(0), y: Y(0)))
p.addLine(to: CGPoint(x: X(1), y: Y(0)))
context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1)
}
// --- Area fill (gradient from actual line down to baseline) ---
do {
var p = Path()
p.move(to: CGPoint(x: X(0), y: Y(100)))
p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow)))
p.addLine(to: CGPoint(x: X(tNow), y: Y(0)))
p.addLine(to: CGPoint(x: X(0), y: Y(0)))
p.closeSubpath()
let gradient = Gradient(stops: [
.init(color: self.theme.chartFillTop.opacity(self.theme.chartFillTopOpacity), location: 0),
.init(color: self.theme.chartFillTop.opacity(0), location: 0.92),
])
context.fill(
p,
with: .linearGradient(
gradient,
startPoint: CGPoint(x: 0, y: padT),
endPoint: CGPoint(x: 0, y: h)))
}
// --- Ideal line (dashed, knocked back) ---
do {
var p = Path()
p.move(to: CGPoint(x: X(0), y: Y(100)))
p.addLine(to: CGPoint(x: X(1), y: Y(0)))
context.stroke(
p,
with: .color(self.theme.chartIdeal),
style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3]))
}
// --- Projection (fine dotted) ---
if self.geom.slope < -0.01 {
var p = Path()
p.move(to: CGPoint(x: X(tNow), y: Y(vNow)))
p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV)))
context.stroke(
p,
with: .color(self.theme.chartProj.opacity(0.95)),
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5]))
}
// --- Actual line (solid, hero) ---
do {
var p = Path()
p.move(to: CGPoint(x: X(0), y: Y(100)))
p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow)))
context.stroke(
p,
with: .color(self.theme.chartLine),
style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round))
}
// --- Now dot (filled, with ring punched in bg color) ---
let dotCenter = CGPoint(x: X(tNow), y: Y(vNow))
let ringPath = Path(ellipseIn: CGRect(
x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8))
context.fill(ringPath, with: .color(self.theme.chartNowRing))
let dotPath = Path(ellipseIn: CGRect(
x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8))
context.fill(dotPath, with: .color(self.theme.chartNowDot))
}
}
}
// MARK: - Background
struct BurnWidgetBackground: View {
@Environment(\.widgetRenderingMode) private var renderingMode
@Environment(\.colorScheme) private var colorScheme
var body: some View {
if self.renderingMode == .fullColor {
let dark = self.colorScheme == .dark
LinearGradient(
colors: dark
? [BurnPalette.darkBgTop, BurnPalette.darkBgBottom]
: [BurnPalette.lightBgTop, BurnPalette.lightBgBottom],
startPoint: .init(x: 0.15, y: 0),
endPoint: .init(x: 0.85, y: 1))
.overlay(alignment: .top) {
LinearGradient(
colors: [
Color.white.opacity(dark ? 0.04 : 0.60),
Color.white.opacity(0),
],
startPoint: .top,
endPoint: .center)
}
} else {
Color.clear
}
}
}
// MARK: - Theme
struct BurnTheme {
let text: Color
let sub: Color
let hair: Color
let accent: Color
let statusColor: Color
let danger: Color
let brandDot: Color
let chartLine: Color
let chartFillTop: Color
let chartFillTopOpacity: Double
let chartIdeal: Color
let chartProj: Color
let chartGrid: Color
let chartNowDot: Color
let chartNowRing: Color
init(provider: UsageProvider, geom: BurnGeom, dark: Bool, isMonochrome: Bool) {
if isMonochrome {
let fg = dark ? Color.white : Color.black
self.text = fg.opacity(dark ? 0.95 : 0.90)
self.sub = fg.opacity(dark ? 0.50 : 0.46)
self.hair = fg.opacity(dark ? 0.13 : 0.11)
self.accent = fg.opacity(dark ? 0.95 : 0.85)
self.statusColor = fg.opacity(dark ? 0.90 : 0.80)
self.danger = fg.opacity(dark ? 0.95 : 0.85)
self.brandDot = fg.opacity(dark ? 0.85 : 0.72)
self.chartLine = fg.opacity(dark ? 0.95 : 0.85)
self.chartFillTop = fg.opacity(dark ? 0.95 : 0.85)
self.chartFillTopOpacity = dark ? 0.20 : 0.16
self.chartIdeal = fg.opacity(dark ? 0.36 : 0.30)
self.chartProj = fg.opacity(dark ? 0.60 : 0.48)
self.chartGrid = fg.opacity(dark ? 0.12 : 0.10)
self.chartNowDot = fg.opacity(dark ? 1.0 : 0.92)
self.chartNowRing = dark
? Color(red: 0.10, green: 0.10, blue: 0.12).opacity(0.92)
: Color(red: 0.96, green: 0.96, blue: 0.97).opacity(0.95)
} else {
let accentColor = Self.accentColor(geom.status, dark: dark)
self.accent = accentColor
self.statusColor = accentColor
self.text = dark ? Color.white.opacity(0.98) : Color(white: 0.20)
self.sub = dark ? Color(white: 0.60) : Color(white: 0.45)
self.hair = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09)
self.danger = BurnPalette.behindDark
self.brandDot = Self.brandDotColor(provider)
self.chartLine = accentColor
self.chartFillTop = accentColor
self.chartFillTopOpacity = dark ? 0.30 : 0.22
self.chartIdeal = dark ? Color.white.opacity(0.45) : Color.black.opacity(0.50)
// Projection goes red when behind (the one allowed color cue in full-color mode)
self.chartProj = geom.status == .behind ? BurnPalette.behindDark : accentColor
self.chartGrid = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09)
self.chartNowDot = accentColor
self.chartNowRing = dark ? BurnPalette.darkBgBottom : BurnPalette.lightBgBottom
}
}
private static func accentColor(_ status: BurnGeom.Status, dark: Bool) -> Color {
switch status {
case .ahead: dark ? BurnPalette.aheadDark : BurnPalette.aheadLight
case .onpace: dark ? BurnPalette.onpaceDark : BurnPalette.onpaceLight
case .behind: dark ? BurnPalette.behindDark : BurnPalette.behindLight
}
}
private static func brandDotColor(_ provider: UsageProvider) -> Color {
switch provider {
case .claude: BurnPalette.claudeDot
case .codex: BurnPalette.codexDot
case .gemini: BurnPalette.geminiDot
default: BurnPalette.genericDot
}
}
}
// MARK: - Palette
enum BurnPalette {
// Status accents — approximated from OKLCH (L=0.80 dark, L=0.62 light)
// oklch(0.80 0.15 152) / oklch(0.62 0.15 152) — green
static let aheadDark = Color(red: 0.306, green: 0.800, blue: 0.506)
static let aheadLight = Color(red: 0.192, green: 0.620, blue: 0.376)
// oklch(0.80 0.11 236) / oklch(0.62 0.11 236) — blue
static let onpaceDark = Color(red: 0.408, green: 0.668, blue: 0.910)
static let onpaceLight = Color(red: 0.264, green: 0.474, blue: 0.712)
// oklch(0.72 0.19 26) / oklch(0.60 0.19 26) — red-orange
static let behindDark = Color(red: 0.922, green: 0.420, blue: 0.227)
static let behindLight = Color(red: 0.762, green: 0.294, blue: 0.137)
// Brand identity dots — always the LLM's hue
static let claudeDot = Color(red: 0.880, green: 0.580, blue: 0.180) // clay/amber, hue 48
static let codexDot = Color(red: 0.120, green: 0.780, blue: 0.598) // teal, hue 168
static let geminiDot = Color(red: 0.420, green: 0.440, blue: 0.900) // indigo, hue 268
static let genericDot = Color(white: 0.60)
// Backgrounds
static let darkBgTop = Color(red: 0.108, green: 0.108, blue: 0.132)
static let darkBgBottom = Color(red: 0.132, green: 0.132, blue: 0.156)
static let lightBgTop = Color(white: 0.990)
static let lightBgBottom = Color(red: 0.940, green: 0.940, blue: 0.960)
}
// MARK: - Geometry
struct BurnGeom {
enum Status { case ahead, onpace, behind }
let vNow: Double // % remaining (0..100)
let tNow: Double // position in window (0..1)
let idealNow: Double // what you should have left = 100 * (1 - tNow)
let margin: Double // vNow - idealNow; + = conserving, = over pace
let slope: Double // %/unit-t (negative = burning)
let projT: Double // t where projection ends
let projV: Double // v where projection ends
let runsOut: Bool // projection hits 0 inside the window
var status: Status {
self.margin > 4 ? .ahead : self.margin < -4 ? .behind : .onpace
}
var depleted: Bool {
self.vNow <= 0.5
}
var fresh: Bool {
self.vNow >= 99.5
}
init(window: RateWindow) {
let remaining = max(0, min(100, window.remainingPercent))
self.vNow = remaining
let t: Double
if let resetsAt = window.resetsAt, let windowMins = window.windowMinutes, windowMins > 0 {
let minutesUntilReset = max(0, resetsAt.timeIntervalSinceNow / 60)
let minutesElapsed = Double(windowMins) - minutesUntilReset
t = max(0.001, min(0.999, minutesElapsed / Double(windowMins)))
} else {
t = max(0.001, min(0.999, window.usedPercent / 100.0))
}
self.tNow = t
self.idealNow = 100.0 * (1.0 - t)
self.margin = remaining - self.idealNow
let slope = t > 0.001 ? (remaining - 100.0) / t : -remaining
self.slope = slope
if slope < -0.01 {
let tOut = t + remaining / -slope
if tOut <= 1.0 {
self.projT = tOut
self.projV = 0
self.runsOut = true
} else {
self.projT = 1.0
self.projV = max(0, remaining + slope * (1.0 - t))
self.runsOut = false
}
} else {
self.projT = 1.0
self.projV = remaining
self.runsOut = false
}
}
}
// MARK: - Helpers
func burnWindowLabel(_ windowMinutes: Int?) -> String {
guard let mins = windowMinutes else { return "Usage limit" }
if mins < 60 { return "\(mins)-minute limit" }
let hours = mins / 60
if hours < 24 { return "\(hours)-hour limit" }
return "\(hours / 24)-day limit"
}
func burnEffectiveResetDate(
explicitResetAt: Date?,
estimatedResetMinutes: Double?,
now: Date) -> Date?
{
if let explicitResetAt {
return explicitResetAt > now ? explicitResetAt : nil
}
guard let estimatedResetMinutes, estimatedResetMinutes > 0 else { return nil }
return now.addingTimeInterval(estimatedResetMinutes * 60)
}
func burnAxisDateRange(
effectiveResetAt: Date?,
windowMinutes: Int,
now: Date) -> (start: Date, reset: Date)
{
let reset = effectiveResetAt ?? now
return (reset.addingTimeInterval(-Double(windowMinutes) * 60), reset)
}
func burnCompactWindowLabel(_ windowMinutes: Int?, fallback: String) -> String {
guard let minutes = windowMinutes else { return fallback }
if minutes < 60 { return "\(minutes)M" }
let hours = minutes / 60
if hours < 24 { return "\(hours)H" }
return "\(hours / 24)D"
}
func burnFmtDuration(_ minutes: Double) -> String {
guard minutes.isFinite, minutes > 0 else { return "—" }
if minutes >= 1440 {
let d = Int(minutes / 1440)
let h = Int(minutes / 60) % 24
return "\(d)d \(h)h"
}
let h = Int(minutes / 60)
let m = Int(minutes) % 60
if h <= 0 { return "\(max(1, m))m" }
return "\(h)h \(String(format: "%02d", m))m"
}
func burnAxisLabel(_ date: Date, isDailyWindow: Bool) -> String {
let f = DateFormatter()
if isDailyWindow {
// A rolling multi-day window's start and reset fall on the same weekday, so "EEE"
// would print the same label at both ends ("Sat … Sat"). Use a numeric date so the
// two ends are distinguishable.
f.setLocalizedDateFormatFromTemplate("Md")
} else {
f.dateStyle = .none
f.timeStyle = .short
}
return f.string(from: date)
}
func burnProviderName(_ provider: UsageProvider) -> String {
ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized
}