Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions TokenMeterApp/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ final class AppSettings {
static let retentionDays = "retentionDays"
static let widgetShowTokens = "widgetShowTokens"
static let widgetShowReset = "widgetShowReset"
static let widgetBackgroundStyle = "widgetBackgroundStyle"
static let launchAtLogin = "launchAtLogin"
static let tokenNotation = "tokenNotation"
}
Expand Down Expand Up @@ -183,6 +184,13 @@ final class AppSettings {
var widgetShowTokens: Bool { didSet { defaults.set(widgetShowTokens, forKey: Key.widgetShowTokens) } }
var widgetShowReset: Bool { didSet { defaults.set(widgetShowReset, forKey: Key.widgetShowReset) } }

/// The widget background: solid (the long-standing default) or clear Liquid
/// Glass. The clear look needs macOS 26, so the picker is only offered there —
/// see `SettingsView`; the widget itself falls back to solid on older systems.
var widgetBackgroundStyle: WidgetBackgroundStyle {
didSet { defaults.set(widgetBackgroundStyle.rawValue, forKey: Key.widgetBackgroundStyle) }
}

var launchAtLogin: Bool {
didSet {
defaults.set(launchAtLogin, forKey: Key.launchAtLogin)
Expand Down Expand Up @@ -220,6 +228,7 @@ final class AppSettings {
Key.retentionDays: 90,
Key.widgetShowTokens: true,
Key.widgetShowReset: true,
Key.widgetBackgroundStyle: WidgetBackgroundStyle.solid.rawValue,
Key.launchAtLogin: false,
])

Expand Down Expand Up @@ -250,6 +259,9 @@ final class AppSettings {
retentionDays = defaults.integer(forKey: Key.retentionDays)
widgetShowTokens = defaults.bool(forKey: Key.widgetShowTokens)
widgetShowReset = defaults.bool(forKey: Key.widgetShowReset)
widgetBackgroundStyle = WidgetBackgroundStyle(
rawValue: defaults.string(forKey: Key.widgetBackgroundStyle) ?? ""
) ?? .solid
launchAtLogin = defaults.bool(forKey: Key.launchAtLogin)
}

Expand Down
3 changes: 2 additions & 1 deletion TokenMeterApp/UsageMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ final class UsageMonitor {
var snapshot = SharedSnapshot(
updatedAt: Date(),
languageCode: settings.appLanguage.rawValue,
tokenNotation: settings.effectiveTokenNotation
tokenNotation: settings.effectiveTokenNotation,
widgetBackgroundStyle: settings.widgetBackgroundStyle
)

for id in UsageProviderID.allCases {
Expand Down
11 changes: 11 additions & 0 deletions TokenMeterApp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ struct SettingsView: View {
Section("Widget") {
Toggle("Show token counts", isOn: $settings.widgetShowTokens)
Toggle("Show reset time", isOn: $settings.widgetShowReset)
// Liquid Glass exists only on macOS 26+, so the choice is offered
// there alone; older systems stay on the solid background.
if #available(macOS 26.0, *) {
Picker("Widget background", selection: $settings.widgetBackgroundStyle) {
Text("Solid").tag(WidgetBackgroundStyle.solid)
Text("Clear (Liquid Glass)").tag(WidgetBackgroundStyle.clear)
}
.onChange(of: settings.widgetBackgroundStyle) { _, _ in
monitor.publishSnapshot()
}
}
}

Section("Startup") {
Expand Down
3 changes: 3 additions & 0 deletions TokenMeterApp/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
"Widget" = "ウィジェット";
"Show token counts" = "トークン数を表示";
"Show reset time" = "リセット時刻を表示";
"Widget background" = "ウィジェットの背景";
"Solid" = "ソリッド";
"Clear (Liquid Glass)" = "クリア(リキッドグラス)";
"Startup" = "起動";
"Launch at login" = "ログイン時に起動";
"Updates" = "アップデート";
Expand Down
3 changes: 3 additions & 0 deletions TokenMeterApp/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
"Widget" = "위젯";
"Show token counts" = "토큰 수 표시";
"Show reset time" = "재설정 시간 표시";
"Widget background" = "위젯 배경";
"Solid" = "솔리드";
"Clear (Liquid Glass)" = "클리어 (리퀴드 글래스)";
"Startup" = "시작";
"Launch at login" = "로그인 시 실행";
"Updates" = "업데이트";
Expand Down
3 changes: 3 additions & 0 deletions TokenMeterApp/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
"Widget" = "小组件";
"Show token counts" = "显示 Token 数";
"Show reset time" = "显示重置时间";
"Widget background" = "小组件背景";
"Solid" = "实心";
"Clear (Liquid Glass)" = "透明(液态玻璃)";
"Startup" = "启动";
"Launch at login" = "登录时启动";
"Updates" = "更新";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import Foundation

/// How the widget fills its container background.
///
/// `.clear` renders as Liquid Glass and is only offered on macOS 26+, where that
/// API exists; the widget falls back to `.solid` on earlier systems.
public enum WidgetBackgroundStyle: String, Codable, Sendable, CaseIterable {
/// The opaque material the widget has always used (`.fill.tertiary`).
case solid
/// A translucent Liquid Glass background that lets the wallpaper show through.
case clear
}

/// The payload the widget reads. It contains only what we actually have: every
/// field is optional, and the widget renders "—" rather than 0 for a missing one.
public struct SharedSnapshot: Codable, Sendable, Equatable {
Expand Down Expand Up @@ -130,6 +141,9 @@ public struct SharedSnapshot: Codable, Sendable, Equatable {
/// The token notation selected in the main app. Optional for compatibility with
/// snapshots written by older releases, which predate the choice.
public var tokenNotation: TokenNotation?
/// The widget background the user picked. Optional for compatibility with
/// snapshots written by older releases; the widget reads `.solid` when absent.
public var widgetBackgroundStyle: WidgetBackgroundStyle?
public var claudeCode: Provider?
public var codex: Provider?
/// Optional for compatibility with snapshots written by older releases.
Expand All @@ -139,13 +153,15 @@ public struct SharedSnapshot: Codable, Sendable, Equatable {
updatedAt: Date,
languageCode: String? = nil,
tokenNotation: TokenNotation? = nil,
widgetBackgroundStyle: WidgetBackgroundStyle? = nil,
claudeCode: Provider? = nil,
codex: Provider? = nil,
copilotCli: Provider? = nil
) {
self.updatedAt = updatedAt
self.languageCode = languageCode
self.tokenNotation = tokenNotation
self.widgetBackgroundStyle = widgetBackgroundStyle
self.claudeCode = claudeCode
self.codex = codex
self.copilotCli = copilotCli
Expand Down
179 changes: 178 additions & 1 deletion TokenMeterWidget/TokenMeterWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,13 @@ struct ProviderRow: View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(.quaternary)
// In tinted (accented) mode the fill joins the accent
// group so the bar keeps contrast against the dimmed
// track; the status colour only shows in full-colour mode.
Capsule()
.fill(level.tint)
.frame(width: geo.size.width * max(0, min(1, remaining)))
.widgetAccentable()
}
}
.frame(height: 4)
Expand Down Expand Up @@ -234,6 +238,8 @@ struct ProviderRow: View {
.font(.caption.weight(.semibold))
.monospacedDigit()
}
// The headline figure carries the accent colour in tinted mode.
.widgetAccentable()
} else if let tokens = provider?.todayWorkingTokens, tokens > 0 {
Text(self.tokens(tokens))
.font(.caption.weight(.semibold))
Expand Down Expand Up @@ -268,6 +274,8 @@ struct WidgetProviderIcon: View {
.scaledToFit()
.frame(width: size, height: size)
.foregroundStyle(.primary)
// The brand mark reads as an accent element in tinted mode.
.widgetAccentable()
.accessibilityHidden(true)
}
}
Expand Down Expand Up @@ -560,6 +568,9 @@ struct UsageLineChart: View {
.position(point)
}
}
// The plotted line and points take the accent colour in
// tinted mode; the axes and labels stay in the dimmed group.
.widgetAccentable()
}
.frame(height: plotHeight)

Expand Down Expand Up @@ -609,7 +620,24 @@ struct TokenMeterWidgetEntryView: View {
)
// Tapping anywhere opens the dashboard.
.widgetURL(URL(string: "tokenmeter://dashboard"))
.containerBackground(.fill.tertiary, for: .widget)
.widgetContainerBackground(style: entry.snapshot?.widgetBackgroundStyle ?? .solid)
}
}

private extension View {
/// Fills the widget container per the user's chosen style. Clear renders as
/// Liquid Glass, which only exists on macOS 26+; on anything older — and for
/// `.solid` — it falls back to the opaque `.fill.tertiary` the widget has
/// always used, so the widget looks right on every system it can run on.
@ViewBuilder
func widgetContainerBackground(style: WidgetBackgroundStyle) -> some View {
if style == .clear, #available(macOS 26.0, *) {
containerBackground(for: .widget) {
Rectangle().fill(.clear).glassEffect(.regular, in: .rect)
}
} else {
containerBackground(.fill.tertiary, for: .widget)
}
}
}

