blitztext-app-linux/BlitztextMac/Features/Settings/SettingsContentView.swift
2026-05-23 07:28:46 +02:00

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