838 lines
34 KiB
Swift
838 lines
34 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
struct SettingsContentView: View {
|
|
@Bindable var appState: AppState
|
|
@State private var selectedTab = 0
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Two-tab segmented picker
|
|
Picker("", selection: $selectedTab) {
|
|
Text("Anpassen").tag(0)
|
|
Text("Zugang").tag(1)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
|
|
ScrollView {
|
|
if selectedTab == 0 {
|
|
CustomizeSettingsView(appState: appState)
|
|
} else {
|
|
AccessSettingsView(appState: appState)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
appState.refreshAccessibilityPermission()
|
|
selectedTab = defaultTabSelection
|
|
}
|
|
}
|
|
|
|
private var defaultTabSelection: Int {
|
|
if !appState.accessibilityPermissionGranted {
|
|
return 1
|
|
}
|
|
if appState.isConfigured && !BlitztextInstallLocationService.shouldOfferMoveToApplications {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Section Label (quiet style)
|
|
|
|
private struct SectionLabel: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
Text(text.uppercased())
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Access Settings (Tab 1: Zugang)
|
|
|
|
struct AccessSettingsView: View {
|
|
private static let openAIAPIKeyPattern = #"^sk-[A-Za-z0-9_-]{20,}$"#
|
|
|
|
@Bindable var appState: AppState
|
|
|
|
private enum FieldFocus {
|
|
case openAIAPIKey
|
|
}
|
|
|
|
@State private var launchAtLoginService = LaunchAtLoginService()
|
|
@State private var currentInstallLocation = BlitztextInstallLocationService.currentInstallLocation
|
|
@State private var openAIAPIKey = ""
|
|
@State private var editingAPIKey = false
|
|
@State private var saved = false
|
|
@State private var saveErrorText: String?
|
|
@State private var installActionErrorText: String?
|
|
@State private var showCleanupOptions = false
|
|
@State private var deleteLocalDataOnCleanup = true
|
|
@State private var cleanupStatusText: String?
|
|
@State private var cleanupErrorText: String?
|
|
@FocusState private var focusedField: FieldFocus?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel(text: "Berechtigungen")
|
|
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: appState.accessibilityPermissionGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(appState.accessibilityPermissionGranted ? .green : .orange)
|
|
.frame(width: 18, height: 18)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(appState.accessibilityPermissionGranted ? "Direktes Einfügen ist freigegeben." : "Direktes Einfügen ist noch nicht freigegeben.")
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
|
|
Text("Öffne Bedienungshilfen und aktiviere Blitztext. Falls Blitztext schon aktiv ist, einmal aus- und wieder einschalten.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Button("Bedienungshilfen öffnen") {
|
|
appState.requestAccessibilityPermission()
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
|
|
Button("Erneut prüfen") {
|
|
appState.refreshAccessibilityPermission()
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
SectionLabel(text: "OpenAI API Key")
|
|
Spacer()
|
|
if appState.hasValue(for: .openAIAPIKey) && !editingAPIKey {
|
|
Button("Aendern") { editingAPIKey = true }
|
|
.font(.system(size: 10, weight: .medium))
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
|
|
if appState.hasValue(for: .openAIAPIKey) && !editingAPIKey {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.green.opacity(0.8))
|
|
Text(appState.apiKeyDisplayValue(for: .openAIAPIKey))
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
SecureField("sk-...", text: $openAIAPIKey)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.system(size: 11.5))
|
|
.focused($focusedField, equals: .openAIAPIKey)
|
|
|
|
Button("Einfuegen") {
|
|
pasteAPIKeyFromClipboard()
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
}
|
|
}
|
|
|
|
Text("Dein Key bleibt lokal in dieser App. Audio und Text werden direkt an die OpenAI API gesendet.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel(text: "Installation")
|
|
|
|
Text(installationHeadline)
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(installationDetail)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(BlitztextInstallLocationService.bundleURL.path)
|
|
.font(.system(size: 10.5, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
|
|
if !BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
|
|
Text("Weitere Blitztext-Kopien auf diesem Mac können doppelte Login-Items auslösen.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.orange)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
if BlitztextInstallLocationService.shouldOfferMoveToApplications {
|
|
Button("Nach /Applications bewegen") {
|
|
moveToApplications()
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
}
|
|
|
|
Button("Im Finder zeigen") {
|
|
revealInFinder(urls: [BlitztextInstallLocationService.bundleURL])
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
|
|
if !BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
|
|
Button("Weitere Kopien zeigen") {
|
|
revealInFinder(urls: BlitztextInstallLocationService.otherInstalledBundleURLs)
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
}
|
|
}
|
|
|
|
if let installActionErrorText {
|
|
Text(installActionErrorText)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel(text: "Updates")
|
|
|
|
Text("Diese Preview hat keinen oeffentlichen Update-Feed. Baue neue Versionen selbst aus dem Repo.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if !currentInstallLocation.isCanonicalInstall {
|
|
Text("Hotkeys und Login-Start laufen am stabilsten, wenn Blitztext aus /Applications gestartet wird.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.orange)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} else {
|
|
Text("Updates sind in dieser Preview manuell: pull, build, starten.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// Launch at Login
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel(text: "Beim Anmelden")
|
|
|
|
Toggle("Blitztext automatisch starten", isOn: Binding(
|
|
get: { launchAtLoginService.isEnabled },
|
|
set: { launchAtLoginService.setEnabled($0) }
|
|
))
|
|
.toggleStyle(.switch)
|
|
|
|
Text(launchAtLoginService.errorText ?? launchAtLoginService.helperText)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(
|
|
launchAtLoginService.errorText == nil
|
|
? AnyShapeStyle(.secondary)
|
|
: AnyShapeStyle(.red)
|
|
)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if let saveErrorText {
|
|
Text(saveErrorText)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
SectionLabel(text: "Hinweis")
|
|
|
|
Text("Fuer direktes Einfuegen: Blitztext einmal nach /Applications legen und danach Mikrofon sowie Bedienungshilfen erlauben.")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.primary.opacity(0.03))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel(text: "Sauber Entfernen")
|
|
|
|
Text("Vor dem Löschen Blitztext erst auf diesem Mac bereinigen. So verschwinden Anmeldestart und lokale Daten sauber aus dem Weg.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
if showCleanupOptions {
|
|
Toggle("Zugangsdaten und Einstellungen dieses Macs löschen", isOn: $deleteLocalDataOnCleanup)
|
|
.toggleStyle(.switch)
|
|
|
|
Text("Danach Blitztext beenden und die App aus /Applications löschen. Bereits verwaiste alte Login-Items können in den Systemeinstellungen einmalig manuell entfernt werden.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
HStack(spacing: 8) {
|
|
Button("Abbrechen") {
|
|
showCleanupOptions = false
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
|
|
Button("Jetzt bereinigen") {
|
|
runCleanup()
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
.foregroundStyle(.red)
|
|
}
|
|
} else {
|
|
Button("Entfernung vorbereiten") {
|
|
showCleanupOptions = true
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
}
|
|
|
|
if let cleanupStatusText {
|
|
Text(cleanupStatusText)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.green)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if let cleanupErrorText {
|
|
Text(cleanupErrorText)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
// Save button (right-aligned, text only)
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
save()
|
|
} label: {
|
|
if saved {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 10, weight: .bold))
|
|
Text("Gespeichert")
|
|
}
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.green)
|
|
} else {
|
|
Text("Speichern")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
.animation(.easeInOut(duration: 0.2), value: saved)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.onAppear {
|
|
launchAtLoginService.refresh()
|
|
refreshInstallState()
|
|
load()
|
|
if !appState.hasValue(for: .openAIAPIKey) {
|
|
editingAPIKey = true
|
|
focusedField = .openAIAPIKey
|
|
}
|
|
}
|
|
}
|
|
|
|
private func load() {
|
|
openAIAPIKey = ""
|
|
}
|
|
|
|
private func save() {
|
|
saveErrorText = nil
|
|
cleanupStatusText = nil
|
|
cleanupErrorText = nil
|
|
KeychainService.invalidateCache()
|
|
let trimmedAPIKey = openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if editingAPIKey || !appState.hasValue(for: .openAIAPIKey) {
|
|
guard !trimmedAPIKey.isEmpty else {
|
|
saveErrorText = "Bitte trage deinen OpenAI API Key ein."
|
|
return
|
|
}
|
|
do {
|
|
try KeychainService.save(key: .openAIAPIKey, value: trimmedAPIKey)
|
|
openAIAPIKey = ""
|
|
editingAPIKey = false
|
|
} catch {
|
|
saveErrorText = "OpenAI API Key konnte nicht gespeichert werden."
|
|
return
|
|
}
|
|
}
|
|
|
|
KeychainService.invalidateCache()
|
|
if !appState.hasValue(for: .openAIAPIKey) {
|
|
saveErrorText = "OpenAI API Key wurde nicht persistent gespeichert. Bitte App neu starten und erneut versuchen."
|
|
return
|
|
}
|
|
|
|
withAnimation(.easeInOut(duration: 0.2)) { saved = true }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
withAnimation(.easeInOut(duration: 0.2)) { saved = false }
|
|
}
|
|
}
|
|
|
|
private func pasteAPIKeyFromClipboard() {
|
|
guard let rawText = NSPasteboard.general.string(forType: .string) else {
|
|
saveErrorText = "Zwischenablage enthält keinen Text."
|
|
return
|
|
}
|
|
|
|
let firstLine = rawText.components(separatedBy: .newlines).first ?? rawText
|
|
let trimmedKey = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmedKey.range(of: Self.openAIAPIKeyPattern, options: .regularExpression) != nil else {
|
|
saveErrorText = "Zwischenablage enthält keinen plausiblen OpenAI API Key."
|
|
return
|
|
}
|
|
|
|
openAIAPIKey = trimmedKey
|
|
NSPasteboard.general.clearContents()
|
|
saveErrorText = nil
|
|
}
|
|
|
|
private var installationHeadline: String {
|
|
switch currentInstallLocation {
|
|
case .applications:
|
|
return "Blitztext liegt am richtigen Ort."
|
|
case .userApplications:
|
|
return "Blitztext liegt noch in ~/Applications."
|
|
case .outsideApplications:
|
|
return "Blitztext liegt noch nicht in /Applications."
|
|
case .unknown:
|
|
return "Der Installationsort konnte nicht sicher erkannt werden."
|
|
}
|
|
}
|
|
|
|
private var installationDetail: String {
|
|
switch currentInstallLocation {
|
|
case .applications:
|
|
if BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
|
|
return "Für stabile Login-Items und Updates nur diese Kopie weiterverwenden."
|
|
}
|
|
return "Diese Kopie ist korrekt. Zusätzliche Kopien solltest du später entfernen."
|
|
case .userApplications:
|
|
return "Fuer stabile Hotkeys und Login-Items sollte Blitztext nur aus /Applications laufen."
|
|
case .outsideApplications:
|
|
return "Verschiebe Blitztext einmal nach /Applications, damit Anmeldestart und Hotkeys sauber bleiben."
|
|
case .unknown:
|
|
return "Öffne Blitztext möglichst direkt aus /Applications."
|
|
}
|
|
}
|
|
|
|
private func refreshInstallState() {
|
|
currentInstallLocation = BlitztextInstallLocationService.currentInstallLocation
|
|
installActionErrorText = nil
|
|
}
|
|
|
|
private func moveToApplications() {
|
|
installActionErrorText = nil
|
|
|
|
do {
|
|
try BlitztextInstallLocationService.moveToApplicationsAndRelaunch()
|
|
} catch {
|
|
installActionErrorText = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func runCleanup() {
|
|
cleanupStatusText = nil
|
|
cleanupErrorText = nil
|
|
|
|
let report = deleteLocalDataOnCleanup
|
|
? BlitztextCleanupService.cleanupUserData()
|
|
: BlitztextCleanupService.removeLaunchAtLoginRegistration()
|
|
|
|
KeychainService.invalidateCache()
|
|
launchAtLoginService.refresh()
|
|
refreshInstallState()
|
|
|
|
if deleteLocalDataOnCleanup {
|
|
openAIAPIKey = ""
|
|
editingAPIKey = true
|
|
}
|
|
|
|
if report.failedItems.isEmpty {
|
|
cleanupStatusText = deleteLocalDataOnCleanup
|
|
? "Anmeldestart und lokale Daten wurden bereinigt. Jetzt Blitztext beenden und aus /Applications löschen."
|
|
: "Anmeldestart wurde deaktiviert. Jetzt Blitztext beenden und aus /Applications löschen."
|
|
showCleanupOptions = false
|
|
|
|
let urlsToReveal = report.knownInstallBundleURLs.isEmpty
|
|
? [BlitztextInstallLocationService.bundleURL]
|
|
: report.knownInstallBundleURLs
|
|
revealInFinder(urls: urlsToReveal)
|
|
return
|
|
}
|
|
|
|
let failureSummary = report.failedItems
|
|
.map { "\($0.url.lastPathComponent): \($0.errorDescription)" }
|
|
.joined(separator: "\n")
|
|
cleanupErrorText = "Nicht alles konnte bereinigt werden:\n\(failureSummary)"
|
|
}
|
|
|
|
private func revealInFinder(urls: [URL]) {
|
|
guard !urls.isEmpty else { return }
|
|
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
|
}
|
|
}
|
|
|
|
// MARK: - Customize Settings (Tab 2: Anpassen)
|
|
|
|
struct CustomizeSettingsView: View {
|
|
@Bindable var appState: AppState
|
|
@State private var newTerm = ""
|
|
|
|
private var installedLocalModels: [LocalTranscriptionModel] {
|
|
LocalTranscriptionService.installedModels()
|
|
}
|
|
|
|
private var localModelOptions: [LocalTranscriptionModel] {
|
|
LocalTranscriptionService.modelOptions()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
|
|
// MARK: Lokaler Modus
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: "Sicherer Lokaler Modus")
|
|
|
|
Toggle("Sicherer Lokaler Modus", isOn: $appState.appSettings.secureLocalModeEnabled)
|
|
.toggleStyle(.switch)
|
|
.onChange(of: appState.appSettings.secureLocalModeEnabled) { _, newValue in
|
|
if newValue && !appState.selectedLocalModelIsInstalled {
|
|
appState.installSelectedLocalModel()
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 6) {
|
|
Image(systemName: appState.selectedLocalModelIsInstalled ? "checkmark.circle.fill" : "arrow.down.circle.fill")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(appState.selectedLocalModelIsInstalled ? .green : .blue)
|
|
Text(appState.selectedLocalModelIsInstalled ? "\(installedLocalModels.count) lokales WhisperKit-Modell installiert." : "Das ausgewählte Modell wird beim Installieren lokal gespeichert.")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Text("Lokales Modell")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Picker("", selection: Binding(
|
|
get: { appState.selectedLocalModelName },
|
|
set: { appState.appSettings.selectedLocalTranscriptionModelName = $0 }
|
|
)) {
|
|
ForEach(localModelOptions) { model in
|
|
Text("\(model.displayName) · \(model.installStateLabel)").tag(model.id)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.disabled(appState.isDownloadingLocalModel)
|
|
}
|
|
|
|
if let progress = appState.localModelDownloadProgress {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ProgressView(value: progress)
|
|
Text(appState.localModelDownloadStatusText ?? "Modell wird geladen...")
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
HStack(spacing: 10) {
|
|
Button(appState.localModelDownloadButtonTitle) {
|
|
appState.installSelectedLocalModel()
|
|
}
|
|
.controlSize(.small)
|
|
.disabled(appState.selectedLocalModelIsInstalled)
|
|
|
|
Link("Modellseite", destination: LocalTranscriptionService.modelPageURL(for: appState.selectedLocalModelName))
|
|
.font(.system(size: 10.5, weight: .medium))
|
|
}
|
|
}
|
|
|
|
if let errorText = appState.localModelDownloadErrorText {
|
|
Text(errorText)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
// MARK: Tastenkuerzel
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: "Tastenk\u{00FC}rzel")
|
|
|
|
VStack(spacing: 6) {
|
|
ForEach(WorkflowType.mainMenuCases) { type in
|
|
HStack {
|
|
Text(type.hotkeyLabel)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 124, alignment: .leading)
|
|
Text(appState.displayName(for: type))
|
|
.font(.system(size: 11.5, weight: .medium))
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mode picker
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Modus")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Picker("", selection: $appState.appSettings.hotkeyMode) {
|
|
ForEach(HotkeyMode.allCases) { mode in
|
|
Text(mode.displayName).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
|
|
// MARK: Blitztext+
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: "Blitztext+")
|
|
|
|
// Tone
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Schreibstil")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Picker("", selection: $appState.textImprovementSettings.tone) {
|
|
ForEach(TextImprovementSettings.TextTone.allCases) { tone in
|
|
Text(tone.displayName).tag(tone)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
// System Prompt
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Eigene Anweisung")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextEditor(text: $appState.textImprovementSettings.systemPrompt)
|
|
.font(.system(size: 11))
|
|
.frame(height: 64)
|
|
.scrollContentBackground(.hidden)
|
|
.padding(8)
|
|
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5))
|
|
.overlay(alignment: .topLeading) {
|
|
if appState.textImprovementSettings.systemPrompt.isEmpty {
|
|
Text("z.B. \"Schreibe pr\u{00E4}gnant und ohne F\u{00FC}llw\u{00F6}rter.\"")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.quaternary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 12)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Context
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Kontext")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextField("z.B. \"E-Mails im Bereich Unternehmensberatung\"", text: $appState.textImprovementSettings.context)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
|
|
// MARK: Blitztext $%&!
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: "Blitztext $%&!")
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Eigene Anweisung")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextEditor(text: $appState.dampfAblassenSettings.systemPrompt)
|
|
.font(.system(size: 11))
|
|
.frame(height: 80)
|
|
.scrollContentBackground(.hidden)
|
|
.padding(8)
|
|
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
|
|
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5))
|
|
.overlay(alignment: .topLeading) {
|
|
if appState.dampfAblassenSettings.systemPrompt.isEmpty {
|
|
Text("z.B. \"Formuliere den Text sachlich und freundlich um.\"")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.quaternary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 12)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Blitztext :)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: "Blitztext :)")
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Emoji-Dichte")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Picker("", selection: $appState.emojiTextSettings.emojiDensity) {
|
|
ForEach(EmojiTextSettings.EmojiDensity.allCases) { density in
|
|
Text(density.displayName).tag(density)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
|
|
// MARK: Eigennamen
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: "Eigennamen")
|
|
|
|
// Term chips
|
|
if !appState.textImprovementSettings.customTerms.isEmpty {
|
|
FlowLayout(spacing: 5) {
|
|
ForEach(appState.textImprovementSettings.customTerms, id: \.self) { term in
|
|
HStack(spacing: 3) {
|
|
Text(term)
|
|
.font(.system(size: 10.5))
|
|
Button {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
appState.textImprovementSettings.customTerms.removeAll { $0 == term }
|
|
}
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
.overlay(
|
|
Capsule()
|
|
.strokeBorder(Color.primary.opacity(0.04), lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 6) {
|
|
TextField("Neuer Begriff", text: $newTerm)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.system(size: 11))
|
|
.onSubmit { addTerm() }
|
|
|
|
Button { addTerm() } label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.blue.opacity(0.7))
|
|
}
|
|
.buttonStyle(SubtleButtonStyle())
|
|
.disabled(newTerm.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
}
|
|
|
|
}
|
|
.padding(16)
|
|
}
|
|
|
|
private func addTerm() {
|
|
let trimmed = newTerm.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty, !appState.textImprovementSettings.customTerms.contains(trimmed) else { return }
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
appState.textImprovementSettings.customTerms.append(trimmed)
|
|
}
|
|
newTerm = ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow Layout (for term tags)
|
|
|
|
struct FlowLayout: Layout {
|
|
var spacing: CGFloat = 6
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
|
|
return result.size
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
|
|
for (index, position) in result.positions.enumerated() {
|
|
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
|
}
|
|
}
|
|
|
|
private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
|
|
let maxWidth = proposal.width ?? .infinity
|
|
var positions: [CGPoint] = []
|
|
var x: CGFloat = 0
|
|
var y: CGFloat = 0
|
|
var rowHeight: CGFloat = 0
|
|
var maxX: CGFloat = 0
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if x + size.width > maxWidth && x > 0 {
|
|
x = 0
|
|
y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
positions.append(CGPoint(x: x, y: y))
|
|
rowHeight = max(rowHeight, size.height)
|
|
x += size.width + spacing
|
|
maxX = max(maxX, x)
|
|
}
|
|
|
|
return (positions, CGSize(width: maxX, height: y + rowHeight))
|
|
}
|
|
}
|