on 2026.07.11.1840 help sign in
usage-menubar/UsageMenubar/MenuView.swift
526 lines 19 kB history raw
1import SwiftUI
3/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
4struct MenuView: View {
5 @ObservedObject var viewModel: ViewModel
7 /// Dims the balance while the pointer is over it, hinting that it is clickable.
8 @State private var isHoveringBalance = false
10 private static let websiteURL = URL(string: "https://hyper.charm.land")!
12 // MARK: - Type Scale
14 /// One rounded family, sized so that the balance is the only large element and
15 /// everything else steps down from it — nothing competes with it for attention.
16 /// The Claude percentage is the one exception, and it is deliberately set well
17 /// below the balance so it reads as the second thing on the page, not the first.
18 /// Monospaced digits keep numbers from jittering as their digits change.
19 private static let heroFont = Font.system(size: 46, weight: .semibold, design: .rounded)
20 .monospacedDigit()
21 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
22 .monospacedDigit()
23 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
24 private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
25 private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
26 private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
28 var body: some View {
29 VStack(alignment: .leading, spacing: 18) {
30 hero
31 if showsClaude {
32 hairline
33 claude
34 }
35 hairline
36 settings
37 hairline
38 footer
39 }
40 .padding(.horizontal, 18)
41 .padding(.top, 18)
42 .padding(.bottom, 14)
43 .frame(width: 280)
44 // Set as the environment font so the controls inherit the rounded design too.
45 .font(Self.controlFont)
46 .background(Color(nsColor: .windowBackgroundColor))
47 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
48 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
49 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
50 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
51 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
52 .animation(.easeInOut(duration: 0.3), value: historyValues.count)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
54 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
55 }
57 /// A section rule light enough to read as a pause rather than a border.
58 private var hairline: some View {
59 Divider().opacity(0.3)
60 }
62 // MARK: - Hero
64 private var hero: some View {
65 VStack(spacing: 8) {
66 if viewModel.isLoading && viewModel.balance == nil {
67 PulsingBolt()
68 .transition(.opacity)
69 } else {
70 VStack(spacing: 6) {
71 balanceNumber
72 caption
73 sparkline
74 }
75 .transition(.opacity)
76 }
78 if let lastUpdatedText = viewModel.lastUpdatedText {
79 Text(lastUpdatedText)
80 .font(Self.footnoteFont)
81 .foregroundColor(.secondary)
82 .transition(.opacity)
83 }
85 if let error = viewModel.errorMessage {
86 errorRow(error)
87 .transition(.opacity)
88 }
89 }
90 .frame(maxWidth: .infinity)
91 }
93 /// The balance itself. Clicking it copies the raw number to the clipboard.
94 private var balanceNumber: some View {
95 Button(action: { viewModel.copyBalanceToClipboard() }) {
96 HStack(alignment: .firstTextBaseline, spacing: 6) {
97 Text(viewModel.formattedBalance)
98 .font(Self.heroFont)
99 .foregroundColor(viewModel.balanceColor)
100 .contentTransition(.opacity)
102 if let symbol = viewModel.balanceTrend.symbolName {
103 Image(systemName: symbol)
104 .font(.system(size: 12, weight: .semibold))
105 .foregroundColor(.secondary)
106 .transition(.opacity)
107 }
108 }
109 // Without this the hover area is only the glyphs themselves.
110 .contentShape(Rectangle())
111 }
112 .buttonStyle(.plain)
113 .disabled(viewModel.balance == nil)
114 .help("Click to copy")
115 .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
116 .onHover { isHoveringBalance = $0 }
117 .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
118 }
120 /// Doubles as the copy confirmation so the layout doesn't shift.
121 private var caption: some View {
122 Group {
123 if viewModel.copiedConfirmation {
124 Text("✓ Copied")
125 .foregroundColor(.green)
126 } else {
127 Text("credits")
128 .foregroundColor(.secondary)
129 }
130 }
131 .font(Self.captionFont)
132 }
134 @ViewBuilder
135 private var sparkline: some View {
136 if historyValues.count >= 2 {
137 Sparkline(values: historyValues, color: viewModel.balanceColor)
138 .frame(height: 26)
139 .padding(.top, 4)
140 .transition(.opacity)
141 }
142 }
144 /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
145 private var historyValues: [Int] {
146 viewModel.balanceHistory.map { $0.balance }
147 }
149 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
150 /// message, and a tinted rounded background with no border.
151 private func errorRow(_ message: String) -> some View {
152 HStack(alignment: .top, spacing: 6) {
153 Image(systemName: "exclamationmark.triangle.fill")
154 .font(.system(size: 10, weight: .semibold))
156 Text(message)
157 .font(Self.captionFont)
158 // Long messages wrap instead of being truncated to one line.
159 .fixedSize(horizontal: false, vertical: true)
161 Spacer(minLength: 0)
162 }
163 .foregroundColor(.red)
164 .padding(.horizontal, 10)
165 .padding(.vertical, 7)
166 .background(
167 RoundedRectangle(cornerRadius: 8, style: .continuous)
168 .fill(Color.red.opacity(0.1))
169 )
170 .padding(.top, 2)
171 }
173 // MARK: - Claude
175 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
176 /// isn't signed in on this machine — the section doesn't appear, and no error is
177 /// shown for a service the user may simply not use.
178 private var showsClaude: Bool {
179 if viewModel.claudeError != nil { return true }
180 if let usage = viewModel.claudeUsage { return !usage.isEmpty }
181 return false
182 }
184 /// Claude Code's plan limits. The percentage leads, as the balance does above it,
185 /// with the individual windows underneath as quiet supporting detail.
186 private var claude: some View {
187 VStack(alignment: .leading, spacing: 12) {
188 HStack(spacing: 8) {
189 Text("Claude Code")
190 .font(Self.sectionFont)
192 Spacer(minLength: 0)
194 if let plan = viewModel.claudePlanLabel {
195 Text(plan)
196 .font(Self.captionFont)
197 .foregroundColor(.secondary)
198 }
199 }
201 if let usage = viewModel.claudeUsage, !usage.isEmpty {
202 usageDetail(usage)
203 }
205 if let error = viewModel.claudeError {
206 errorRow(error)
207 }
208 }
209 }
211 private func usageDetail(_ usage: ClaudeUsage) -> some View {
212 VStack(alignment: .leading, spacing: 12) {
213 if let headline = Self.headline(for: usage) {
214 HStack(alignment: .firstTextBaseline, spacing: 8) {
215 Text("\(headline.percent)%")
216 .font(Self.subheroFont)
217 .foregroundColor(Self.usageColor(headline.percent))
218 .contentTransition(.opacity)
220 Spacer(minLength: 0)
222 if let resets = headline.resetsIn {
223 Text("resets in \(resets)")
224 .font(Self.footnoteFont)
225 .foregroundColor(.secondary)
226 }
227 }
228 }
230 VStack(alignment: .leading, spacing: 9) {
231 if let fiveHour = usage.fiveHour {
232 window("5-hour", fiveHour)
233 }
234 if let sevenDay = usage.sevenDay {
235 window("7-day", sevenDay)
236 }
237 // Opus has its own weekly cap on Max plans, but it is only worth a row
238 // once it has actually been used.
239 if let opus = usage.sevenDayOpus, opus.utilization > 0 {
240 window("7-day Opus", opus)
241 }
242 }
243 }
244 }
246 /// One window: its name, its share of the allowance, and a hairline bar. The bar is
247 /// the only chrome here, and it carries the same color as the number beside it.
248 private func window(_ label: String, _ window: UsageWindow) -> some View {
249 let percent = Int(window.utilization.rounded())
251 return VStack(alignment: .leading, spacing: 5) {
252 HStack(spacing: 6) {
253 Text(label)
254 .font(Self.captionFont)
255 .foregroundColor(.secondary)
257 Spacer(minLength: 0)
259 Text("\(percent)%")
260 .font(Self.captionFont.monospacedDigit())
261 .foregroundColor(Self.usageColor(percent))
262 }
264 UsageBar(percent: percent, color: Self.usageColor(percent))
265 }
266 }
268 /// The number the section leads with: whichever limit the server flags as binding,
269 /// falling back to the 5-hour window when it flags none.
270 private static func headline(for usage: ClaudeUsage) -> (percent: Int, resetsIn: String?)? {
271 if let active = usage.activeLimit {
272 return (active.percent, active.resetsInFormatted)
273 }
274 if let fiveHour = usage.fiveHour {
275 return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
276 }
277 return nil
278 }
280 /// The balance colors read the other way round here, because for a limit more is
281 /// worse: green with room to spare, yellow closing in, red nearly spent.
282 private static func usageColor(_ percent: Int) -> Color {
283 if percent >= 90 { return .red }
284 if percent >= 70 { return .yellow }
285 return .green
286 }
288 // MARK: - Settings
290 private var settings: some View {
291 VStack(alignment: .leading, spacing: 14) {
292 VStack(alignment: .leading, spacing: 8) {
293 HStack(spacing: 8) {
294 Text("API Key")
295 .font(Self.sectionFont)
297 Spacer(minLength: 0)
299 // The confirmation takes the link's place rather than adding a row
300 // of its own, so saving never resizes the popover.
301 if viewModel.savedConfirmation {
302 Text("✓ Saved")
303 .font(Self.captionFont)
304 .foregroundColor(.green)
305 .transition(.opacity)
306 } else {
307 Link("Get key →", destination: Self.websiteURL)
308 .font(Self.captionFont)
309 .transition(.opacity)
310 }
311 }
313 HStack(spacing: 8) {
314 SecureField("sk-…", text: $viewModel.apiKeyInput)
315 .textFieldStyle(.roundedBorder)
317 Button("Save") {
318 viewModel.saveAPIKey()
319 }
320 }
321 .controlSize(.small)
322 }
324 HStack {
325 Text("Refresh every")
327 Spacer(minLength: 0)
329 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
330 ForEach(RefreshInterval.options, id: \.self) { minutes in
331 Text("\(minutes)m").tag(minutes)
332 }
333 }
334 .pickerStyle(.menu)
335 .labelsHidden()
336 .fixedSize()
337 .controlSize(.small)
338 }
340 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
341 .toggleStyle(.switch)
342 .controlSize(.small)
343 }
344 }
346 // MARK: - Footer
348 private var footer: some View {
349 VStack(spacing: 14) {
350 HStack {
351 Button(action: { viewModel.refresh() }) {
352 Label("Refresh", systemImage: "arrow.clockwise")
353 }
354 .disabled(viewModel.isLoading)
356 Spacer(minLength: 12)
358 Button("Quit") {
359 NSApplication.shared.terminate(nil)
360 }
361 .keyboardShortcut("q")
362 }
363 .controlSize(.small)
365 Link("hyper.charm.land", destination: Self.websiteURL)
366 .font(Self.footnoteFont)
367 .foregroundColor(.secondary)
368 .opacity(0.7)
369 .frame(maxWidth: .infinity, alignment: .center)
370 }
371 }
374// MARK: - Loading
376/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
377/// the menu bar title rather than a generic spinner, so the two read as the same app.
378private struct PulsingBolt: View {
379 @State private var isPulsing = false
381 var body: some View {
382 VStack(spacing: 6) {
383 Text("⚡")
384 .font(.system(size: 34))
385 .opacity(isPulsing ? 0.35 : 1)
386 .scaleEffect(isPulsing ? 0.94 : 1)
387 .animation(
388 .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
389 value: isPulsing
390 )
391 // Roughly the height of the balance it stands in for, so the hero
392 // doesn't jump when the first fetch lands.
393 .frame(height: 56)
395 Text("Loading…")
396 .font(.system(size: 11, weight: .medium, design: .rounded))
397 .foregroundColor(.secondary)
398 }
399 .onAppear { isPulsing = true }
400 }
403// MARK: - Usage Bar
405/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
406/// rule under the label rather than as a control.
407private struct UsageBar: View {
408 /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
409 let percent: Int
410 let color: Color
412 private let height: CGFloat = 3
414 var body: some View {
415 GeometryReader { geometry in
416 let fraction = min(max(Double(percent) / 100, 0), 1)
418 ZStack(alignment: .leading) {
419 Capsule()
420 .fill(Color.secondary.opacity(0.15))
422 Capsule()
423 .fill(color)
424 .frame(width: geometry.size.width * fraction)
425 }
426 }
427 .frame(height: height)
428 }
431// MARK: - Sparkline
433/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
434/// takes the balance's color so the trend reads as part of the number above it, with a
435/// faint fade underneath for weight and a dot marking the latest point.
436///
437/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
438/// readable when fetches are irregular (wake-ups, manual refreshes).
439struct Sparkline: View {
440 let values: [Int]
441 var color: Color = .secondary
443 /// Keeps the stroke and the dot from being clipped at the edges.
444 private let verticalInset: CGFloat = 3
445 private let dotRadius: CGFloat = 2.5
447 var body: some View {
448 GeometryReader { geometry in
449 let points = points(in: geometry.size)
451 if points.count >= 2 {
452 ZStack {
453 fill(under: points, height: geometry.size.height)
454 .fill(
455 LinearGradient(
456 colors: [color.opacity(0.18), color.opacity(0)],
457 startPoint: .top,
458 endPoint: .bottom
459 )
460 )
462 line(through: points)
463 .stroke(
464 color.opacity(0.9),
465 style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
466 )
468 if let last = points.last {
469 Circle()
470 .fill(color)
471 .frame(width: dotRadius * 2, height: dotRadius * 2)
472 .position(last)
473 }
474 }
475 }
476 }
477 }
479 /// Normalizes the values into the available space, oldest at the left.
480 private func points(in size: CGSize) -> [CGPoint] {
481 guard values.count >= 2 else { return [] }
483 let minValue = values.min() ?? 0
484 let maxValue = values.max() ?? 0
485 let range = Double(maxValue - minValue)
487 // The dot is centered on the last point, so the line stops a radius short of
488 // the trailing edge rather than letting the dot spill outside the frame.
489 let usableWidth = max(size.width - dotRadius, 1)
490 let usableHeight = max(size.height - verticalInset * 2, 1)
491 let stepX = usableWidth / CGFloat(values.count - 1)
493 return values.enumerated().map { index, value in
494 // A flat series has no range to normalize against; center it.
495 let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
496 return CGPoint(
497 x: CGFloat(index) * stepX,
498 y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
499 )
500 }
501 }
503 private func line(through points: [CGPoint]) -> Path {
504 Path { path in
505 guard let first = points.first else { return }
506 path.move(to: first)
507 for point in points.dropFirst() {
508 path.addLine(to: point)
509 }
510 }
511 }
513 /// The line closed down to the baseline, so it can be filled.
514 private func fill(under points: [CGPoint], height: CGFloat) -> Path {
515 Path { path in
516 guard let first = points.first, let last = points.last else { return }
517 path.move(to: CGPoint(x: first.x, y: height))
518 path.addLine(to: first)
519 for point in points.dropFirst() {
520 path.addLine(to: point)
521 }
522 path.addLine(to: CGPoint(x: last.x, y: height))
523 path.closeSubpath()
524 }
525 }