1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// MenuBarView.swift — le MenuBarExtra « Ka » : pouls de l'écosystème en direct3// (compteurs 5 min), recherche universelle rapide (résultats compacts, clic →4// fiche web à la source), pastilles des 13 services, bouton « Ouvrir KA ».5import SwiftUI67struct MenuBarView: View {8 @EnvironmentObject private var pulse: EcosystemPulse9 @EnvironmentObject private var state: AppState10 @Environment(\.openWindow) private var openWindow11 @Environment(\.colorScheme) private var scheme1213 @State private var query = ""14 @State private var results: [KAItem] = []15 @State private var searching = false16 @State private var searched = ""1718 var body: some View {19 VStack(alignment: .leading, spacing: 10) {20 header21 KASearchField(prompt: "Recherche rapide partout…", text: $query) {22 Task { await quickSearch() }23 }24 .onChange(of: query) {25 if query.isEmpty { results = []; searched = "" }26 }27 if searching || !searched.isEmpty {28 searchResults29 } else {30 pulseGrid31 }32 Divider().opacity(0.5)33 statusStrip34 footer35 }36 .padding(12)37 .frame(width: 350)38 .background(KATheme.paper(scheme))39 .onAppear { state.openMain = { focusMainWindow(openWindow) } }40 }4142 // MARK: en-tête4344 private var header: some View {45 HStack {46 GroupeKAMark(size: 15)47 Spacer()48 Button {49 focusMainWindow(openWindow)50 } label: {51 Label("Ouvrir KA", systemImage: "macwindow")52 .font(.caption.weight(.bold))53 .padding(.horizontal, 10).padding(.vertical, 5)54 .background(KATheme.inkLight, in: Capsule())55 .foregroundStyle(KATheme.lime)56 }57 .buttonStyle(.plain)58 }59 }6061 // MARK: le pouls (compteurs des 12 univers)6263 private var pulseGrid: some View {64 VStack(alignment: .leading, spacing: 6) {65 Text("Le pouls de l'écosystème")66 .font(.system(.caption2, design: .monospaced).weight(.bold))67 .textCase(.uppercase)68 .foregroundStyle(.secondary)69 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {70 ForEach(Ecosystem.all) { u in71 Button {72 state.selection = .universe(u.id)73 focusMainWindow(openWindow)74 } label: {75 HStack(spacing: 6) {76 Image(systemName: u.symbol)77 .font(.caption2.weight(.semibold))78 .foregroundStyle(u.accent)79 .frame(width: 14)80 KAWordmark(universe: u, size: 10)81 Spacer(minLength: 2)82 Text(pulse.totals[u.id].map { Double($0).compact } ?? "—")83 .font(.system(.caption2, design: .monospaced).weight(.bold))84 .foregroundStyle(pulse.totals[u.id] == nil ? .secondary : u.accent)85 .contentTransition(.numericText())86 }87 .padding(.horizontal, 8).padding(.vertical, 6)88 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))89 .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)90 .strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1))91 }92 .buttonStyle(.plain)93 .help("\(u.name) — \(u.tagline)")94 }95 }96 }97 }9899 // MARK: résultats compacts multi-univers (clic → fiche web à la source)100101 @ViewBuilder102 private var searchResults: some View {103 ScrollView {104 VStack(alignment: .leading, spacing: 6) {105 if searching {106 HStack(spacing: 8) {107 ProgressView().controlSize(.small)108 Text("KA interroge les univers…").font(.caption).foregroundStyle(.secondary)109 }110 .padding(10)111 } else if results.isEmpty {112 Text("Rien trouvé pour « \(searched) ».")113 .font(.caption).foregroundStyle(.secondary).padding(10)114 } else {115 ForEach(results) { item in116 let u = Ecosystem.universe(item.universeID)117 Button {118 NSWorkspace.shared.open(item.url ?? u?.baseURL ?? Ecosystem.hubURL)119 } label: {120 HStack(spacing: 8) {121 RoundedRectangle(cornerRadius: 3)122 .fill(u?.accent ?? .gray)123 .frame(width: 4, height: 30)124 VStack(alignment: .leading, spacing: 1) {125 Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)126 HStack(spacing: 6) {127 if let p = item.priceLabel {128 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))129 .foregroundStyle(u?.accent ?? .primary)130 }131 Text(u?.wordmark ?? "")132 .font(.system(size: 9, weight: .bold, design: .monospaced))133 .foregroundStyle(.secondary)134 }135 }136 Spacer()137 Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(.tertiary)138 }139 .padding(.horizontal, 8).padding(.vertical, 5)140 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))141 .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)142 .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))143 }144 .buttonStyle(.plain)145 .help("Ouvrir la fiche à la source")146 }147 Button {148 focusMainWindow(openWindow)149 state.openUniversalSearch()150 } label: {151 Text("Recherche complète dans KA →")152 .font(.caption.weight(.semibold))153 .foregroundStyle(KATheme.green)154 .padding(.top, 2)155 }156 .buttonStyle(.plain)157 }158 }159 }160 .frame(maxHeight: 300)161 }162163 // MARK: pastilles des 13 services164165 private var statusStrip: some View {166 VStack(alignment: .leading, spacing: 6) {167 HStack {168 Text("Services")169 .font(.system(.caption2, design: .monospaced).weight(.bold))170 .textCase(.uppercase)171 .foregroundStyle(.secondary)172 Spacer()173 Text("\(pulse.servicesUp)/\(pulse.health.count) en ligne")174 .font(.system(.caption2, design: .monospaced).weight(.bold))175 .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)176 }177 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {178 ForEach(pulse.health) { s in179 HStack(spacing: 5) {180 KAStatusDot(up: s.up)181 Text(s.name.replacingOccurrences(of: "Groupe-KA", with: "Hub"))182 .font(.system(size: 10, weight: .semibold))183 .lineLimit(1)184 Spacer(minLength: 0)185 }186 .help("\(s.id)\(s.latencyMs.map { " — \($0) ms" } ?? "")")187 }188 }189 }190 }191192 // MARK: pied193194 private var footer: some View {195 HStack {196 if let d = pulse.lastRefresh {197 Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")198 .font(.system(size: 10)).foregroundStyle(.tertiary)199 } else {200 Text("Première actualisation…").font(.system(size: 10)).foregroundStyle(.tertiary)201 }202 Spacer()203 Button("Quitter") { NSApp.terminate(nil) }204 .font(.system(size: 10))205 .buttonStyle(.plain)206 .foregroundStyle(.secondary)207 .keyboardShortcut("q")208 }209 }210211 private func quickSearch() async {212 let q = query.trimmingCharacters(in: .whitespaces)213 guard !q.isEmpty else { return }214 searching = true215 searched = q216 let targets = Ecosystem.all.filter { $0.map != nil }217 var found: [(Universe, [KAItem])] = []218 await withTaskGroup(of: (String, [KAItem]).self) { group in219 for u in targets {220 group.addTask {221 let items = (try? await UniverseService.fetch(u, query: q, limit: 3)) ?? []222 return (u.id, items)223 }224 }225 var dict: [String: [KAItem]] = [:]226 for await (id, items) in group { dict[id] = items }227 found = targets.map { ($0, dict[$0.id] ?? []) }228 }229 results = found230 .sorted {231 if $0.0.id == "trouve-ka" { return false }232 if $1.0.id == "trouve-ka" { return true }233 return $0.1.count > $1.1.count234 }235 .flatMap { $0.1.prefix(2) }236 searching = false237 }238}239