Expand Down Expand Up @@ -638,3 +666,152 @@ extension UsageStatusLevel {
}
}
}

// MARK: - Previews

// Canvas previews with realistic data, so the widget can be checked without the
// main app writing a snapshot. Use the canvas's rendering-mode control to see the
// tinted (accented) appearance, and the Solid/Clear variants below to compare the
// two background styles. Clear renders as Liquid Glass only on macOS 26+.
#if DEBUG
private extension SharedSnapshot {
static func previewSample(background: WidgetBackgroundStyle) -> SharedSnapshot {
let now = Date()
let cal = Calendar.current
func days(_ delta: Int) -> Date { cal.date(byAdding: .day, value: delta, to: now) ?? now }
func hours(_ delta: Double) -> Date { now.addingTimeInterval(delta * 3600) }
func series(_ values: [Int]) -> [DayPoint] {
values.enumerated().map { index, value in
DayPoint(day: days(-(values.count - 1 - index)), workingTokens: value)
}
}

let claude = Provider(
displayName: "Claude Code",
todayWorkingTokens: 184_000,
hasQuotaInformation: true,
dailyTotals: series([120_000, 340_000, 90_000, 510_000, 260_000, 430_000, 184_000]),
fiveHourWindow: TokenWindowUsage(
start: hours(-2), resetsAt: hours(3),
tokens: 420_000, workingTokens: 384_000, boundary: .inferred
),
weeklyWindow: TokenWindowUsage(
start: days(-4), resetsAt: days(3),
tokens: 5_800_000, workingTokens: 5_200_000, boundary: .reported
),
fiveHourQuota: UsageWindow(usedRatio: 0.32, remainingRatio: 0.68, resetsAt: hours(3)),
weeklyQuota: UsageWindow(usedRatio: 0.55, remainingRatio: 0.45, resetsAt: days(3))
)

let codex = Provider(
displayName: "Codex",
todayWorkingTokens: 92_000,
hasQuotaInformation: true,
dailyTotals: series([80_000, 60_000, 210_000, 140_000, 60_000, 120_000, 92_000]),
fiveHourWindow: TokenWindowUsage(
start: hours(-1), resetsAt: hours(4),
tokens: 150_000, workingTokens: 138_000, boundary: .reported, windowMinutes: 300
),
weeklyWindow: TokenWindowUsage(
start: days(-3), resetsAt: days(4),
tokens: 2_100_000, workingTokens: 1_900_000, boundary: .reported
),
fiveHourQuota: UsageWindow(usedRatio: 0.58, remainingRatio: 0.42, resetsAt: hours(4), windowMinutes: 300),
weeklyQuota: UsageWindow(usedRatio: 0.30, remainingRatio: 0.70, resetsAt: days(4))
)

return SharedSnapshot(
updatedAt: now.addingTimeInterval(-180),
languageCode: "en",
tokenNotation: .metric,
widgetBackgroundStyle: background,
claudeCode: claude,
codex: codex
)
}
}

