3/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
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")!
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)
21 private static let subheroFont = Font.system(size: 26, weight: .semibold, design: .rounded)
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)
29 VStack(alignment: .leading, spacing: 18) {
40 .padding(.horizontal, 18)
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)
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)
64 private var hero: some View {
66 if viewModel.isLoading && viewModel.balance == nil {
78 if let lastUpdatedText = viewModel.lastUpdatedText {
80 .font(Self.footnoteFont)
81 .foregroundColor(.secondary)
85 if let error = viewModel.errorMessage {
90 .frame(maxWidth: .infinity)
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)
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)
109 // Without this the hover area is only the glyphs themselves.
110 .contentShape(Rectangle())
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)
120 /// Doubles as the copy confirmation so the layout doesn't shift.
121 private var caption: some View {
123 if viewModel.copiedConfirmation {
125 .foregroundColor(.green)
128 .foregroundColor(.secondary)
131 .font(Self.captionFont)
135 private var sparkline: some View {
136 if historyValues.count >= 2 {
137 Sparkline(values: historyValues, color: viewModel.balanceColor)
140 .transition(.opacity)
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 }
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))
157 .font(Self.captionFont)
158 // Long messages wrap instead of being truncated to one line.
159 .fixedSize(horizontal: false, vertical: true)
163 .foregroundColor(.red)
164 .padding(.horizontal, 10)
165 .padding(.vertical, 7)
167 RoundedRectangle(cornerRadius: 8, style: .continuous)
168 .fill(Color.red.opacity(0.1))
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 }
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) {
190 .font(Self.sectionFont)
194 if let plan = viewModel.claudePlanLabel {
196 .font(Self.captionFont)
197 .foregroundColor(.secondary)
201 if let usage = viewModel.claudeUsage, !usage.isEmpty {
205 if let error = viewModel.claudeError {
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)
222 if let resets = headline.resetsIn {
223 Text("resets in \(resets)")
224 .font(Self.footnoteFont)
225 .foregroundColor(.secondary)
230 VStack(alignment: .leading, spacing: 9) {
231 if let fiveHour = usage.fiveHour {
232 window("5-hour", fiveHour)
234 if let sevenDay = usage.sevenDay {
235 window("7-day", sevenDay)
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)
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) {
254 .font(Self.captionFont)
255 .foregroundColor(.secondary)
260 .font(Self.captionFont.monospacedDigit())
261 .foregroundColor(Self.usageColor(percent))
264 UsageBar(percent: percent, color: Self.usageColor(percent))
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)
274 if let fiveHour = usage.fiveHour {
275 return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
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 }
290 private var settings: some View {
291 VStack(alignment: .leading, spacing: 14) {
292 VStack(alignment: .leading, spacing: 8) {
295 .font(Self.sectionFont)
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 {
303 .font(Self.captionFont)
304 .foregroundColor(.green)
305 .transition(.opacity)
307 Link("Get key →", destination: Self.websiteURL)
308 .font(Self.captionFont)
309 .transition(.opacity)
314 SecureField("sk-…", text: $viewModel.apiKeyInput)
315 .textFieldStyle(.roundedBorder)
318 viewModel.saveAPIKey()
325 Text("Refresh every")
329 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
330 ForEach(RefreshInterval.options, id: \.self) { minutes in
331 Text("\(minutes)m").tag(minutes)
340 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
341 .toggleStyle(.switch)
348 private var footer: some View {
349 VStack(spacing: 14) {
351 Button(action: { viewModel.refresh() }) {
352 Label("Refresh", systemImage: "arrow.clockwise")
354 .disabled(viewModel.isLoading)
356 Spacer(minLength: 12)
359 NSApplication.shared.terminate(nil)
361 .keyboardShortcut("q")
365 Link("hyper.charm.land", destination: Self.websiteURL)
366 .font(Self.footnoteFont)
367 .foregroundColor(.secondary)
369 .frame(maxWidth: .infinity, alignment: .center)
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 {
384 .font(.system(size: 34))
385 .opacity(isPulsing ? 0.35 : 1)
386 .scaleEffect(isPulsing ? 0.94 : 1)
388 .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
391 // Roughly the height of the balance it stands in for, so the hero
392 // doesn't jump when the first fetch lands.
396 .font(.system(size: 11, weight: .medium, design: .rounded))
397 .foregroundColor(.secondary)
399 .onAppear { isPulsing = true }
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.
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) {
420 .fill(Color.secondary.opacity(0.15))
424 .frame(width: geometry.size.width * fraction)
427 .frame(height: height)
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.
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 {
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 {
453 fill(under: points, height: geometry.size.height)
456 colors: [color.opacity(0.18), color.opacity(0)],
462 line(through: points)
465 style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
468 if let last = points.last {
471 .frame(width: dotRadius * 2, height: dotRadius * 2)
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
497 x: CGFloat(index) * stepX,
498 y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
503 private func line(through points: [CGPoint]) -> Path {
505 guard let first = points.first else { return }
507 for point in points.dropFirst() {
508 path.addLine(to: point)
513 /// The line closed down to the baseline, so it can be filled.
514 private func fill(under points: [CGPoint], height: CGFloat) -> Path {
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)
522 path.addLine(to: CGPoint(x: last.x, y: height))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list