306 lines
10 KiB
Swift
306 lines
10 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import SwiftUI
|
|
|
|
/// System Settings-style sidebar: fixed app panes on top, one row per provider below.
|
|
@MainActor
|
|
struct SettingsSidebarView: View {
|
|
@Bindable var settings: SettingsStore
|
|
@Bindable var store: UsageStore
|
|
@Binding var selection: SettingsPane
|
|
@State private var searchText = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
SettingsSidebarSearchField(searchText: self.$searchText)
|
|
SettingsSidebarSortToggle(isOn: self.sortAlphabeticallyBinding)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
|
|
List(selection: self.selectionBinding) {
|
|
self.appPanesSection
|
|
self.providersSection
|
|
}
|
|
.listStyle(.sidebar)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
}
|
|
|
|
private var appPanesSection: some View {
|
|
Section {
|
|
SettingsSidebarPaneRow(pane: .general, systemImage: "gearshape.fill", color: .gray)
|
|
SettingsSidebarPaneRow(pane: .notifications, systemImage: "bell.badge.fill", color: .red)
|
|
SettingsSidebarPaneRow(pane: .menuBar, systemImage: "menubar.rectangle", color: .blue)
|
|
SettingsSidebarPaneRow(pane: .menu, systemImage: "filemenu.and.selection", color: .teal)
|
|
SettingsSidebarPaneRow(pane: .advanced, systemImage: "slider.horizontal.3", color: .purple)
|
|
SettingsSidebarAboutRow()
|
|
if self.settings.debugMenuEnabled {
|
|
SettingsSidebarPaneRow(pane: .debug, systemImage: "ladybug.fill", color: .red)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var providersSection: some View {
|
|
Section {
|
|
ForEach(self.filteredProviders, id: \.self) { provider in
|
|
SettingsSidebarProviderRow(
|
|
provider: provider,
|
|
store: self.store,
|
|
isEnabled: self.enabledBinding(for: provider))
|
|
.tag(SettingsPane.provider(provider))
|
|
.moveDisabled(!self.canReorderProviders)
|
|
}
|
|
.onMove { fromOffsets, toOffset in
|
|
guard self.canReorderProviders else { return }
|
|
self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset)
|
|
}
|
|
|
|
if self.filteredProviders.isEmpty {
|
|
Text(L("No matching providers"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} header: {
|
|
HStack(spacing: 4) {
|
|
Text(L("tab_providers"))
|
|
Spacer()
|
|
Text(String(format: L("providers_on_count"), self.enabledProviderCount))
|
|
.foregroundStyle(.tertiary)
|
|
.monospacedDigit()
|
|
.padding(.trailing, 10)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var selectionBinding: Binding<SettingsPane?> {
|
|
Binding(
|
|
get: { self.selection },
|
|
set: { newValue in
|
|
if let newValue {
|
|
self.selection = newValue
|
|
}
|
|
})
|
|
}
|
|
|
|
private var sortAlphabeticallyBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { self.settings.providersSortedAlphabetically },
|
|
set: { self.settings.providersSortedAlphabetically = $0 })
|
|
}
|
|
|
|
private var orderedProviders: [UsageProvider] {
|
|
guard self.settings.providersSortedAlphabetically else {
|
|
return self.settings.orderedProviders()
|
|
}
|
|
return CodexBarConfig.alphabeticalProviderOrder(enablement: { provider in
|
|
self.settings.isProviderEnabled(provider: provider, metadata: self.store.metadata(for: provider))
|
|
})
|
|
}
|
|
|
|
private var filteredProviders: [UsageProvider] {
|
|
ProvidersPane.filteredProviders(
|
|
self.orderedProviders,
|
|
query: self.searchText,
|
|
displayName: { provider in self.store.metadata(for: provider).displayName })
|
|
}
|
|
|
|
private var enabledProviderCount: Int {
|
|
self.orderedProviders.count(where: { provider in
|
|
self.settings.isProviderEnabled(provider: provider, metadata: self.store.metadata(for: provider))
|
|
})
|
|
}
|
|
|
|
private var canReorderProviders: Bool {
|
|
!self.settings.providersSortedAlphabetically
|
|
&& self.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private func enabledBinding(for provider: UsageProvider) -> Binding<Bool> {
|
|
let meta = self.store.metadata(for: provider)
|
|
return Binding(
|
|
get: { self.settings.isProviderEnabled(provider: provider, metadata: meta) },
|
|
set: { newValue in
|
|
self.settings.setProviderEnabled(provider: provider, metadata: meta, enabled: newValue)
|
|
})
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct SettingsSidebarPaneRow: View {
|
|
let pane: SettingsPane
|
|
let systemImage: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
SettingsIconChip(systemImage: self.systemImage, color: self.color)
|
|
Text(self.pane.title)
|
|
}
|
|
.tag(self.pane)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct SettingsSidebarAboutRow: View {
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
if let icon = NSApplication.shared.applicationIconImage {
|
|
Image(nsImage: icon)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: SettingsIconChip.side, height: SettingsIconChip.side)
|
|
.accessibilityHidden(true)
|
|
} else {
|
|
SettingsIconChip(systemImage: "info.circle.fill", color: .green)
|
|
}
|
|
Text(SettingsPane.about.title)
|
|
}
|
|
.tag(SettingsPane.about)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct SettingsSidebarProviderRow: View {
|
|
let provider: UsageProvider
|
|
@Bindable var store: UsageStore
|
|
@Binding var isEnabled: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
SettingsSidebarBrandIcon(provider: self.provider, isEnabled: self.isEnabled)
|
|
|
|
Text(self.store.metadata(for: self.provider).displayName)
|
|
.foregroundStyle(self.isEnabled ? .primary : .secondary)
|
|
|
|
Spacer(minLength: 4)
|
|
|
|
if self.store.refreshingProviders.contains(self.provider) {
|
|
ProgressView()
|
|
.controlSize(.mini)
|
|
}
|
|
|
|
if self.isEnabled, self.store.statusChecksEnabled {
|
|
SettingsSidebarStatusDot(indicator: self.store.statusIndicator(for: self.provider))
|
|
}
|
|
}
|
|
.opacity(self.isEnabled ? 1 : 0.62)
|
|
.contextMenu {
|
|
Button(self.isEnabled ? L("Disable") : L("Enable")) {
|
|
self.isEnabled.toggle()
|
|
}
|
|
}
|
|
.accessibilityLabel(self.accessibilityLabel)
|
|
}
|
|
|
|
private var accessibilityLabel: String {
|
|
let name = self.store.metadata(for: self.provider).displayName
|
|
return self.isEnabled ? name : "\(name) — \(L("Disabled"))"
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct SettingsSidebarBrandIcon: View {
|
|
let provider: UsageProvider
|
|
let isEnabled: Bool
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let brand = ProviderBrandIcon.image(for: self.provider) {
|
|
Image(nsImage: brand)
|
|
.resizable()
|
|
.scaledToFit()
|
|
} else {
|
|
Image(systemName: "circle.dotted")
|
|
.resizable()
|
|
.scaledToFit()
|
|
}
|
|
}
|
|
.frame(width: 16, height: 16)
|
|
.foregroundStyle(self.isEnabled ? .primary : .secondary)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
private struct SettingsSidebarStatusDot: View {
|
|
let indicator: ProviderStatusIndicator
|
|
|
|
var body: some View {
|
|
Circle()
|
|
.fill(self.statusColor)
|
|
.frame(width: 6, height: 6)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch self.indicator {
|
|
case .none: .green
|
|
case .minor: .yellow
|
|
case .major: .orange
|
|
case .critical: .red
|
|
case .maintenance: .gray
|
|
case .unknown: .gray
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SettingsSidebarSearchField: View {
|
|
@Binding var searchText: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityHidden(true)
|
|
|
|
TextField(L("Search providers"), text: self.$searchText)
|
|
.textFieldStyle(.plain)
|
|
|
|
if !self.searchText.isEmpty {
|
|
Button {
|
|
self.searchText = ""
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel(L("Clear"))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.font(.callout)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(Color(nsColor: .textBackgroundColor).opacity(0.6)))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.stroke(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1))
|
|
}
|
|
}
|
|
|
|
private struct SettingsSidebarSortToggle: View {
|
|
@Binding var isOn: Bool
|
|
|
|
var body: some View {
|
|
Button {
|
|
self.isOn.toggle()
|
|
} label: {
|
|
Image(systemName: "arrow.up.arrow.down")
|
|
.font(.callout)
|
|
.foregroundStyle(self.isOn ? Color.accentColor : Color.secondary)
|
|
.frame(width: 24, height: 24)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(self.isOn
|
|
? L("Sorted alphabetically (enabled first) — click to use your custom order")
|
|
: L("Sort providers alphabetically (enabled first)"))
|
|
.accessibilityLabel(L("Sort providers alphabetically"))
|
|
.accessibilityAddTraits(self.isOn ? .isSelected : [])
|
|
}
|
|
}
|