on 2026.07.17.1348 help sign in
usage-menubar/UsageMenubar/MenuView.swift
690 lines 26 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 private static let websiteURL = URL(string: "https://hyper.charm.land")!
8 private static let zaiKeyURL = URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")!
10 // MARK: - Type Scale
12 /// One rounded family, sized so that every provider's headline number reads at the
13 /// same weight. With three providers in the popover, the 26pt subhero is the right
14 /// scale — a 46pt hero would make the popover too tall. Monospaced digits keep
15 /// numbers from jittering as their digits change.
16 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
17 .monospacedDigit()
18 private static let sectionFont = Font.system(size: 12, weight: .semibold, design: .rounded)
19 private static let controlFont = Font.system(size: 12, weight: .regular, design: .rounded)
20 private static let captionFont = Font.system(size: 11, weight: .medium, design: .rounded)
21 private static let footnoteFont = Font.system(size: 10, weight: .regular, design: .rounded)
23 var body: some View {
24 VStack(alignment: .leading, spacing: 18) {
25 if viewModel.hyperConfigured {
26 hyper
27 }
28 if showsClaude {
29 serviceBreak
30 claude
31 }
32 if showsZai {
33 serviceBreak
34 zai
35 }
36 hairline
37 settings
38 hairline
39 footer
40 }
41 .padding(.horizontal, 18)
42 .padding(.top, 18)
43 .padding(.bottom, 14)
44 .frame(width: 280)
45 // Set as the environment font so the controls inherit the rounded design too.
46 .font(Self.controlFont)
47 .background(Color(nsColor: .windowBackgroundColor))
48 .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
49 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedConfirmation)
50 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedClaudeConfirmation)
51 .animation(.easeInOut(duration: 0.2), value: viewModel.copiedZaiConfirmation)
52 .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
53 .animation(.easeInOut(duration: 0.25), value: viewModel.balance)
54 .animation(.easeInOut(duration: 0.25), value: viewModel.isLoading)
55 .animation(.easeInOut(duration: 0.3), value: viewModel.balanceSparklineValues.count)
56 .animation(.easeInOut(duration: 0.3), value: viewModel.claudeSparklineValues.count)
57 .animation(.easeInOut(duration: 0.3), value: viewModel.zaiSparklineValues.count)
58 .animation(.easeInOut(duration: 0.25), value: viewModel.claudeUsage)
59 .animation(.easeInOut(duration: 0.2), value: viewModel.claudeError)
60 .animation(.easeInOut(duration: 0.25), value: viewModel.zaiUsage)
61 .animation(.easeInOut(duration: 0.2), value: viewModel.zaiError)
62 }
64 /// A section rule light enough to read as a pause rather than a border.
65 private var hairline: some View {
66 Divider().opacity(0.3)
67 }
69 /// The rule between services. Wider than the others on purpose: the providers are
70 /// unrelated accounts, and the gap should say so before the headers do.
71 private var serviceBreak: some View {
72 hairline.padding(.vertical, 5)
73 }
75 /// A section header: an icon and a title, sized to match the "API Key" header below
76 /// so the popover reads as three equal sections rather than a hero with appendages.
77 private func sectionHeader(_ title: String, systemImage: String) -> some View {
78 HStack(spacing: 5) {
79 Image(systemName: systemImage)
80 .font(.system(size: 11, weight: .semibold))
82 Text(title)
83 .font(Self.sectionFont)
84 }
85 }
87 /// A reusable headline number row: the provider's main metric at subhero scale,
88 /// with emoji, color, trend arrow, and click-to-copy. The `caption` doubles as the
89 /// copy-confirmation slot so the layout doesn't shift.
90 private func headlineNumber(
91 text: String,
92 color: Color,
93 trend: MetricTrend,
94 copiedConfirmation: Bool,
95 captionText: String,
96 copyAction: @escaping () -> Void
97 ) -> some View {
98 VStack(spacing: 6) {
99 Button(action: copyAction) {
100 HStack(alignment: .firstTextBaseline, spacing: 6) {
101 Text(text)
102 .font(Self.subheroFont)
103 .foregroundColor(color)
104 .contentTransition(.opacity)
106 if let symbol = trend.symbolName {
107 Image(systemName: symbol)
108 .font(.system(size: 12, weight: .semibold))
109 .foregroundColor(.secondary)
110 .transition(.opacity)
111 }
112 }
113 .contentShape(Rectangle())
114 }
115 .buttonStyle(.plain)
116 .help("Click to copy")
118 // Caption doubles as copy confirmation so the layout doesn't shift.
119 Group {
120 if copiedConfirmation {
121 Text("✓ Copied")
122 .foregroundColor(.green)
123 } else {
124 Text(captionText)
125 .foregroundColor(.secondary)
126 }
127 }
128 .font(Self.captionFont)
129 }
130 }
132 /// A sparkline shown when there are at least 2 history points.
133 @ViewBuilder
134 private func sparkline(values: [Int], color: Color) -> some View {
135 if values.count >= 2 {
136 Sparkline(values: values, color: color)
137 .frame(height: 26)
138 .padding(.top, 4)
139 .transition(.opacity)
140 }
141 }
143 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
144 /// message, and a tinted rounded background with no border.
145 private func errorRow(_ message: String) -> some View {
146 HStack(alignment: .top, spacing: 6) {
147 Image(systemName: "exclamationmark.triangle.fill")
148 .font(.system(size: 10, weight: .semibold))
150 Text(message)
151 .font(Self.captionFont)
152 // Long messages wrap instead of being truncated to one line.
153 .fixedSize(horizontal: false, vertical: true)
155 Spacer(minLength: 0)
156 }
157 .foregroundColor(.red)
158 .padding(.horizontal, 10)
159 .padding(.vertical, 7)
160 .background(
161 RoundedRectangle(cornerRadius: 8, style: .continuous)
162 .fill(Color.red.opacity(0.1))
163 )
164 .padding(.top, 2)
165 }
167 // MARK: - Hyper
169 /// Whether there is anything to say about Hyper. Nothing at all when Hyper isn't
170 /// configured — the section doesn't appear, same as Claude and z.ai.
171 private var showsHyper: Bool {
172 viewModel.hyperConfigured
173 }
175 /// The Hyper section: header, balance as a subhero number, caption, sparkline.
176 private var hyper: some View {
177 VStack(alignment: .leading, spacing: 12) {
178 sectionHeader("Hyper Credits", systemImage: "bolt.fill")
180 if viewModel.isLoading && viewModel.balance == nil {
181 Text("…")
182 .font(Self.subheroFont)
183 .foregroundColor(.secondary)
184 } else if let balance = viewModel.balance {
185 VStack(spacing: 8) {
186 headlineNumber(
187 text: "⚡ \(viewModel.formattedBalance)",
188 color: viewModel.balanceColor,
189 trend: viewModel.balanceTrend,
190 copiedConfirmation: viewModel.copiedConfirmation,
191 captionText: "credits",
192 copyAction: { viewModel.copyBalanceToClipboard() }
193 )
195 sparkline(
196 values: viewModel.balanceSparklineValues,
197 color: viewModel.balanceColor
198 )
199 }
200 .transition(.opacity)
201 }
203 if let lastUpdatedText = viewModel.lastUpdatedText {
204 Text(lastUpdatedText)
205 .font(Self.footnoteFont)
206 .foregroundColor(.secondary)
207 .transition(.opacity)
208 }
210 if let error = viewModel.errorMessage {
211 errorRow(error)
212 .transition(.opacity)
213 }
214 }
215 }
217 // MARK: - Claude
219 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
220 /// isn't signed in on this machine — the section doesn't appear, and no error is
221 /// shown for a service the user may simply not use.
222 private var showsClaude: Bool {
223 if viewModel.claudeError != nil { return true }
224 if let usage = viewModel.claudeUsage { return !usage.isEmpty }
225 return false
226 }
228 /// Claude Code's plan limits. Same visual structure as Hyper and z.ai: a subhero
229 /// headline number, bars under every window, a sparkline, and error row.
230 private var claude: some View {
231 VStack(alignment: .leading, spacing: 12) {
232 HStack(spacing: 8) {
233 sectionHeader("Claude Code", systemImage: "sparkles")
235 Spacer(minLength: 0)
237 if let plan = viewModel.claudePlanLabel {
238 Text(plan)
239 .font(Self.captionFont)
240 .foregroundColor(.secondary)
241 }
242 }
244 if let usage = viewModel.claudeUsage, !usage.isEmpty {
245 usageDetail(usage)
246 }
248 if let error = viewModel.claudeError {
249 errorRow(error)
250 }
251 }
252 }
254 private func usageDetail(_ usage: ClaudeUsage) -> some View {
255 VStack(alignment: .leading, spacing: 12) {
256 // The 5-hour window leads as the headline number.
257 if let fiveHour = usage.fiveHour {
258 let percent = Int(fiveHour.utilization.rounded())
259 VStack(spacing: 8) {
260 HStack(alignment: .firstTextBaseline, spacing: 8) {
261 headlineNumber(
262 text: "🕐 \(percent)%",
263 color: Self.usageColor(percent),
264 trend: viewModel.claudeTrend,
265 copiedConfirmation: viewModel.copiedClaudeConfirmation,
266 captionText: "5-hour usage",
267 copyAction: { viewModel.copyClaudePercentToClipboard() }
268 )
270 Spacer(minLength: 0)
272 if let resets = fiveHour.resetsInFormatted {
273 Text("resets in \(resets)")
274 .font(Self.footnoteFont)
275 .foregroundColor(.secondary)
276 }
277 }
279 // Bar under the 5-hour window too, same as 7-day windows below.
280 UsageBar(percent: percent, color: Self.usageColor(percent))
281 }
282 }
284 VStack(alignment: .leading, spacing: 9) {
285 if let sevenDay = usage.sevenDay {
286 window("7-day", sevenDay)
287 }
288 // Opus has its own weekly cap on Max plans, but it is only worth a row
289 // once it has actually been used.
290 if let opus = usage.sevenDayOpus, opus.utilization > 0 {
291 window("7-day Opus", opus)
292 }
293 }
295 // Sparkline for the Claude 5-hour % history.
296 sparkline(
297 values: viewModel.claudeSparklineValues,
298 color: Self.usageColor(claudeFiveHourPercent ?? 0)
299 )
300 }
301 }
303 /// One window: its name, its share of the allowance, and a hairline bar. The bar is
304 /// the only chrome here, and it carries the same color as the number beside it.
305 private func window(_ label: String, _ window: UsageWindow) -> some View {
306 let percent = Int(window.utilization.rounded())
308 return VStack(alignment: .leading, spacing: 5) {
309 HStack(spacing: 6) {
310 Text(label)
311 .font(Self.captionFont)
312 .foregroundColor(.secondary)
314 Spacer(minLength: 0)
316 Text("\(percent)%")
317 .font(Self.captionFont.monospacedDigit())
318 .foregroundColor(Self.usageColor(percent))
319 }
321 UsageBar(percent: percent, color: Self.usageColor(percent))
322 }
323 }
325 /// The current Claude 5-hour percentage, or 0 if absent. Used for sparkline color.
326 private var claudeFiveHourPercent: Int? {
327 viewModel.claudeFiveHourPercent
328 }
330 /// The balance colors read the other way round here, because for a limit more is
331 /// worse: green with room to spare, yellow closing in, red nearly spent.
332 private static func usageColor(_ percent: Int) -> Color {
333 if percent >= 90 { return .red }
334 if percent >= 70 { return .yellow }
335 return .green
336 }
338 // MARK: - z.ai
340 /// Whether there is anything to say about z.ai. Nothing at all when no API key has
341 /// been entered — the section doesn't appear, and no error is shown for a service
342 /// the user may simply not use.
343 private var showsZai: Bool {
344 if viewModel.zaiError != nil { return true }
345 if let usage = viewModel.zaiUsage { return !usage.isEmpty }
346 return false
347 }
349 /// z.ai Coding Plan's quota limits. Same visual structure as Hyper and Claude:
350 /// a subhero headline number, bars under every window, a sparkline, and error row.
351 private var zai: some View {
352 VStack(alignment: .leading, spacing: 12) {
353 HStack(spacing: 8) {
354 sectionHeader("z.ai Coding", systemImage: "bolt.horizontal")
356 Spacer(minLength: 0)
358 if let plan = viewModel.zaiPlanLabel {
359 Text(plan)
360 .font(Self.captionFont)
361 .foregroundColor(.secondary)
362 }
363 }
365 if let usage = viewModel.zaiUsage, !usage.isEmpty {
366 zaiUsageDetail(usage)
367 }
369 if let error = viewModel.zaiError {
370 errorRow(error)
371 }
372 }
373 }
375 private func zaiUsageDetail(_ usage: ZaiUsageReport) -> some View {
376 VStack(alignment: .leading, spacing: 12) {
377 // The 5-hour window leads as the headline number.
378 if let fiveHour = usage.fiveHourPercent {
379 VStack(spacing: 8) {
380 HStack(alignment: .firstTextBaseline, spacing: 8) {
381 headlineNumber(
382 text: "🤖 \(fiveHour)%",
383 color: Self.usageColor(fiveHour),
384 trend: viewModel.zaiTrend,
385 copiedConfirmation: viewModel.copiedZaiConfirmation,
386 captionText: "5-hour usage",
387 copyAction: { viewModel.copyZaiPercentToClipboard() }
388 )
390 Spacer(minLength: 0)
392 if let resets = usage.fiveHourResetsAt {
393 Text("resets in \(Self.zaiResetsIn(from: resets))")
394 .font(Self.footnoteFont)
395 .foregroundColor(.secondary)
396 }
397 }
399 // Bar under the 5-hour window too.
400 UsageBar(percent: fiveHour, color: Self.usageColor(fiveHour))
401 }
402 }
404 VStack(alignment: .leading, spacing: 9) {
405 if let weekly = usage.weeklyPercent {
406 zaiWindow("Weekly", percent: weekly, resetsAt: usage.weeklyResetsAt)
407 }
408 }
410 // Sparkline for the z.ai 5-hour % history.
411 sparkline(
412 values: viewModel.zaiSparklineValues,
413 color: Self.usageColor(zaiFiveHourPercent ?? 0)
414 )
415 }
416 }
418 /// The current z.ai 5-hour percentage, or 0 if absent. Used for sparkline color.
419 private var zaiFiveHourPercent: Int? {
420 viewModel.zaiFiveHourPercent
421 }
423 /// One window row for z.ai: label, percentage, and a hairline bar.
424 private func zaiWindow(_ label: String, percent: Int, resetsAt: Date?) -> some View {
425 VStack(alignment: .leading, spacing: 5) {
426 HStack(spacing: 6) {
427 Text(label)
428 .font(Self.captionFont)
429 .foregroundColor(.secondary)
431 Spacer(minLength: 0)
433 Text("\(percent)%")
434 .font(Self.captionFont.monospacedDigit())
435 .foregroundColor(Self.usageColor(percent))
436 }
438 UsageBar(percent: percent, color: Self.usageColor(percent))
439 }
440 }
442 /// Formats the time until a z.ai window resets, e.g. "3h 20m".
443 private static func zaiResetsIn(from date: Date) -> String {
444 let interval = date.timeIntervalSince(Date())
445 guard interval > 0 else { return "soon" }
447 let seconds = Int(interval.rounded())
448 let days = seconds / 86_400
449 let hours = (seconds % 86_400) / 3_600
450 let minutes = (seconds % 3_600) / 60
452 if days > 0 { return "\(days)d \(hours)h" }
453 if hours > 0 { return "\(hours)h \(minutes)m" }
454 return "\(minutes)m"
455 }
457 // MARK: - Settings
459 private var settings: some View {
460 VStack(alignment: .leading, spacing: 14) {
461 VStack(alignment: .leading, spacing: 8) {
462 HStack(spacing: 8) {
463 sectionHeader("Hyper API Key", systemImage: "bolt.fill")
465 Spacer(minLength: 0)
467 // The confirmation takes the link's place rather than adding a row
468 // of its own, so saving never resizes the popover.
469 if viewModel.savedConfirmation {
470 Text("✓ Saved")
471 .font(Self.captionFont)
472 .foregroundColor(.green)
473 .transition(.opacity)
474 } else {
475 Link("Get key →", destination: Self.websiteURL)
476 .font(Self.captionFont)
477 .transition(.opacity)
478 }
479 }
481 HStack(spacing: 8) {
482 SecureField("sk-…", text: $viewModel.apiKeyInput)
483 .textFieldStyle(.roundedBorder)
485 Button("Save") {
486 viewModel.saveAPIKey()
487 }
488 }
489 .controlSize(.small)
490 }
492 VStack(alignment: .leading, spacing: 8) {
493 HStack(spacing: 8) {
494 sectionHeader("z.ai API Key", systemImage: "bolt.horizontal")
496 Spacer(minLength: 0)
498 // The confirmation takes the link's place rather than adding a row
499 // of its own, so saving never resizes the popover.
500 if viewModel.savedConfirmation {
501 Text("✓ Saved")
502 .font(Self.captionFont)
503 .foregroundColor(.green)
504 .transition(.opacity)
505 } else {
506 Link("Get key →", destination: Self.zaiKeyURL)
507 .font(Self.captionFont)
508 .transition(.opacity)
509 }
510 }
512 HStack(spacing: 8) {
513 SecureField("your z.ai key", text: $viewModel.zaiAPIKeyInput)
514 .textFieldStyle(.roundedBorder)
516 Button("Save") {
517 viewModel.saveZaiAPIKey()
518 }
519 }
520 .controlSize(.small)
521 }
523 HStack {
524 Text("Refresh every")
526 Spacer(minLength: 0)
528 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
529 ForEach(RefreshInterval.options, id: \.self) { minutes in
530 Text("\(minutes)m").tag(minutes)
531 }
532 }
533 .pickerStyle(.menu)
534 .labelsHidden()
535 .fixedSize()
536 .controlSize(.small)
537 }
539 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
540 .toggleStyle(.switch)
541 .controlSize(.small)
542 }
543 }
545 // MARK: - Footer
547 private var footer: some View {
548 VStack(spacing: 14) {
549 HStack {
550 Button(action: { viewModel.refresh() }) {
551 Label("Refresh", systemImage: "arrow.clockwise")
552 }
553 .disabled(viewModel.isLoading)
555 Spacer(minLength: 12)
557 Button("Quit") {
558 NSApplication.shared.terminate(nil)
559 }
560 .keyboardShortcut("q")
561 }
562 .controlSize(.small)
563 }
564 }
567// MARK: - Usage Bar
569/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
570/// rule under the label rather than as a control.
571private struct UsageBar: View {
572 /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
573 let percent: Int
574 let color: Color
576 private let height: CGFloat = 3
578 var body: some View {
579 GeometryReader { geometry in
580 let fraction = min(max(Double(percent) / 100, 0), 1)
582 ZStack(alignment: .leading) {
583 Capsule()
584 .fill(Color.secondary.opacity(0.15))
586 Capsule()
587 .fill(color)
588 .frame(width: geometry.size.width * fraction)
589 }
590 }
591 .frame(height: height)
592 }
595// MARK: - Sparkline
597/// A minimal line chart of recent values: no axes, no labels, no grid. The stroke
598/// takes the metric's color so the trend reads as part of the number above it, with a
599/// faint fade underneath for weight and a dot marking the latest point.
600///
601/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
602/// readable when fetches are irregular (wake-ups, manual refreshes).
603struct Sparkline: View {
604 let values: [Int]
605 var color: Color = .secondary
607 /// Keeps the stroke and the dot from being clipped at the edges.
608 private let verticalInset: CGFloat = 3
609 private let dotRadius: CGFloat = 2.5
611 var body: some View {
612 GeometryReader { geometry in
613 let points = points(in: geometry.size)
615 if points.count >= 2 {
616 ZStack {
617 fill(under: points, height: geometry.size.height)
618 .fill(
619 LinearGradient(
620 colors: [color.opacity(0.18), color.opacity(0)],
621 startPoint: .top,
622 endPoint: .bottom
623 )
624 )
626 line(through: points)
627 .stroke(
628 color.opacity(0.9),
629 style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
630 )
632 if let last = points.last {
633 Circle()
634 .fill(color)
635 .frame(width: dotRadius * 2, height: dotRadius * 2)
636 .position(last)
637 }
638 }
639 }
640 }
641 }
643 /// Normalizes the values into the available space, oldest at the left.
644 private func points(in size: CGSize) -> [CGPoint] {
645 guard values.count >= 2 else { return [] }
647 let minValue = values.min() ?? 0
648 let maxValue = values.max() ?? 0
649 let range = Double(maxValue - minValue)
651 // The dot is centered on the last point, so the line stops a radius short of
652 // the trailing edge rather than letting the dot spill outside the frame.
653 let usableWidth = max(size.width - dotRadius, 1)
654 let usableHeight = max(size.height - verticalInset * 2, 1)
655 let stepX = usableWidth / CGFloat(values.count - 1)
657 return values.enumerated().map { index, value in
658 // A flat series has no range to normalize against; center it.
659 let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
660 return CGPoint(
661 x: CGFloat(index) * stepX,
662 y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
663 )
664 }
665 }
667 private func line(through points: [CGPoint]) -> Path {
668 Path { path in
669 guard let first = points.first else { return }
670 path.move(to: first)
671 for point in points.dropFirst() {
672 path.addLine(to: point)
673 }
674 }
675 }
677 /// The line closed down to the baseline, so it can be filled.
678 private func fill(under points: [CGPoint], height: CGFloat) -> Path {
679 Path { path in
680 guard let first = points.first, let last = points.last else { return }
681 path.move(to: CGPoint(x: first.x, y: height))
682 path.addLine(to: first)
683 for point in points.dropFirst() {
684 path.addLine(to: point)
685 }
686 path.addLine(to: CGPoint(x: last.x, y: height))
687 path.closeSubpath()
688 }
689 }