Files
sst/Sources/Sst/SettingsView.swift
T

225 lines
8.1 KiB
Swift

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)
}
}
}