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 {
69 } else if viewModel.balance == nil {
72 .font(Self.sectionFont)
73 .foregroundColor(.secondary)
77 .foregroundColor(.secondary)
79 Text("Enter your API key below")
80 .font(Self.captionFont)
81 .foregroundColor(.secondary)
87 .font(Self.sectionFont)
88 .foregroundColor(.secondary)
96 if let lastUpdatedText = viewModel.lastUpdatedText {
98 .font(Self.footnoteFont)
99 .foregroundColor(.secondary)
100 .transition(.opacity)
103 if let error = viewModel.errorMessage {
105 .transition(.opacity)
108 .frame(maxWidth: .infinity)
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)
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)
127 // Without this the hover area is only the glyphs themselves.
128 .contentShape(Rectangle())
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)
138 /// Doubles as the copy confirmation so the layout doesn't shift.
139 private var caption: some View {
141 if viewModel.copiedConfirmation {
143 .foregroundColor(.green)
146 .foregroundColor(.secondary)
149 .font(Self.captionFont)
153 private var sparkline: some View {
154 if historyValues.count >= 2 {
155 Sparkline(values: historyValues, color: viewModel.balanceColor)
158 .transition(.opacity)
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 }
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))
175 .font(Self.captionFont)
176 // Long messages wrap instead of being truncated to one line.
177 .fixedSize(horizontal: false, vertical: true)
181 .foregroundColor(.red)
182 .padding(.horizontal, 10)
183 .padding(.vertical, 7)
185 RoundedRectangle(cornerRadius: 8, style: .continuous)
186 .fill(Color.red.opacity(0.1))
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 }
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) {
208 .font(Self.sectionFont)
212 if let plan = viewModel.claudePlanLabel {
214 .font(Self.captionFont)
215 .foregroundColor(.secondary)
219 if let usage = viewModel.claudeUsage, !usage.isEmpty {
223 if let error = viewModel.claudeError {
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)
240 if let resets = headline.resetsIn {
241 Text("resets in \(resets)")
242 .font(Self.footnoteFont)
243 .foregroundColor(.secondary)
248 VStack(alignment: .leading, spacing: 9) {
249 if let fiveHour = usage.fiveHour {
250 window("5-hour", fiveHour)
252 if let sevenDay = usage.sevenDay {
253 window("7-day", sevenDay)
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)
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) {
272 .font(Self.captionFont)
273 .foregroundColor(.secondary)
278 .font(Self.captionFont.monospacedDigit())
279 .foregroundColor(Self.usageColor(percent))
282 UsageBar(percent: percent, color: Self.usageColor(percent))
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)
292 if let fiveHour = usage.fiveHour {
293 return (Int(fiveHour.utilization.rounded()), fiveHour.resetsInFormatted)
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 }
308 private var settings: some View {
309 VStack(alignment: .leading, spacing: 14) {
310 VStack(alignment: .leading, spacing: 8) {
313 .font(Self.sectionFont)
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 {
321 .font(Self.captionFont)
322 .foregroundColor(.green)
323 .transition(.opacity)
325 Link("Get key →", destination: Self.websiteURL)
326 .font(Self.captionFont)
327 .transition(.opacity)
332 SecureField("sk-…", text: $viewModel.apiKeyInput)
333 .textFieldStyle(.roundedBorder)
336 viewModel.saveAPIKey()
343 Text("Refresh every")
347 Picker("Refresh every", selection: $viewModel.refreshIntervalMinutes) {
348 ForEach(RefreshInterval.options, id: \.self) { minutes in
349 Text("\(minutes)m").tag(minutes)
358 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
359 .toggleStyle(.switch)
366 private var footer: some View {
367 VStack(spacing: 14) {
369 Button(action: { viewModel.refresh() }) {
370 Label("Refresh", systemImage: "arrow.clockwise")
372 .disabled(viewModel.isLoading)
374 Spacer(minLength: 12)
377 NSApplication.shared.terminate(nil)
379 .keyboardShortcut("q")
383 Link("hyper.charm.land", destination: Self.websiteURL)
384 .font(Self.footnoteFont)
385 .foregroundColor(.secondary)
387 .frame(maxWidth: .infinity, alignment: .center)
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 {
402 .font(.system(size: 34))
403 .opacity(isPulsing ? 0.35 : 1)
404 .scaleEffect(isPulsing ? 0.94 : 1)
406 .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
409 // Roughly the height of the balance it stands in for, so the hero
410 // doesn't jump when the first fetch lands.
414 .font(.system(size: 11, weight: .medium, design: .rounded))
415 .foregroundColor(.secondary)
417 .onAppear { isPulsing = true }
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.
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) {
438 .fill(Color.secondary.opacity(0.15))
442 .frame(width: geometry.size.width * fraction)
445 .frame(height: height)
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.
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 {
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 {
471 fill(under: points, height: geometry.size.height)
474 colors: [color.opacity(0.18), color.opacity(0)],
480 line(through: points)
483 style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
486 if let last = points.last {
489 .frame(width: dotRadius * 2, height: dotRadius * 2)
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
515 x: CGFloat(index) * stepX,
516 y: verticalInset + (1 - CGFloat(normalized)) * usableHeight
521 private func line(through points: [CGPoint]) -> Path {
523 guard let first = points.first else { return }
525 for point in points.dropFirst() {
526 path.addLine(to: point)
531 /// The line closed down to the baseline, so it can be filled.
532 private func fill(under points: [CGPoint], height: CGFloat) -> Path {
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)
540 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