From 3f2a3ffe41096a0be32a5fe6d8377af68c256018 Mon Sep 17 00:00:00 2001 From: TakeruF <99144975+TakeruF@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:09:46 +0900 Subject: [PATCH] Add widget background style (solid / clear Liquid Glass) and tinted support Let the user choose the widget background in Settings: - Solid: the existing opaque .fill.tertiary (light/dark aware). - Clear (Liquid Glass): translucent glassEffect background, offered only on macOS 26+ where the API exists; the widget falls back to solid on older systems. The choice rides the App Group snapshot (WidgetBackgroundStyle) alongside the existing language/notation, and republishes on change. Also optimize the widget for tinted (accented) rendering mode: brand icons, the progress-bar fill, the headline percentage, and the large-widget chart line are marked .widgetAccentable() so they take the accent colour while labels stay in the dimmed group. containerBackground is left removable, so the OS-level light/dark/tinted/clear appearances all work. Adds sample-data SwiftUI previews (plain views, since macOS cannot preview a widget extension in the canvas). Co-Authored-By: Claude Opus 4.8 (1M context) --- TokenMeterApp/AppSettings.swift | 12 ++ TokenMeterApp/UsageMonitor.swift | 3 +- TokenMeterApp/Views/SettingsView.swift | 11 ++ TokenMeterApp/ja.lproj/Localizable.strings | 3 + TokenMeterApp/ko.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../Persistence/SharedSnapshot.swift | 16 ++ TokenMeterWidget/TokenMeterWidget.swift | 179 +++++++++++++++++- 8 files changed, 228 insertions(+), 2 deletions(-) diff --git a/TokenMeterApp/AppSettings.swift b/TokenMeterApp/AppSettings.swift index 1593689..e7bd7aa 100644 --- a/TokenMeterApp/AppSettings.swift +++ b/TokenMeterApp/AppSettings.swift @@ -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" } @@ -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) @@ -220,6 +228,7 @@ final class AppSettings { Key.retentionDays: 90, Key.widgetShowTokens: true, Key.widgetShowReset: true, + Key.widgetBackgroundStyle: WidgetBackgroundStyle.solid.rawValue, Key.launchAtLogin: false, ]) @@ -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) } diff --git a/TokenMeterApp/UsageMonitor.swift b/TokenMeterApp/UsageMonitor.swift index c4f5ebc..1c8082e 100644 --- a/TokenMeterApp/UsageMonitor.swift +++ b/TokenMeterApp/UsageMonitor.swift @@ -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 { diff --git a/TokenMeterApp/Views/SettingsView.swift b/TokenMeterApp/Views/SettingsView.swift index 58bc8d9..d88dbb2 100644 --- a/TokenMeterApp/Views/SettingsView.swift +++ b/TokenMeterApp/Views/SettingsView.swift @@ -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") { diff --git a/TokenMeterApp/ja.lproj/Localizable.strings b/TokenMeterApp/ja.lproj/Localizable.strings index 2b8ef27..2713f10 100644 --- a/TokenMeterApp/ja.lproj/Localizable.strings +++ b/TokenMeterApp/ja.lproj/Localizable.strings @@ -77,6 +77,9 @@ "Widget" = "ウィジェット"; "Show token counts" = "トークン数を表示"; "Show reset time" = "リセット時刻を表示"; +"Widget background" = "ウィジェットの背景"; +"Solid" = "ソリッド"; +"Clear (Liquid Glass)" = "クリア(リキッドグラス)"; "Startup" = "起動"; "Launch at login" = "ログイン時に起動"; "Updates" = "アップデート"; diff --git a/TokenMeterApp/ko.lproj/Localizable.strings b/TokenMeterApp/ko.lproj/Localizable.strings index 0344b51..698eeb0 100644 --- a/TokenMeterApp/ko.lproj/Localizable.strings +++ b/TokenMeterApp/ko.lproj/Localizable.strings @@ -77,6 +77,9 @@ "Widget" = "위젯"; "Show token counts" = "토큰 수 표시"; "Show reset time" = "재설정 시간 표시"; +"Widget background" = "위젯 배경"; +"Solid" = "솔리드"; +"Clear (Liquid Glass)" = "클리어 (리퀴드 글래스)"; "Startup" = "시작"; "Launch at login" = "로그인 시 실행"; "Updates" = "업데이트"; diff --git a/TokenMeterApp/zh-Hans.lproj/Localizable.strings b/TokenMeterApp/zh-Hans.lproj/Localizable.strings index e4dd3e6..cba0109 100644 --- a/TokenMeterApp/zh-Hans.lproj/Localizable.strings +++ b/TokenMeterApp/zh-Hans.lproj/Localizable.strings @@ -77,6 +77,9 @@ "Widget" = "小组件"; "Show token counts" = "显示 Token 数"; "Show reset time" = "显示重置时间"; +"Widget background" = "小组件背景"; +"Solid" = "实心"; +"Clear (Liquid Glass)" = "透明(液态玻璃)"; "Startup" = "启动"; "Launch at login" = "登录时启动"; "Updates" = "更新"; diff --git a/TokenMeterCore/Sources/TokenMeterCore/Persistence/SharedSnapshot.swift b/TokenMeterCore/Sources/TokenMeterCore/Persistence/SharedSnapshot.swift index 74f4ccd..5570a29 100644 --- a/TokenMeterCore/Sources/TokenMeterCore/Persistence/SharedSnapshot.swift +++ b/TokenMeterCore/Sources/TokenMeterCore/Persistence/SharedSnapshot.swift @@ -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 { @@ -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. @@ -139,6 +153,7 @@ 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 @@ -146,6 +161,7 @@ public struct SharedSnapshot: Codable, Sendable, Equatable { self.updatedAt = updatedAt self.languageCode = languageCode self.tokenNotation = tokenNotation + self.widgetBackgroundStyle = widgetBackgroundStyle self.claudeCode = claudeCode self.codex = codex self.copilotCli = copilotCli diff --git a/TokenMeterWidget/TokenMeterWidget.swift b/TokenMeterWidget/TokenMeterWidget.swift index 5bb2870..93be84a 100644 --- a/TokenMeterWidget/TokenMeterWidget.swift +++ b/TokenMeterWidget/TokenMeterWidget.swift @@ -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) @@ -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)) @@ -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) } } @@ -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) @@ -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) + } } } @@ -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: 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