on 2026.07.11.1853 help sign in
usage-menubar/UsageMenubar/MenuView.swift
544 lines 20 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 if viewModel.balance == nil {
70 VStack(spacing: 6) {
71 Text("Hyper Credits")
72 .font(Self.sectionFont)
73 .foregroundColor(.secondary)
75 Text("⚡?")
76 .font(Self.heroFont)
77 .foregroundColor(.secondary)
79 Text("Enter your API key below")
80 .font(Self.captionFont)
81 .foregroundColor(.secondary)
82 }
83 .transition(.opacity)
84 } else {
85 VStack(spacing: 6) {
86 Text("Hyper Credits")
87 .font(Self.sectionFont)
88 .foregroundColor(.secondary)
89 balanceNumber
90 caption
91 sparkline
92 }
93 .transition(.opacity)
94 }
96 if let lastUpdatedText = viewModel.lastUpdatedText {
97 Text(lastUpdatedText)
98 .font(Self.footnoteFont)
99 .foregroundColor(.secondary)
100 .transition(.opacity)
101 }
103 if let error = viewModel.errorMessage {
104 errorRow(error)
105 .transition(.opacity)
106 }
107 }
108 .frame(maxWidth: .infinity)
109 }
111 /// The balance itself. Clicking it copies the raw number to the clipboard.
112 private var balanceNumber: some View {
113 Button(action: { viewModel.copyBalanceToClipboard() }) {
114 HStack(alignment: .firstTextBaseline, spacing: 6) {
115 Text(viewModel.formattedBalance)
116 .font(Self.heroFont)
117 .foregroundColor(viewModel.balanceColor)
118 .contentTransition(.opacity)
120 if let symbol = viewModel.balanceTrend.symbolName {
121 Image(systemName: symbol)
122 .font(.system(size: 12, weight: .semibold))
123 .foregroundColor(.secondary)
124 .transition(.opacity)
125 }
126 }
127 // Without this the hover area is only the glyphs themselves.
128 .contentShape(Rectangle())
129 }
130 .buttonStyle(.plain)
131 .disabled(viewModel.balance == nil)
132 .help("Click to copy")
133 .opacity(isHoveringBalance && viewModel.balance != nil ? 0.65 : 1)
134 .onHover { isHoveringBalance = $0 }
135 .animation(.easeInOut(duration: 0.12), value: isHoveringBalance)
136 }
138 /// Doubles as the copy confirmation so the layout doesn't shift.
139 private var caption: some View {
140 Group {
141 if viewModel.copiedConfirmation {
142 Text("✓ Copied")
143 .foregroundColor(.green)
144 } else {
145 Text("credits")
146 .foregroundColor(.secondary)
147 }
148 }
149 .font(Self.captionFont)
150 }
152 @ViewBuilder
153 private var sparkline: some View {
154 if historyValues.count >= 2 {
155 Sparkline(values: historyValues, color: viewModel.balanceColor)
156 .frame(height: 26)
157 .padding(.top, 4)
158 .transition(.opacity)
159 }
160 }
162 /// The sparkline's data. Key paths can't index tuple elements, so this stays a closure.
163 private var historyValues: [Int] {
164 viewModel.balanceHistory.map { $0.balance }
165 }
167 /// Errors read as a quiet inline notice rather than loose red text: an icon, the
168 /// message, and a tinted rounded background with no border.
169 private func errorRow(_ message: String) -> some View {
170 HStack(alignment: .top, spacing: 6) {
171 Image(systemName: "exclamationmark.triangle.fill")
172 .font(.system(size: 10, weight: .semibold))
174 Text(message)
175 .font(Self.captionFont)
176 // Long messages wrap instead of being truncated to one line.
177 .fixedSize(horizontal: false, vertical: true)
179 Spacer(minLength: 0)
180 }
181 .foregroundColor(.red)
182 .padding(.horizontal, 10)
183 .padding(.vertical, 7)
184 .background(
185 RoundedRectangle(cornerRadius: 8, style: .continuous)
186 .fill(Color.red.opacity(0.1))
187 )
188 .padding(.top, 2)
189 }
191 // MARK: - Claude
193 /// Whether there is anything to say about Claude. Nothing at all when Claude Code
194 /// isn't signed in on this machine — the section doesn't appear, and no error is
195 /// shown for a service the user may simply not use.
196 private var showsClaude: Bool {
197 if viewModel.claudeError != nil { return true }
198 if let usage = viewModel.claudeUsage { return !usage.isEmpty }
199 return false
200 }
202 /// Claude Code's plan limits. The percentage leads, as the balance does above it,
203 /// with the individual windows underneath as quiet supporting detail.
204 private var claude: some View {
205 VStack(alignment: .leading, spacing: 12) {
206 HStack(spacing: 8) {
207 Text("Claude Code")
208 .font(Self.sectionFont)
210 Spacer(minLength: 0)
212 if let plan = viewModel.claudePlanLabel {
213 Text(plan)
214 .font(Self.captionFont)
215 .foregroundColor(.secondary)
216 }
217 }
219 if let usage = viewModel.claudeUsage, !usage.isEmpty {
220 usageDetail(usage)
221 }
223 if let error = viewModel.claudeError {
224 errorRow(error)
225 }
226 }
227 }
229 private func usageDetail(_ usage: ClaudeUsage) -> some View {
230 VStack(alignment: .leading, spacing: 12) {
231 if let headline = Self.headline(for: usage) {
232 HStack(alignment: .firstTextBaseline, spacing: 8) {
233 Text("\(headline.percent)%")
234 .font(Self.subheroFont)
235 .foregroundColor(Self.usageColor(headline.percent))
236 .contentTransition(.opacity)
238 Spacer(minLength: 0)
240 if let resets = headline.resetsIn {
241 Text("resets in \(resets)")
242 .font(Self.footnoteFont)
243 .foregroundColor(.secondary)
244 }
245 }
246 }
248 VStack(alignment: .leading, spacing: 9) {
249 if let fiveHour = usage.fiveHour {
250 window("5-hour", fiveHour)
251 }
252 if let sevenDay = usage.sevenDay {
253 window("7-day", sevenDay)
254 }
255 // Opus has its own weekly cap on Max plans, but it is only worth a row
256 // once it has actually been used.
257 if let opus = usage.sevenDayOpus, opus.utilization > 0 {
258 window("7-day Opus", opus)
259 }
260 }
261 }
262 }
264 /// One window: its name, its share of the allowance, and a hairline bar. The bar is
265 /// the only chrome here, and it carries the same color as the number beside it.
266 private func window(_ label: String, _ window: UsageWindow) -> some View {
267 let percent = Int(window.utilization.rounded())
269 return VStack(alignment: .leading, spacing: 5) {
270 HStack(spacing: 6) {
271 Text(label)
272 .font(Self.captionFont)
273 .foregroundColor(.secondary)
275 Spacer(minLength: 0)
277 Text("\(percent)%")
278 .font(Self.captionFont.monospacedDigit())
279 .foregroundColor(Self.usageColor(percent))
280 }
282 UsageBar(percent: percent, color: Self.usageColor(percent))
283 }
284 }
286 /// The number the section leads with: whichever limit the server flags as binding,
287 /// falling back to the 5-hour window when it flags none.
288 private static func headline(for usage: ClaudeUsage) -> (percent: Int, resetsIn: String?)? {
289 if let active = usage.activeLimit {
290 return (active.percent, active.resetsInFormatted)
291 }
292 if let fiveHour = usage.fiveHour {
293 return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
294 }
295 return nil
296 }
298 /// The balance colors read the other way round here, because for a limit more is
299 /// worse: green with room to spare, yellow closing in, red nearly spent.
300 private static func usageColor(_ percent: Int) -> Color {
301 if percent >= 90 { return .red }
302 if percent >= 70 { return .yellow }
303 return .green
304 }
306 // MARK: - Settings
308 private var settings: some View {
309 VStack(alignment: .leading, spacing: 14) {
310 VStack(alignment: .leading, spacing: 8) {
311 HStack(spacing: 8) {
312 Text("API Key")
313 .font(Self.sectionFont)
315 Spacer(minLength: 0)
317 // The confirmation takes the link's place rather than adding a row
318 // of its own, so saving never resizes the popover.
319 if viewModel.savedConfirmation {
320 Text("✓ Saved")
321 .font(Self.captionFont)
322 .foregroundColor(.green)
323 .transition(.opacity)
324 } else {
325 Link("Get key →", destination: Self.websiteURL)
326 .font(Self.captionFont)
327 .transition(.opacity)
328 }
329 }
331 HStack(spacing: 8) {
332 SecureField("sk-…", text: $viewModel.apiKeyInput)
333 .textFieldStyle(.roundedBorder)
335 Button("Save") {
336 viewModel.saveAPIKey()
337 }
338 }
339 .controlSize(.small)
340 }
342 HStack {
343 Text("Refresh every")
345 Spacer(minLength: 0)
347 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
348 ForEach(RefreshInterval.options, id: \.self) { minutes in
349 Text("\(minutes)m").tag(minutes)
350 }
351 }
352 .pickerStyle(.menu)
353 .labelsHidden()
354 .fixedSize()
355 .controlSize(.small)
356 }
358 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
359 .toggleStyle(.switch)
360 .controlSize(.small)
361 }
362 }
364 // MARK: - Footer
366 private var footer: some View {
367 VStack(spacing: 14) {
368 HStack {
369 Button(action: { viewModel.refresh() }) {
370 Label("Refresh", systemImage: "arrow.clockwise")
371 }
372 .disabled(viewModel.isLoading)
374 Spacer(minLength: 12)
376 Button("Quit") {
377 NSApplication.shared.terminate(nil)
378 }
379 .keyboardShortcut("q")
380 }
381 .controlSize(.small)
383 Link("hyper.charm.land", destination: Self.websiteURL)
384 .font(Self.footnoteFont)
385 .foregroundColor(.secondary)
386 .opacity(0.7)
387 .frame(maxWidth: .infinity, alignment: .center)
388 }
389 }
392// MARK: - Loading
394/// The loading state, before there has ever been a balance to show. Reuses the ⚡ from
395/// the menu bar title rather than a generic spinner, so the two read as the same app.
396private struct PulsingBolt: View {
397 @State private var isPulsing = false
399 var body: some View {
400 VStack(spacing: 6) {
401 Text("⚡")
402 .font(.system(size: 34))
403 .opacity(isPulsing ? 0.35 : 1)
404 .scaleEffect(isPulsing ? 0.94 : 1)
405 .animation(
406 .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
407 value: isPulsing
408 )
409 // Roughly the height of the balance it stands in for, so the hero
410 // doesn't jump when the first fetch lands.
411 .frame(height: 56)
413 Text("Loading…")
414 .font(.system(size: 11, weight: .medium, design: .rounded))
415 .foregroundColor(.secondary)
416 }
417 .onAppear { isPulsing = true }
418 }
421// MARK: - Usage Bar
423/// A hairline utilization bar: a track, a fill, and nothing else. Sized to read as a
424/// rule under the label rather than as a control.
425private struct UsageBar: View {
426 /// 0–100. Clamped, because a plan with extra usage enabled can report past 100.
427 let percent: Int
428 let color: Color
430 private let height: CGFloat = 3
432 var body: some View {
433 GeometryReader { geometry in
434 let fraction = min(max(Double(percent) / 100, 0), 1)
436 ZStack(alignment: .leading) {
437 Capsule()
438 .fill(Color.secondary.opacity(0.15))
440 Capsule()
441 .fill(color)
442 .frame(width: geometry.size.width * fraction)
443 }
444 }
445 .frame(height: height)
446 }
449// MARK: - Sparkline
451/// A minimal line chart of recent balances: no axes, no labels, no grid. The stroke
452/// takes the balance's color so the trend reads as part of the number above it, with a
453/// faint fade underneath for weight and a dot marking the latest point.
454///
455/// Points are spaced evenly by index rather than by timestamp, which keeps the shape
456/// readable when fetches are irregular (wake-ups, manual refreshes).
457struct Sparkline: View {
458 let values: [Int]
459 var color: Color = .secondary
461 /// Keeps the stroke and the dot from being clipped at the edges.
462 private let verticalInset: CGFloat = 3
463 private let dotRadius: CGFloat = 2.5
465 var body: some View {
466 GeometryReader { geometry in
467 let points = points(in: geometry.size)
469 if points.count >= 2 {
470 ZStack {
471 fill(under: points, height: geometry.size.height)
472 .fill(
473 LinearGradient(
474 colors: [color.opacity(0.18), color.opacity(0)],
475 startPoint: .top,
476 endPoint: .bottom
477 )
478 )
480 line(through: points)
481 .stroke(
482 color.opacity(0.9),
483 style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
484 )
486 if let last = points.last {
487 Circle()
488 .fill(color)
489 .frame(width: dotRadius * 2, height: dotRadius * 2)
490 .position(last)
491 }
492 }
493 }
494 }
495 }
497 /// Normalizes the values into the available space, oldest at the left.
498 private func points(in size: CGSize) -> [CGPoint] {
499 guard values.count >= 2 else { return [] }
501 let minValue = values.min() ?? 0
502 let maxValue = values.max() ?? 0
503 let range = Double(maxValue - minValue)
505 // The dot is centered on the last point, so the line stops a radius short of
506 // the trailing edge rather than letting the dot spill outside the frame.
507 let usableWidth = max(size.width - dotRadius, 1)
508 let usableHeight = max(size.height - verticalInset * 2, 1)
509 let stepX = usableWidth / CGFloat(values.count - 1)
511 return values.enumerated().map { index, value in
512 // A flat series has no range to normalize against; center it.
513 let normalized = range == 0 ? 0.5 : Double(value - minValue) / range
514 return CGPoint(
515 x: CGFloat(index) * stepX,
516 y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
517 )
518 }
519 }
521 private func line(through points: [CGPoint]) -> Path {
522 Path { path in
523 guard let first = points.first else { return }
524 path.move(to: first)
525 for point in points.dropFirst() {
526 path.addLine(to: point)
527 }
528 }
529 }
531 /// The line closed down to the baseline, so it can be filled.
532 private func fill(under points: [CGPoint], height: CGFloat) -> Path {
533 Path { path in
534 guard let first = points.first, let last = points.last else { return }
535 path.move(to: CGPoint(x: first.x, y: height))
536 path.addLine(to: first)
537 for point in points.dropFirst() {
538 path.addLine(to: point)
539 }
540 path.addLine(to: CGPoint(x: last.x, y: height))
541 path.closeSubpath()
542 }
543 }