// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // MenuBarView.swift — le MenuBarExtra « Ka » : pouls de l'écosystème en direct // (compteurs 5 min), recherche universelle rapide (résultats compacts, clic → // fiche web à la source), pastilles des 13 services, bouton « Ouvrir KA ». import SwiftUI struct MenuBarView: View { @EnvironmentObject private var pulse: EcosystemPulse @EnvironmentObject private var state: AppState @Environment(\.openWindow) private var openWindow @Environment(\.colorScheme) private var scheme @State private var query = "" @State private var results: [KAItem] = [] @State private var searching = false @State private var searched = "" var body: some View { VStack(alignment: .leading, spacing: 10) { header KASearchField(prompt: "Recherche rapide partout…", text: $query) { Task { await quickSearch() } } .onChange(of: query) { if query.isEmpty { results = []; searched = "" } } if searching || !searched.isEmpty { searchResults } else { pulseGrid } Divider().opacity(0.5) statusStrip footer } .padding(12) .frame(width: 350) .background(KATheme.paper(scheme)) .onAppear { state.openMain = { focusMainWindow(openWindow) } } } // MARK: en-tête private var header: some View { HStack { GroupeKAMark(size: 15) Spacer() Button { focusMainWindow(openWindow) } label: { Label("Ouvrir KA", systemImage: "macwindow") .font(.caption.weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 5) .background(KATheme.inkLight, in: Capsule()) .foregroundStyle(KATheme.lime) } .buttonStyle(.plain) } } // MARK: le pouls (compteurs des 12 univers) private var pulseGrid: some View { VStack(alignment: .leading, spacing: 6) { Text("Le pouls de l'écosystème") .font(.system(.caption2, design: .monospaced).weight(.bold)) .textCase(.uppercase) .foregroundStyle(.secondary) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) { ForEach(Ecosystem.all) { u in Button { state.selection = .universe(u.id) focusMainWindow(openWindow) } label: { HStack(spacing: 6) { KALogo(id: u.id, size: 14, fallbackSymbol: u.symbol, fallbackAccent: u.accent) KAWordmark(universe: u, size: 10) Spacer(minLength: 2) Text(pulse.totals[u.id].map { Double($0).compact } ?? "—") .font(.system(.caption2, design: .monospaced).weight(.bold)) .foregroundStyle(pulse.totals[u.id] == nil ? .secondary : u.accent) .contentTransition(.numericText()) } .padding(.horizontal, 8).padding(.vertical, 6) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1)) } .buttonStyle(.plain) .help("\(u.name) — \(u.tagline)") } } } } // MARK: résultats compacts multi-univers (clic → fiche web à la source) @ViewBuilder private var searchResults: some View { ScrollView { VStack(alignment: .leading, spacing: 6) { if searching { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("KA interroge les univers…").font(.caption).foregroundStyle(.secondary) } .padding(10) } else if results.isEmpty { Text("Rien trouvé pour « \(searched) ».") .font(.caption).foregroundStyle(.secondary).padding(10) } else { ForEach(results) { item in let u = Ecosystem.universe(item.universeID) Button { NSWorkspace.shared.open(item.url ?? u?.baseURL ?? Ecosystem.hubURL) } label: { HStack(spacing: 8) { RoundedRectangle(cornerRadius: 3) .fill(u?.accent ?? .gray) .frame(width: 4, height: 30) VStack(alignment: .leading, spacing: 1) { Text(item.title).font(.caption.weight(.semibold)).lineLimit(1) HStack(spacing: 6) { if let p = item.priceLabel { Text(p).font(.system(.caption2, design: .rounded).weight(.bold)) .foregroundStyle(u?.accent ?? .primary) } Text(u?.wordmark ?? "") .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(.tertiary) } .padding(.horizontal, 8).padding(.vertical, 5) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1)) } .buttonStyle(.plain) .help("Ouvrir la fiche à la source") } Button { focusMainWindow(openWindow) state.openUniversalSearch() } label: { Text("Recherche complète dans KA →") .font(.caption.weight(.semibold)) .foregroundStyle(KATheme.green) .padding(.top, 2) } .buttonStyle(.plain) } } } .frame(maxHeight: 300) } // MARK: pastilles des 13 services private var statusStrip: some View { VStack(alignment: .leading, spacing: 6) { HStack { Text("Services") .font(.system(.caption2, design: .monospaced).weight(.bold)) .textCase(.uppercase) .foregroundStyle(.secondary) Spacer() Text("\(pulse.servicesUp)/\(pulse.health.count) en ligne") .font(.system(.caption2, design: .monospaced).weight(.bold)) .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange) } LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) { ForEach(pulse.health) { s in HStack(spacing: 5) { KAStatusDot(up: s.up) Text(s.name.replacingOccurrences(of: "Groupe-KA", with: "Hub")) .font(.system(size: 10, weight: .semibold)) .lineLimit(1) Spacer(minLength: 0) } .help("\(s.id)\(s.latencyMs.map { " — \($0) ms" } ?? "")") } } } } // MARK: pied private var footer: some View { HStack { if let d = pulse.lastRefresh { Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))") .font(.system(size: 10)).foregroundStyle(.tertiary) } else { Text("Première actualisation…").font(.system(size: 10)).foregroundStyle(.tertiary) } Spacer() Button("Quitter") { NSApp.terminate(nil) } .font(.system(size: 10)) .buttonStyle(.plain) .foregroundStyle(.secondary) .keyboardShortcut("q") } } private func quickSearch() async { let q = query.trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return } searching = true searched = q let targets = Ecosystem.all.filter { $0.map != nil } var found: [(Universe, [KAItem])] = [] await withTaskGroup(of: (String, [KAItem]).self) { group in for u in targets { group.addTask { let items = (try? await UniverseService.fetch(u, query: q, limit: 3)) ?? [] return (u.id, items) } } var dict: [String: [KAItem]] = [:] for await (id, items) in group { dict[id] = items } found = targets.map { ($0, dict[$0.id] ?? []) } } results = found .sorted { if $0.0.id == "trouve-ka" { return false } if $1.0.id == "trouve-ka" { return true } return $0.1.count > $1.1.count } .flatMap { $0.1.prefix(2) } searching = false } }