import AppKit import SstCore import SstData import SstUI import SstWhisper import SwiftUI struct SettingsView: View { private enum Tab: CaseIterable { case hotkey, model, privacy var title: String { switch self { case .hotkey: Strings.tabHotkey case .model: Strings.tabModel case .privacy: Strings.tabPrivacy } } } @State private var tab: Tab = .hotkey var body: some View { ZStack { BrushedBackground() VStack(alignment: .leading, spacing: Layout.panelGap) { HStack(spacing: Space.s3) { ForEach(Tab.allCases, id: \.self) { candidate in Button(candidate.title) { tab = candidate } .buttonStyle(KeyButtonStyle(latched: tab == candidate)) } } Group { switch tab { case .hotkey: HotkeySettingsView() case .model: ModelSettingsView() case .privacy: PrivacySettingsView() } } .padding(Layout.panelPadding) .frame(maxWidth: .infinity, alignment: .topLeading) .background(PanelBackground()) } .padding(Layout.panelPadding) } .frame(width: Layout.settingsWidth) .fixedSize(horizontal: false, vertical: true) } } // MARK: hotkey extension HotkeyBinding { /// The matching flag in an AppKit event, used while capturing a new key. fileprivate var eventFlag: NSEvent.ModifierFlags { switch id { case "fn": .function case "rightOption": .option case "rightCommand": .command default: .control } } } @MainActor private final class HotkeyCapture: ObservableObject { @Published var isCapturing = false @Published var message: String? private var monitor: Any? func start(onCapture: @escaping (HotkeyBinding) -> Void) { stop() isCapturing = true message = Strings.capturing monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in guard let self else { return event } if event.type == .keyDown { if event.keyCode == 53 { self.stop() } // Escape cancels return nil } guard let binding = HotkeyBinding.binding(keyCode: Int(event.keyCode)) else { self.message = Strings.hotkeyRejected return nil } if event.modifierFlags.contains(binding.eventFlag) { self.stop() onCapture(binding) } return nil } } func stop() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil isCapturing = false message = nil } } private struct HotkeySettingsView: View { @EnvironmentObject private var settings: AppSettings @StateObject private var capture = HotkeyCapture() private var current: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } var body: some View { VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(Strings.tabHotkey, style: Typography.heading) HStack(spacing: Layout.panelGap) { Text(current.displayName) .textStyle(Typography.heading) .foregroundStyle(Palette.inkPrimary) .padding(.horizontal, Space.s4) .frame(minHeight: Size.controlHeight) .background(WellBackground()) Button(Strings.captureHotkey) { capture.start { settings.hotkeyID = $0.id } } .buttonStyle(KeyButtonStyle(latched: capture.isCapturing)) } if let message = capture.message { Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) } SeamDivider() Text(HotkeyBinding.allowed.map(\.displayName).joined(separator: " · ")) .textStyle(Typography.meta) .foregroundStyle(Palette.inkSecondary) if current == .fn { Text(Strings.globeHint).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) } } .onDisappear { capture.stop() } } } // MARK: model private struct ModelSettingsView: View { @EnvironmentObject private var model: AppModel var body: some View { VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(Strings.tabModel, style: Typography.heading) ForEach(ModelCatalog.all) { option in row(option) SeamDivider() } HStack { Text(model.storage.base.path) .textStyle(Typography.code) .foregroundStyle(Palette.inkSecondary) .lineLimit(1) .truncationMode(.middle) Spacer() Button(Strings.showInFinder) { NSWorkspace.shared.activateFileViewerSelecting([model.storage.base]) } .buttonStyle(KeyButtonStyle()) } } .onAppear { model.refreshInstalled() } } private func row(_ option: ModelOption) -> some View { let isActive = model.activeVariant == option.id let installed = model.installedVariants.contains(option.id) let progress: Double? = { if case .downloading(option.id, let fraction) = model.modelStatus { return fraction } return nil }() return HStack(alignment: .center, spacing: Layout.panelGap) { LampView(isOn: isActive, tint: .amber) VStack(alignment: .leading, spacing: Space.s1) { Text(option.title).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) Text("\(option.detail) · \(option.approxSizeMB) MB") .textStyle(Typography.meta) .foregroundStyle(Palette.inkSecondary) if let progress { LEDBarView(fraction: progress) } else if case .loading(option.id) = model.modelStatus { SilkLabel(Strings.modelLoading(option.title), style: Typography.labelSmall) } } Spacer() if installed && !isActive { Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) .disabled(model.loadingVariant != nil) Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle()) .disabled(model.loadingVariant != nil) } else if !installed { Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) .disabled(model.loadingVariant != nil) } } } } // MARK: privacy private struct PrivacySettingsView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @State private var confirming = false var body: some View { VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(Strings.tabPrivacy, style: Typography.heading) Toggle(isOn: $settings.keepHistory) { Text(Strings.keepHistory).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) } .toggleStyle(RockerToggleStyle()) SeamDivider() Button(Strings.clearHistory) { confirming = true } .buttonStyle(KeyButtonStyle()) } .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $confirming) { Button(Strings.erase, role: .destructive) { model.clearHistory() } Button(Strings.cancel, role: .cancel) {} } message: { Text(Strings.clearHistoryDetail) } } }