Files
brov-macbook/NotchBuddy/Sources/App/AppGridView.swift
T

208 lines
8.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
// MARK: - BroV home: two floors of flat app pills
//
// Top floor: two empty slots, to be filled later.
// Bottom floor: the Claude and ChatGPT desktop apps (click = bring to front, or launch). The bot keeps its spot on the left.
struct DesktopAppSlot: Identifiable {
let id: String
let name: String
/// Tried in order: the first installed one wins.
let bundleIds: [String]
let color: String
let downloadURL: String
}
enum DesktopApps {
static let claude = DesktopAppSlot(id: "app_claude", name: "Claude",
bundleIds: ["com.anthropic.claudefordesktop"],
color: "#D97757", downloadURL: "https://claude.ai/download")
static let chatGPT = DesktopAppSlot(id: "app_chatgpt", name: "ChatGPT",
// New ChatGPT app first (its id is com.openai.codex), Classic as fallback.
bundleIds: ["com.openai.codex", "com.openai.chat"],
color: "#10A37F", downloadURL: "https://chatgpt.com/download")
}
struct AppGridView: View {
@ObservedObject var state: AppState
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
private var claudeLimit: PillLimit? {
guard state.planRelayInstalled, let usage = state.claudePlanUsage,
let pct = ClaudePlanGauge.dominantPct(usage) else { return nil }
var text = "\(Int(pct.rounded()))%"
if let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() {
text += " · сброс через \(PlanTime.untilReset(w.resetsAt))"
}
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
/// "23% · сброс сб 15:22" — ChatGPT weekly limit from the Codex logs.
private var chatGPTLimit: PillLimit? {
guard let w = state.codexWeekly else { return nil }
let pct = ClaudePlanGauge.effectivePct(w)
var text = "\(Int(pct.rounded()))%"
if w.resetsAt > Date() { text += " · сброс \(PlanTime.resetMoment(w.resetsAt))" }
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
var body: some View {
ZStack {
VStack(spacing: 8) {
HStack(spacing: 8) {
EmptyPill()
EmptyPill()
}
HStack(spacing: 8) {
AppPill(app: DesktopApps.claude, limit: claudeLimit)
AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit)
}
}
.padding(.leading, 104)
.padding(.trailing, 20)
.padding(.vertical, 6)
#if !APPSTORE
if state.showingPlanDetail {
CardBackground(wash: nil)
ClaudePlanCardView(usage: state.claudePlanUsage)
.transition(.opacity)
}
#endif
}
#if !APPSTORE
.onChange(of: state.mode) { _, m in
if m != .expanded { state.showingPlanDetail = false }
}
#endif
}
}
// MARK: - App pill
struct PillLimit {
let text: String
let color: String
}
struct AppPill: View {
let app: DesktopAppSlot
var limit: PillLimit? = nil
@State private var appURL: URL?
@State private var isRunning = false
@State private var isHovered = false
private var status: String {
if appURL == nil { return "не установлено" }
return isRunning ? "запущено" : "не запущено"
}
var body: some View {
Button(action: open) {
HStack(spacing: 10) {
icon
.frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(app.name)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
Spacer(minLength: 4)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D"))
}
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
.frame(width: 5, height: 5)
// With a limit shown, the green dot alone says "running".
if limit == nil || appURL == nil {
Text(status)
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#8E939C"))
.lineLimit(1)
}
if let limit, appURL != nil {
Text(limit.text)
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(Color(hex: limit.color))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
}
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(isHovered ? Color(hex: app.color).opacity(0.16) : Color(hex: "#0E0F11"))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.scaleEffect(isHovered ? 1.02 : 1)
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
.onAppear(perform: refresh)
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)) { _ in refresh() }
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() }
}
@ViewBuilder private var icon: some View {
if let appURL {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
.resizable()
.interpolation(.high)
} else {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(hex: app.color))
.overlay(Text(String(app.name.prefix(1)))
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white))
}
}
/// The first installed bundle id of the slot.
private var bundleId: String? {
app.bundleIds.first { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) != nil }
}
private func refresh() {
appURL = bundleId.flatMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) }
isRunning = NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundleId }
}
private func open() {
SoundEngine.shared.play("blip")
if let running = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleId }) {
running.activate(options: .activateIgnoringOtherApps)
} else if let appURL {
NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
} else if let url = URL(string: app.downloadURL) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Empty slot
struct EmptyPill: View {
var body: some View {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.overlay(
Text("пусто")
.font(.system(size: 10.5))
.foregroundColor(Color(hex: "#4A4E55"))
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}