Files
2026-07-13 12:22:33 +08:00

683 lines
26 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}