private extension Entry {
static func preview(_ background: WidgetBackgroundStyle) -> Entry {
Entry(date: Date(), snapshot: .previewSample(background: background), isPlaceholder: false)
}
}

/// Sizes in points that macOS uses for each desktop widget family, so the preview
/// frame matches the real proportions closely enough to judge layout and fit.
private enum PreviewSize {
static let small = CGSize(width: 170, height: 170)
static let medium = CGSize(width: 364, height: 170)
static let large = CGSize(width: 364, height: 382)
}

/// Hosts a widget size view in a simulated desktop-widget frame.
///
/// The macOS canvas cannot launch a widget extension ("This platform does not
/// support previewing widgets"), so instead of `#Preview(as:)` these render the
/// plain SwiftUI views the widget is built from — which the canvas handles fine.
/// A stand-in wallpaper sits behind the frame so a clear (Liquid Glass) background
/// has something to reveal. True tinted/accented rendering only happens on a real
/// desktop widget: run the app, add the widget, and pick Tinted in Edit Widget.
private struct WidgetFramePreview<Content: View>: View {
let size: CGSize
let background: WidgetBackgroundStyle
@ViewBuilder let content: () -> Content

var body: some View {
content()
.environment(\.locale, Locale(identifier: "en"))
.environment(
\.tokenFormatter,
TokenFormatter(notation: .metric, locale: Locale(identifier: "en"))
)
.padding(14)
.frame(width: size.width, height: size.height, alignment: .topLeading)
.background {
if background == .clear, #available(macOS 26.0, *) {
Rectangle().fill(.clear).glassEffect(.regular, in: .rect(cornerRadius: 22))
} else {
Rectangle().fill(.fill.tertiary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 22))
.padding(28)
.background {
LinearGradient(
colors: [.blue, .indigo, .purple],
startPoint: .topLeading, endPoint: .bottomTrailing
)
}
}
}

#Preview("Small · Solid") {
WidgetFramePreview(size: PreviewSize.small, background: .solid) {
SmallWidgetView(entry: .preview(.solid))
}
}

#Preview("Medium · Solid") {
WidgetFramePreview(size: PreviewSize.medium, background: .solid) {
MediumWidgetView(entry: .preview(.solid))
}
}

#Preview("Medium · Clear (Liquid Glass)") {
WidgetFramePreview(size: PreviewSize.medium, background: .clear) {
MediumWidgetView(entry: .preview(.clear))
}
}

#Preview("Large · Solid") {
WidgetFramePreview(size: PreviewSize.large, background: .solid) {
LargeWidgetView(entry: .preview(.solid))
}
}

#Preview("Large · Clear (Liquid Glass)") {
WidgetFramePreview(size: PreviewSize.large, background: .clear) {
LargeWidgetView(entry: .preview(.clear))
}
}
#endif