222 lines
7.9 KiB
Swift
222 lines
7.9 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())
|
|
Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle())
|
|
} else if !installed {
|
|
Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|