mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
922 lines
41 KiB
Swift
922 lines
41 KiB
Swift
import SwiftUI
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct NeonSettingsView: View {
|
|
private static var cachedEditorFonts: [String] = []
|
|
let supportsOpenInTabs: Bool
|
|
let supportsTranslucency: Bool
|
|
@EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system"
|
|
@AppStorage("SettingsEditorFontName") private var editorFontName: String = ""
|
|
@AppStorage("SettingsUseSystemFont") private var useSystemFont: Bool = false
|
|
@AppStorage("SettingsEditorFontSize") private var editorFontSize: Double = 14
|
|
@AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0
|
|
@AppStorage("SettingsAppearance") private var appearance: String = "system"
|
|
@AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = false
|
|
@AppStorage("SettingsReopenLastSession") private var reopenLastSession: Bool = true
|
|
@AppStorage("SettingsOpenWithBlankDocument") private var openWithBlankDocument: Bool = true
|
|
@AppStorage("SettingsDefaultNewFileLanguage") private var defaultNewFileLanguage: String = "plain"
|
|
@AppStorage("SettingsConfirmCloseDirtyTab") private var confirmCloseDirtyTab: Bool = true
|
|
@AppStorage("SettingsConfirmClearEditor") private var confirmClearEditor: Bool = true
|
|
|
|
@AppStorage("SettingsShowLineNumbers") private var showLineNumbers: Bool = true
|
|
@AppStorage("SettingsHighlightCurrentLine") private var highlightCurrentLine: Bool = false
|
|
@AppStorage("SettingsHighlightMatchingBrackets") private var highlightMatchingBrackets: Bool = false
|
|
@AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false
|
|
@AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false
|
|
@AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false
|
|
@AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces"
|
|
@AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4
|
|
@AppStorage("SettingsAutoIndent") private var autoIndent: Bool = true
|
|
@AppStorage("SettingsAutoCloseBrackets") private var autoCloseBrackets: Bool = false
|
|
@AppStorage("SettingsTrimTrailingWhitespace") private var trimTrailingWhitespace: Bool = false
|
|
@AppStorage("SettingsTrimWhitespaceForSyntaxDetection") private var trimWhitespaceForSyntaxDetection: Bool = false
|
|
|
|
@AppStorage("SettingsCompletionEnabled") private var completionEnabled: Bool = false
|
|
@AppStorage("SettingsCompletionFromDocument") private var completionFromDocument: Bool = false
|
|
@AppStorage("SettingsCompletionFromSyntax") private var completionFromSyntax: Bool = false
|
|
@AppStorage("SelectedAIModel") private var selectedAIModelRaw: String = AIModel.appleIntelligence.rawValue
|
|
@AppStorage("SettingsActiveTab") private var settingsActiveTab: String = "general"
|
|
@AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift"
|
|
#if os(macOS)
|
|
@State private var fontPicker = FontPickerController()
|
|
#endif
|
|
|
|
@State private var grokAPIToken: String = SecureTokenStore.token(for: .grok)
|
|
@State private var openAIAPIToken: String = SecureTokenStore.token(for: .openAI)
|
|
@State private var geminiAPIToken: String = SecureTokenStore.token(for: .gemini)
|
|
@State private var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic)
|
|
@State private var showSupportPurchaseDialog: Bool = false
|
|
@State private var availableEditorFonts: [String] = []
|
|
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md")
|
|
|
|
@AppStorage("SettingsThemeName") private var selectedTheme: String = "Neon Glow"
|
|
@AppStorage("SettingsThemeTextColor") private var themeTextHex: String = "#EDEDED"
|
|
@AppStorage("SettingsThemeBackgroundColor") private var themeBackgroundHex: String = "#0E1116"
|
|
@AppStorage("SettingsThemeCursorColor") private var themeCursorHex: String = "#4EA4FF"
|
|
@AppStorage("SettingsThemeSelectionColor") private var themeSelectionHex: String = "#2A3340"
|
|
@AppStorage("SettingsThemeKeywordColor") private var themeKeywordHex: String = "#F5D90A"
|
|
@AppStorage("SettingsThemeStringColor") private var themeStringHex: String = "#FF7AD9"
|
|
@AppStorage("SettingsThemeNumberColor") private var themeNumberHex: String = "#FFB86C"
|
|
@AppStorage("SettingsThemeCommentColor") private var themeCommentHex: String = "#7F8C98"
|
|
|
|
private var inputFieldBackground: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .windowBackgroundColor)
|
|
#else
|
|
Color(.secondarySystemBackground)
|
|
#endif
|
|
}
|
|
|
|
private let themes: [String] = [
|
|
"Neon Glow",
|
|
"Arc",
|
|
"Dusk",
|
|
"Aurora",
|
|
"Horizon",
|
|
"Midnight",
|
|
"Mono",
|
|
"Paper",
|
|
"Solar",
|
|
"Pulse",
|
|
"Mocha",
|
|
"Custom"
|
|
]
|
|
|
|
private let templateLanguages: [String] = [
|
|
"swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust",
|
|
"cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "css", "c", "cpp",
|
|
"csharp", "objective-c", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb",
|
|
"markdown", "bash", "zsh", "powershell", "standard", "plain"
|
|
]
|
|
|
|
private var isCompactSettingsLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
init(
|
|
supportsOpenInTabs: Bool = true,
|
|
supportsTranslucency: Bool = true
|
|
) {
|
|
self.supportsOpenInTabs = supportsOpenInTabs
|
|
self.supportsTranslucency = supportsTranslucency
|
|
}
|
|
|
|
var body: some View {
|
|
TabView(selection: $settingsActiveTab) {
|
|
generalTab
|
|
.tabItem { Label("General", systemImage: "gearshape") }
|
|
.tag("general")
|
|
editorTab
|
|
.tabItem { Label("Editor", systemImage: "slider.horizontal.3") }
|
|
.tag("editor")
|
|
templateTab
|
|
.tabItem { Label("Templates", systemImage: "doc.badge.plus") }
|
|
.tag("templates")
|
|
themeTab
|
|
.tabItem { Label("Themes", systemImage: "paintpalette") }
|
|
.tag("themes")
|
|
aiTab
|
|
.tabItem { Label("AI", systemImage: "brain.head.profile") }
|
|
.tag("ai")
|
|
supportTab
|
|
.tabItem { Label("Support", systemImage: "heart") }
|
|
.tag("support")
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 860, minHeight: 620)
|
|
#endif
|
|
.preferredColorScheme(preferredColorSchemeOverride)
|
|
.onAppear {
|
|
settingsActiveTab = "general"
|
|
loadAvailableEditorFontsIfNeeded()
|
|
if supportPurchaseManager.supportProduct == nil {
|
|
Task { await supportPurchaseManager.refreshStoreState() }
|
|
}
|
|
#if os(macOS)
|
|
fontPicker.onChange = { selected in
|
|
useSystemFont = false
|
|
editorFontName = selected.fontName
|
|
editorFontSize = Double(selected.pointSize)
|
|
}
|
|
applyAppearanceImmediately()
|
|
#endif
|
|
}
|
|
.onChange(of: appearance) { _, _ in
|
|
#if os(macOS)
|
|
applyAppearanceImmediately()
|
|
#endif
|
|
}
|
|
.onChange(of: showScopeGuides) { _, enabled in
|
|
if enabled && lineWrapEnabled {
|
|
lineWrapEnabled = false
|
|
}
|
|
}
|
|
.onChange(of: highlightScopeBackground) { _, enabled in
|
|
if enabled && lineWrapEnabled {
|
|
lineWrapEnabled = false
|
|
}
|
|
}
|
|
.onChange(of: lineWrapEnabled) { _, enabled in
|
|
if enabled {
|
|
showScopeGuides = false
|
|
highlightScopeBackground = false
|
|
}
|
|
}
|
|
.confirmationDialog("Support Neon Vision Editor", isPresented: $showSupportPurchaseDialog, titleVisibility: .visible) {
|
|
Button("Support \(supportPurchaseManager.supportPriceLabel)") {
|
|
Task { await supportPurchaseManager.purchaseSupport() }
|
|
}
|
|
Button("Restore Purchases") {
|
|
Task { await supportPurchaseManager.restorePurchases() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Optional one-time purchase to support development. No features are locked behind this purchase.")
|
|
}
|
|
.alert(
|
|
"App Store",
|
|
isPresented: Binding(
|
|
get: { supportPurchaseManager.statusMessage != nil },
|
|
set: { if !$0 { supportPurchaseManager.statusMessage = nil } }
|
|
)
|
|
) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(supportPurchaseManager.statusMessage ?? "")
|
|
}
|
|
}
|
|
|
|
private var preferredColorSchemeOverride: ColorScheme? {
|
|
ReleaseRuntimePolicy.preferredColorScheme(for: appearance)
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func applyAppearanceImmediately() {
|
|
let target: NSAppearance?
|
|
switch appearance {
|
|
case "light":
|
|
target = NSAppearance(named: .aqua)
|
|
case "dark":
|
|
target = NSAppearance(named: .darkAqua)
|
|
default:
|
|
target = nil
|
|
}
|
|
NSApp.appearance = target
|
|
for window in NSApp.windows {
|
|
window.appearance = target
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private var generalTab: some View {
|
|
settingsContainer {
|
|
GroupBox("Window") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if supportsOpenInTabs {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Open in Tabs")
|
|
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
|
Picker("", selection: $openInTabs) {
|
|
Text("Follow System").tag("system")
|
|
Text("Always").tag("always")
|
|
Text("Never").tag("never")
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
}
|
|
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Appearance")
|
|
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
|
Picker("", selection: $appearance) {
|
|
Text("System").tag("system")
|
|
Text("Light").tag("light")
|
|
Text("Dark").tag("dark")
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
if supportsTranslucency {
|
|
Toggle("Translucent Window", isOn: $translucentWindow)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
GroupBox("Editor Font") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Toggle("Use System Font", isOn: $useSystemFont)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Font")
|
|
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
|
Picker("", selection: selectedFontBinding) {
|
|
Text("System").tag(systemFontSentinel)
|
|
ForEach(availableEditorFonts, id: \.self) { fontName in
|
|
Text(fontName).tag(fontName)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.background(inputFieldBackground)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.35), lineWidth: 1)
|
|
)
|
|
.cornerRadius(6)
|
|
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading)
|
|
.onChange(of: selectedFontValue) { _, _ in
|
|
useSystemFont = (selectedFontValue == systemFontSentinel)
|
|
if !useSystemFont && !selectedFontValue.isEmpty {
|
|
editorFontName = selectedFontValue
|
|
}
|
|
}
|
|
.onChange(of: useSystemFont) { _, isSystem in
|
|
if isSystem {
|
|
selectedFontValue = systemFontSentinel
|
|
} else if !editorFontName.isEmpty {
|
|
selectedFontValue = editorFontName
|
|
}
|
|
}
|
|
.onChange(of: editorFontName) { _, newValue in
|
|
guard !useSystemFont else { return }
|
|
if !newValue.isEmpty {
|
|
selectedFontValue = newValue
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
Button("Choose…") {
|
|
useSystemFont = false
|
|
fontPicker.open(currentName: editorFontName, size: editorFontSize)
|
|
}
|
|
.disabled(useSystemFont)
|
|
#endif
|
|
}
|
|
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Font Size")
|
|
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
|
Stepper(value: $editorFontSize, in: 10...28, step: 1) {
|
|
Text("\(Int(editorFontSize)) pt")
|
|
}
|
|
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 220, alignment: .leading)
|
|
}
|
|
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Line Height")
|
|
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
|
Slider(value: $lineHeight, in: 1.0...1.8, step: 0.05)
|
|
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 240)
|
|
Text(String(format: "%.2fx", lineHeight))
|
|
.frame(width: 54, alignment: .trailing)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
GroupBox("Startup") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Toggle("Open with Blank Document", isOn: $openWithBlankDocument)
|
|
Toggle("Reopen Last Session", isOn: $reopenLastSession)
|
|
.disabled(openWithBlankDocument)
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Default New File Language")
|
|
.frame(width: isCompactSettingsLayout ? nil : 180, alignment: .leading)
|
|
Picker("", selection: $defaultNewFileLanguage) {
|
|
ForEach(templateLanguages, id: \.self) { lang in
|
|
Text(languageLabel(for: lang)).tag(lang)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
GroupBox("Confirmations") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Toggle("Confirm Before Closing Dirty Tab", isOn: $confirmCloseDirtyTab)
|
|
Toggle("Confirm Before Clearing Editor", isOn: $confirmClearEditor)
|
|
}
|
|
.padding(12)
|
|
}
|
|
}
|
|
}
|
|
|
|
private let systemFontSentinel = "__system__"
|
|
@State private var selectedFontValue: String = "__system__"
|
|
|
|
private var selectedFontBinding: Binding<String> {
|
|
Binding(
|
|
get: {
|
|
if useSystemFont { return systemFontSentinel }
|
|
if editorFontName.isEmpty { return systemFontSentinel }
|
|
return editorFontName
|
|
},
|
|
set: { selectedFontValue = $0 }
|
|
)
|
|
}
|
|
|
|
private func loadAvailableEditorFontsIfNeeded() {
|
|
if !availableEditorFonts.isEmpty {
|
|
selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName)
|
|
return
|
|
}
|
|
if !Self.cachedEditorFonts.isEmpty {
|
|
availableEditorFonts = Self.cachedEditorFonts
|
|
selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName)
|
|
return
|
|
}
|
|
// Defer font discovery until after the initial settings view appears.
|
|
DispatchQueue.main.async {
|
|
populateEditorFonts()
|
|
}
|
|
}
|
|
|
|
private func populateEditorFonts() {
|
|
#if os(macOS)
|
|
let names = NSFontManager.shared.availableFonts
|
|
#else
|
|
let names = UIFont.familyNames
|
|
.sorted()
|
|
.flatMap { UIFont.fontNames(forFamilyName: $0) }
|
|
#endif
|
|
var merged = Array(Set(names)).sorted()
|
|
if !editorFontName.isEmpty && !merged.contains(editorFontName) {
|
|
merged.insert(editorFontName, at: 0)
|
|
}
|
|
Self.cachedEditorFonts = merged
|
|
availableEditorFonts = merged
|
|
selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName)
|
|
}
|
|
|
|
private var editorTab: some View {
|
|
settingsContainer(maxWidth: 760) {
|
|
GroupBox("Editor") {
|
|
VStack(spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Display")
|
|
.font(.headline)
|
|
Toggle("Show Line Numbers", isOn: $showLineNumbers)
|
|
Toggle("Highlight Current Line", isOn: $highlightCurrentLine)
|
|
Toggle("Highlight Matching Brackets", isOn: $highlightMatchingBrackets)
|
|
Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides)
|
|
Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground)
|
|
Toggle("Line Wrap", isOn: $lineWrapEnabled)
|
|
Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Text("Invisible character markers are disabled to avoid whitespace glyph artifacts.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Indentation")
|
|
.font(.headline)
|
|
Picker("Indent Style", selection: $indentStyle) {
|
|
Text("Spaces").tag("spaces")
|
|
Text("Tabs").tag("tabs")
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Stepper(value: $indentWidth, in: 2...8, step: 1) {
|
|
Text("Indent Width: \(indentWidth)")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Editing")
|
|
.font(.headline)
|
|
Toggle("Auto Indent", isOn: $autoIndent)
|
|
Toggle("Auto Close Brackets", isOn: $autoCloseBrackets)
|
|
Toggle("Trim Trailing Whitespace", isOn: $trimTrailingWhitespace)
|
|
Toggle("Trim Edges for Syntax Detection", isOn: $trimWhitespaceForSyntaxDetection)
|
|
}
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Completion")
|
|
.font(.headline)
|
|
Toggle("Enable Completion", isOn: $completionEnabled)
|
|
Toggle("Include Words in Document", isOn: $completionFromDocument)
|
|
Toggle("Include Syntax Keywords", isOn: $completionFromSyntax)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var templateTab: some View {
|
|
settingsContainer(maxWidth: 640) {
|
|
GroupBox("Completion Template") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Text("Language")
|
|
.frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading)
|
|
Picker("", selection: $settingsTemplateLanguage) {
|
|
ForEach(templateLanguages, id: \.self) { lang in
|
|
Text(languageLabel(for: lang)).tag(lang)
|
|
}
|
|
}
|
|
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 220, alignment: .leading)
|
|
.pickerStyle(.menu)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.background(Color.clear)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.35), lineWidth: 1)
|
|
)
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
TextEditor(text: templateBinding(for: settingsTemplateLanguage))
|
|
.font(.system(size: 13, weight: .regular, design: .monospaced))
|
|
.frame(minHeight: 200, maxHeight: 320)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.clear)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
|
)
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Reset to Default") {
|
|
UserDefaults.standard.removeObject(forKey: templateOverrideKey(for: settingsTemplateLanguage))
|
|
}
|
|
Button("Use Default Template") {
|
|
if let fallback = defaultTemplate(for: settingsTemplateLanguage) {
|
|
UserDefaults.standard.set(fallback, forKey: templateOverrideKey(for: settingsTemplateLanguage))
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(12)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var themeTab: some View {
|
|
let isCustom = selectedTheme == "Custom"
|
|
let palette = themePaletteColors(for: selectedTheme)
|
|
return settingsContainer(maxWidth: 760) {
|
|
HStack(spacing: 16) {
|
|
#if os(macOS)
|
|
let listView = List(themes, id: \.self, selection: $selectedTheme) { theme in
|
|
Text(theme)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
.frame(minWidth: 200)
|
|
.listStyle(.plain)
|
|
.background(Color.clear)
|
|
if #available(macOS 13.0, *) {
|
|
listView.scrollContentBackground(.hidden)
|
|
} else {
|
|
listView
|
|
}
|
|
#else
|
|
let listView = List {
|
|
ForEach(themes, id: \.self) { theme in
|
|
HStack {
|
|
Text(theme)
|
|
Spacer()
|
|
if theme == selectedTheme {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
selectedTheme = theme
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
}
|
|
.frame(minWidth: isCompactSettingsLayout ? nil : 200)
|
|
.listStyle(.plain)
|
|
.background(Color.clear)
|
|
if #available(iOS 16.0, *) {
|
|
listView.scrollContentBackground(.hidden)
|
|
} else {
|
|
listView
|
|
}
|
|
#endif
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Theme Colors")
|
|
.font(.headline)
|
|
Spacer(minLength: 6)
|
|
|
|
colorRow(title: "Text", color: isCustom ? hexBinding($themeTextHex, fallback: .white) : .constant(palette.text))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Background", color: isCustom ? hexBinding($themeBackgroundHex, fallback: .black) : .constant(palette.background))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Cursor", color: isCustom ? hexBinding($themeCursorHex, fallback: .blue) : .constant(palette.cursor))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Selection", color: isCustom ? hexBinding($themeSelectionHex, fallback: .gray) : .constant(palette.selection))
|
|
.disabled(!isCustom)
|
|
|
|
Divider()
|
|
|
|
Text("Syntax")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
colorRow(title: "Keywords", color: isCustom ? hexBinding($themeKeywordHex, fallback: .yellow) : .constant(palette.keyword))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Strings", color: isCustom ? hexBinding($themeStringHex, fallback: .pink) : .constant(palette.string))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Numbers", color: isCustom ? hexBinding($themeNumberHex, fallback: .orange) : .constant(palette.number))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Comments", color: isCustom ? hexBinding($themeCommentHex, fallback: .gray) : .constant(palette.comment))
|
|
.disabled(!isCustom)
|
|
colorRow(title: "Types", color: .constant(palette.type))
|
|
.disabled(true)
|
|
colorRow(title: "Builtins", color: .constant(palette.builtin))
|
|
.disabled(true)
|
|
|
|
Spacer()
|
|
Text(isCustom ? "Custom theme applies immediately." : "Select Custom to edit colors.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
#if os(iOS)
|
|
.padding(.top, 20)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private var aiTab: some View {
|
|
settingsContainer(maxWidth: 520) {
|
|
GroupBox("AI Model") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Picker("Model", selection: selectedAIModelBinding) {
|
|
Text("Apple Intelligence").tag(AIModel.appleIntelligence)
|
|
Text("Grok").tag(AIModel.grok)
|
|
Text("OpenAI").tag(AIModel.openAI)
|
|
Text("Gemini").tag(AIModel.gemini)
|
|
Text("Anthropic").tag(AIModel.anthropic)
|
|
}
|
|
.pickerStyle(.menu)
|
|
|
|
Text("Choose the default model used by editor AI actions.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(12)
|
|
}
|
|
.frame(maxWidth: 420)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
|
|
GroupBox("AI Provider API Keys") {
|
|
VStack(alignment: .center, spacing: 12) {
|
|
aiKeyRow(title: "Grok", placeholder: "sk-…", value: $grokAPIToken, provider: .grok)
|
|
aiKeyRow(title: "OpenAI", placeholder: "sk-…", value: $openAIAPIToken, provider: .openAI)
|
|
aiKeyRow(title: "Gemini", placeholder: "AIza…", value: $geminiAPIToken, provider: .gemini)
|
|
aiKeyRow(title: "Anthropic", placeholder: "sk-ant-…", value: $anthropicAPIToken, provider: .anthropic)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(12)
|
|
}
|
|
.frame(maxWidth: 420)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
|
|
private var selectedAIModelBinding: Binding<AIModel> {
|
|
Binding(
|
|
get: { AIModel(rawValue: selectedAIModelRaw) ?? .appleIntelligence },
|
|
set: { selectedAIModelRaw = $0.rawValue }
|
|
)
|
|
}
|
|
|
|
private var supportTab: some View {
|
|
settingsContainer(maxWidth: 520) {
|
|
GroupBox("Support Development") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("In-App Purchase is optional and only used to support the app.")
|
|
.foregroundStyle(.secondary)
|
|
Text("One-time, non-consumable purchase. No subscription and no auto-renewal.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
if supportPurchaseManager.canUseInAppPurchases {
|
|
Text("Price: \(supportPurchaseManager.supportPriceLabel)")
|
|
.font(.headline)
|
|
if supportPurchaseManager.hasSupported {
|
|
Label("Thank you for your support.", systemImage: "checkmark.seal.fill")
|
|
.foregroundStyle(.green)
|
|
}
|
|
HStack(spacing: 12) {
|
|
Button(supportPurchaseManager.isPurchasing ? "Purchasing…" : "Support the App") {
|
|
showSupportPurchaseDialog = true
|
|
}
|
|
.disabled(supportPurchaseManager.isPurchasing || supportPurchaseManager.isLoadingProducts)
|
|
|
|
Button("Restore Purchases") {
|
|
Task { await supportPurchaseManager.restorePurchases() }
|
|
}
|
|
.disabled(supportPurchaseManager.isLoadingProducts)
|
|
|
|
Button("Refresh Price") {
|
|
Task { await supportPurchaseManager.refreshProducts() }
|
|
}
|
|
.disabled(supportPurchaseManager.isLoadingProducts)
|
|
}
|
|
} else {
|
|
Text("Direct notarized builds are unaffected: all editor features stay fully available without any purchase.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Text("Support purchase is available only in App Store/TestFlight builds.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if let privacyPolicyURL {
|
|
Link("Privacy Policy", destination: privacyPolicyURL)
|
|
.font(.footnote.weight(.semibold))
|
|
}
|
|
|
|
if supportPurchaseManager.canBypassInCurrentBuild {
|
|
Divider()
|
|
Text("TestFlight/Sandbox: You can bypass purchase for testing.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
HStack(spacing: 12) {
|
|
Button("Bypass Purchase (Testing)") {
|
|
supportPurchaseManager.bypassForTesting()
|
|
}
|
|
Button("Clear Bypass") {
|
|
supportPurchaseManager.clearBypassForTesting()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func settingsContainer<Content: View>(maxWidth: CGFloat = 560, @ViewBuilder _ content: () -> Content) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: isCompactSettingsLayout ? .leading : .center, spacing: 20) {
|
|
content()
|
|
}
|
|
.frame(maxWidth: maxWidth, alignment: .center)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isCompactSettingsLayout ? .topLeading : .top)
|
|
.padding(.top, 16)
|
|
.padding(.horizontal, isCompactSettingsLayout ? 12 : 24)
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
|
|
private func colorRow(title: String, color: Binding<Color>) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
.frame(width: isCompactSettingsLayout ? nil : 120, alignment: .leading)
|
|
ColorPicker("", selection: color)
|
|
.labelsHidden()
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private func aiKeyRow(title: String, placeholder: String, value: Binding<String>, provider: APITokenKey) -> some View {
|
|
Group {
|
|
if isCompactSettingsLayout {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
SecureField(placeholder, text: value)
|
|
.textFieldStyle(.plain)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.background(inputFieldBackground)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.35), lineWidth: 1)
|
|
)
|
|
.cornerRadius(6)
|
|
.onChange(of: value.wrappedValue) { _, new in
|
|
SecureTokenStore.setToken(new, for: provider)
|
|
}
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
Text(title)
|
|
.frame(width: 120, alignment: .leading)
|
|
SecureField(placeholder, text: value)
|
|
.textFieldStyle(.plain)
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.background(inputFieldBackground)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.35), lineWidth: 1)
|
|
)
|
|
.cornerRadius(6)
|
|
.frame(width: 200)
|
|
.onChange(of: value.wrappedValue) { _, new in
|
|
SecureTokenStore.setToken(new, for: provider)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: isCompactSettingsLayout ? .leading : .center)
|
|
}
|
|
|
|
private func languageLabel(for lang: String) -> String {
|
|
switch lang {
|
|
case "php": return "PHP"
|
|
case "cobol": return "COBOL"
|
|
case "dotenv": return "Dotenv"
|
|
case "proto": return "Proto"
|
|
case "graphql": return "GraphQL"
|
|
case "rst": return "reStructuredText"
|
|
case "nginx": return "Nginx"
|
|
case "objective-c": return "Objective-C"
|
|
case "csharp": return "C#"
|
|
case "c": return "C"
|
|
case "cpp": return "C++"
|
|
case "json": return "JSON"
|
|
case "xml": return "XML"
|
|
case "yaml": return "YAML"
|
|
case "toml": return "TOML"
|
|
case "csv": return "CSV"
|
|
case "ini": return "INI"
|
|
case "sql": return "SQL"
|
|
case "vim": return "Vim"
|
|
case "log": return "Log"
|
|
case "ipynb": return "Jupyter Notebook"
|
|
case "html": return "HTML"
|
|
case "css": return "CSS"
|
|
case "standard": return "Standard"
|
|
default: return lang.capitalized
|
|
}
|
|
}
|
|
|
|
private func templateOverrideKey(for language: String) -> String {
|
|
"TemplateOverride_\(language)"
|
|
}
|
|
|
|
private func templateBinding(for language: String) -> Binding<String> {
|
|
Binding<String>(
|
|
get: { UserDefaults.standard.string(forKey: templateOverrideKey(for: language)) ?? defaultTemplate(for: language) ?? "" },
|
|
set: { newValue in UserDefaults.standard.set(newValue, forKey: templateOverrideKey(for: language)) }
|
|
)
|
|
}
|
|
|
|
private func defaultTemplate(for language: String) -> String? {
|
|
switch language {
|
|
case "swift":
|
|
return "import Foundation\n\n// TODO: Add code here\n"
|
|
case "python":
|
|
return "def main():\n pass\n\n\nif __name__ == \"__main__\":\n main()\n"
|
|
case "javascript":
|
|
return "\"use strict\";\n\nfunction main() {\n // TODO: Add code here\n}\n\nmain();\n"
|
|
case "typescript":
|
|
return "function main(): void {\n // TODO: Add code here\n}\n\nmain();\n"
|
|
case "java":
|
|
return "public class Main {\n public static void main(String[] args) {\n // TODO: Add code here\n }\n}\n"
|
|
case "kotlin":
|
|
return "fun main() {\n // TODO: Add code here\n}\n"
|
|
case "go":
|
|
return "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Println(\"Hello\")\n}\n"
|
|
case "ruby":
|
|
return "def main\n # TODO: Add code here\nend\n\nmain\n"
|
|
case "rust":
|
|
return "fn main() {\n println!(\"Hello\");\n}\n"
|
|
case "c":
|
|
return "#include <stdio.h>\n\nint main(void) {\n printf(\"Hello\\n\");\n return 0;\n}\n"
|
|
case "cpp":
|
|
return "#include <iostream>\n\nint main() {\n std::cout << \"Hello\" << std::endl;\n return 0;\n}\n"
|
|
case "csharp":
|
|
return "using System;\n\nclass Program {\n static void Main() {\n Console.WriteLine(\"Hello\");\n }\n}\n"
|
|
case "objective-c":
|
|
return "#import <Foundation/Foundation.h>\n\nint main(int argc, const char * argv[]) {\n @autoreleasepool {\n NSLog(@\"Hello\");\n }\n return 0;\n}\n"
|
|
case "php":
|
|
return "<?php\n\nfunction main() {\n // TODO: Add code here\n}\n\nmain();\n"
|
|
case "html":
|
|
return "<!doctype html>\n<html>\n<head>\n <meta charset=\"utf-8\" />\n <title>Document</title>\n</head>\n<body>\n\n</body>\n</html>\n"
|
|
case "css":
|
|
return "body {\n margin: 0;\n font-family: system-ui, sans-serif;\n}\n"
|
|
case "json":
|
|
return "{\n \"key\": \"value\"\n}\n"
|
|
case "yaml":
|
|
return "key: value\n"
|
|
case "toml":
|
|
return "key = \"value\"\n"
|
|
case "sql":
|
|
return "SELECT *\nFROM table_name;\n"
|
|
case "bash", "zsh":
|
|
return "#!/usr/bin/env \(language)\n\n"
|
|
case "markdown":
|
|
return "# Title\n\n"
|
|
case "plain":
|
|
return ""
|
|
default:
|
|
return "TODO\n"
|
|
}
|
|
}
|
|
|
|
private func hexBinding(_ hex: Binding<String>, fallback: Color) -> Binding<Color> {
|
|
Binding<Color>(
|
|
get: { colorFromHex(hex.wrappedValue, fallback: fallback) },
|
|
set: { newColor in hex.wrappedValue = colorToHex(newColor) }
|
|
)
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
final class FontPickerController: NSObject, NSFontChanging {
|
|
private var currentFont: NSFont = NSFont.monospacedSystemFont(ofSize: 14, weight: .regular)
|
|
var onChange: ((NSFont) -> Void)?
|
|
|
|
func open(currentName: String, size: Double) {
|
|
let base = NSFont(name: currentName, size: CGFloat(size)) ?? NSFont.monospacedSystemFont(ofSize: CGFloat(size), weight: .regular)
|
|
currentFont = base
|
|
let manager = NSFontManager.shared
|
|
manager.target = self
|
|
manager.action = #selector(changeFont(_:))
|
|
manager.setSelectedFont(base, isMultiple: false)
|
|
NSFontPanel.shared.orderFront(nil)
|
|
}
|
|
|
|
@objc func changeFont(_ sender: NSFontManager?) {
|
|
let manager = sender ?? NSFontManager.shared
|
|
let converted = manager.convert(currentFont)
|
|
currentFont = converted
|
|
onChange?(converted)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if DEBUG && canImport(SwiftUI) && canImport(PreviewsMacros)
|
|
#Preview {
|
|
NeonSettingsView(
|
|
supportsOpenInTabs: true,
|
|
supportsTranslucency: true
|
|
)
|
|
}
|
|
#endif
|