diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 0bdd424..24a57ac 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 266; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -402,7 +402,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.4.26; + MARKETING_VERSION = 0.4.25; PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -439,7 +439,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 270; + CURRENT_PROJECT_VERSION = 266; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -483,7 +483,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.4.26; + MARKETING_VERSION = 0.4.25; PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index b511ff7..ae62569 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -265,6 +265,7 @@ struct NeonVisionEditorApp: App { .onChange(of: openInTabs) { _, _ in applyOpenInTabsPreference() } .environment(\.showGrokError, $showGrokError) .environment(\.grokErrorMessage, $grokErrorMessage) + .tint(.blue) .preferredColorScheme(preferredAppearance) .frame(minWidth: 600, minHeight: 400) .task { @@ -301,6 +302,7 @@ struct NeonVisionEditorApp: App { .onAppear { applyOpenInTabsPreference() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } .onChange(of: openInTabs) { _, _ in applyOpenInTabsPreference() } + .tint(.blue) .preferredColorScheme(preferredAppearance) } .defaultSize(width: 1000, height: 600) @@ -314,6 +316,7 @@ struct NeonVisionEditorApp: App { .onAppear { applyOpenInTabsPreference() } .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } .onChange(of: openInTabs) { _, _ in applyOpenInTabsPreference() } + .tint(.blue) .preferredColorScheme(preferredAppearance) } diff --git a/Neon Vision Editor/Core/AppUpdateManager.swift b/Neon Vision Editor/Core/AppUpdateManager.swift index 0939477..4995505 100644 --- a/Neon Vision Editor/Core/AppUpdateManager.swift +++ b/Neon Vision Editor/Core/AppUpdateManager.swift @@ -350,19 +350,15 @@ final class AppUpdateManager: ObservableObject { } func installUpdateNow() async { - guard ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution else { - installMessage = "Updater is disabled for this distribution channel." - return - } - guard !Self.isDevelopmentRuntime else { - installMessage = "Install now is disabled while running from Xcode/DerivedData. Use a direct-distribution app build." + if let reason = installNowDisabledReason { + installMessage = reason return } await attemptAutoInstall(interactive: true) } var installNowSupported: Bool { - ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution && !Self.isDevelopmentRuntime + installNowDisabledReason == nil } var installNowDisabledReason: String? { @@ -372,6 +368,20 @@ final class AppUpdateManager: ObservableObject { guard !Self.isDevelopmentRuntime else { return "Install is unavailable in Xcode/DerivedData runs." } +#if os(macOS) + guard let release = latestRelease else { + return "No update metadata loaded yet." + } + guard release.downloadURL != nil, release.assetName != nil else { + return "This release does not provide a supported ZIP asset for automatic install." + } + let destinationDir = Bundle.main.bundleURL + .standardizedFileURL + .deletingLastPathComponent() + guard FileManager.default.isWritableFile(atPath: destinationDir.path) else { + return "No permission to write to \(destinationDir.path). Move the app to a writable location." + } +#endif return nil } diff --git a/Neon Vision Editor/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Neon Vision Editor/Resources/Assets.xcassets/AccentColor.colorset/Contents.json index 27292a6..77aec8c 100644 --- a/Neon Vision Editor/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Neon Vision Editor/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -6,7 +6,7 @@ "components" : { "alpha" : "1.000", "blue" : "1.000", - "green" : "0.486", + "green" : "0.478", "red" : "0.000" } }, @@ -25,7 +25,7 @@ "alpha" : "1.000", "blue" : "1.000", "green" : "0.620", - "red" : "0.220" + "red" : "0.000" } }, "idiom" : "universal" diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index aab9f0a..0e0a803 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -229,10 +229,10 @@ extension ContentView { Button(action: { toggleAutoCompletion() }) { - Image(systemName: "text.badge.plus") + Image(systemName: "bolt.horizontal.circle") .symbolVariant(isAutoCompletionEnabled ? .fill : .none) } - .help("Code Completion") + .help(isAutoCompletionEnabled ? "Disable Code Completion" : "Enable Code Completion") .accessibilityLabel("Code Completion") } @@ -634,10 +634,10 @@ extension ContentView { Button(action: { toggleAutoCompletion() }) { - Image(systemName: "text.badge.plus") + Image(systemName: "bolt.horizontal.circle") .symbolVariant(isAutoCompletionEnabled ? .fill : .none) } - .help("Code Completion") + .help(isAutoCompletionEnabled ? "Disable Code Completion" : "Enable Code Completion") .accessibilityLabel("Code Completion") Button(action: { diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index cfc8217..d5f8949 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -32,12 +32,6 @@ extension String { //Manages the editor area, toolbar, popovers, and bridges to the view model for file I/O and metrics. struct ContentView: View { private static let completionSignposter = OSSignposter(subsystem: "h3p.Neon-Vision-Editor", category: "InlineCompletion") - private enum PerformanceTier: Int { - case normal = 0 - case light = 1 - case strong = 2 - case maximum = 3 - } private struct CompletionCacheEntry { let suggestion: String @@ -49,11 +43,8 @@ struct ContentView: View { @EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager @EnvironmentObject var appUpdateManager: AppUpdateManager @Environment(\.colorScheme) var colorScheme - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency #if os(iOS) @Environment(\.horizontalSizeClass) var horizontalSizeClass - @SceneStorage("SceneSidebarVisible") private var sceneSidebarVisible: Bool = false - @SceneStorage("SceneProjectSidebarVisible") private var sceneProjectSidebarVisible: Bool = false #endif #if os(macOS) @Environment(\.openWindow) var openWindow @@ -76,11 +67,6 @@ struct ContentView: View { @AppStorage("SettingsShowScopeGuides") var showScopeGuides: Bool = false @AppStorage("SettingsHighlightScopeBackground") var highlightScopeBackground: Bool = false @AppStorage("SettingsLineWrapEnabled") var settingsLineWrapEnabled: Bool = false - @AppStorage("SettingsLiquidGlassEnabled") var liquidGlassEnabled: Bool = true - @AppStorage("SettingsForceLargeFileMode") var forceLargeFileMode: Bool = false - @AppStorage("SettingsShowBottomActionBarIOS") var showBottomActionBarIOS: Bool = false - @AppStorage("SettingsShowKeyboardAccessoryBarIOS") var showKeyboardAccessoryBarIOS: Bool = false - @AppStorage("SettingsEnableDiagnostics") var diagnosticsEnabled: Bool = false // Removed showHorizontalRuler and showVerticalRuler AppStorage properties @AppStorage("SettingsIndentStyle") var indentStyle: String = "spaces" @AppStorage("SettingsIndentWidth") var indentWidth: Int = 4 @@ -106,16 +92,14 @@ struct ContentView: View { @State var geminiAPIToken: String = "" @State var anthropicAPIToken: String = "" - // Debounce handle for inline completion - @State var lastCompletionWorkItem: DispatchWorkItem? + // Debounce/cancellation handles for inline completion + @State private var completionDebounceTask: Task? @State private var completionTask: Task? - @State private var inFlightCompletionKey: String? + @State private var lastCompletionTriggerSignature: String = "" @State private var isApplyingCompletion: Bool = false @State private var completionCache: [String: CompletionCacheEntry] = [:] @State private var pendingHighlightRefresh: DispatchWorkItem? @AppStorage("EnableTranslucentWindow") var enableTranslucentWindow: Bool = false - @AppStorage("SettingsTranslucencyStrength") private var translucencyStrength: String = "medium" - @State private var isLowPowerModeEnabled: Bool = ProcessInfo.processInfo.isLowPowerModeEnabled @State var showFindReplace: Bool = false @State var showSettingsSheet: Bool = false @@ -150,17 +134,11 @@ struct ContentView: View { @State var droppedFileLoadProgress: Double = 0 @State var droppedFileLoadLabel: String = "" @State var largeFileModeEnabled: Bool = false - @State private var performanceTier: PerformanceTier = .normal - @State private var lastHighlightRefreshAt: Date = .distantPast - @State var projectTreeRefreshTask: Task? = nil - @State var projectTreeRefreshGeneration: Int = 0 @AppStorage("HasSeenWelcomeTourV1") var hasSeenWelcomeTourV1: Bool = false @AppStorage("WelcomeTourSeenRelease") var welcomeTourSeenRelease: String = "" @State var showWelcomeTour: Bool = false #if os(macOS) @State private var hostWindowNumber: Int? = nil -#else - @State private var iosToolbarCompactness: CGFloat = 0 #endif @State private var showLanguageSetupPrompt: Bool = false @State private var languagePromptSelection: String = "plain" @@ -176,134 +154,6 @@ struct ContentView: View { var activeProviderName: String { lastProviderUsed } - var shouldUseLiquidGlass: Bool { -#if os(iOS) - liquidGlassEnabled && enableTranslucentWindow && !reduceTransparency && !isLowPowerModeEnabled -#else - liquidGlassEnabled && enableTranslucentWindow && !reduceTransparency -#endif - } - - var primaryGlassMaterial: Material { - colorScheme == .dark ? .thinMaterial : .ultraThinMaterial - } - - var chromeFallbackColor: Color { -#if os(iOS) - Color(.secondarySystemBackground).opacity(0.92) -#else - Color.clear -#endif - } - - var toolbarFallbackColor: Color { -#if os(iOS) - let isRegularWidth = horizontalSizeClass == .regular - if isRegularWidth && colorScheme == .light { - // Slightly cooler fallback for better contrast on bright iPad backgrounds. - return Color(red: 0.84, green: 0.90, blue: 0.98).opacity(0.96) - } - return chromeFallbackColor -#else - return chromeFallbackColor -#endif - } - - var toolbarDensityScale: CGFloat { -#if os(iOS) - 1.0 - (iosToolbarCompactness * 0.06) -#else - 1.0 -#endif - } - - var toolbarDensityOpacity: Double { -#if os(iOS) - 1.0 - Double(iosToolbarCompactness * 0.16) -#else - 1.0 -#endif - } - -#if os(macOS) - private enum MacTranslucencySurface { - case editor - case canvas - case toolbar - case tabStrip - } - - private var normalizedTranslucencyStrength: String { - switch translucencyStrength.lowercased() { - case "light", "strong": - return translucencyStrength.lowercased() - default: - return "medium" - } - } - - private func macTranslucencyOpacity(for surface: MacTranslucencySurface, colorScheme: ColorScheme) -> Double { - let isDark = colorScheme == .dark - switch normalizedTranslucencyStrength { - case "light": - switch surface { - case .editor: return isDark ? 0.84 : 0.90 - case .canvas: return isDark ? 0.80 : 0.88 - case .toolbar: return isDark ? 0.64 : 0.80 - case .tabStrip: return isDark ? 0.76 : 0.86 - } - case "strong": - switch surface { - case .editor: return isDark ? 0.70 : 0.80 - case .canvas: return isDark ? 0.64 : 0.76 - case .toolbar: return isDark ? 0.46 : 0.66 - case .tabStrip: return isDark ? 0.58 : 0.72 - } - default: - switch surface { - case .editor: return isDark ? 0.78 : 0.86 - case .canvas: return isDark ? 0.74 : 0.84 - case .toolbar: return isDark ? 0.56 : 0.74 - case .tabStrip: return isDark ? 0.68 : 0.80 - } - } - } -#endif - - private enum SecondaryGlassLayer { - case none - case bottomBar - case statusBar - case brainDumpCanvas - case progressOverlay - } - - private var secondaryGlassLayer: SecondaryGlassLayer { - guard shouldUseLiquidGlass else { return .none } - if viewModel.isBrainDumpMode { return .brainDumpCanvas } -#if os(iOS) - if showBottomActionBarIOS { return .bottomBar } -#endif - if droppedFileLoadInProgress { return .progressOverlay } - return .statusBar - } - - var shouldUseStatusGlassLayer: Bool { - secondaryGlassLayer == .statusBar - } - - var shouldUseBottomBarGlassLayer: Bool { - secondaryGlassLayer == .bottomBar - } - - var shouldUseProgressOverlayGlassLayer: Bool { - secondaryGlassLayer == .progressOverlay - } - - var shouldUseBrainDumpGlassLayer: Bool { - secondaryGlassLayer == .brainDumpCanvas - } - var selectedModel: AIModel { get { AIModel(rawValue: selectedModelRaw) ?? .appleIntelligence } set { selectedModelRaw = newValue.rawValue } @@ -409,7 +259,6 @@ struct ContentView: View { @MainActor private func performInlineCompletion(for textView: NSTextView) { completionTask?.cancel() - inFlightCompletionKey = nil completionTask = Task(priority: .utility) { await performInlineCompletionAsync(for: textView) } @@ -483,16 +332,6 @@ struct ContentView: View { return } - if inFlightCompletionKey == cacheKey { - return - } - inFlightCompletionKey = cacheKey - defer { - if inFlightCompletionKey == cacheKey { - inFlightCompletionKey = nil - } - } - let modelInterval = Self.completionSignposter.beginInterval("model_completion") let suggestion = await generateModelCompletion(prefix: contextPrefix, language: currentLanguage) Self.completionSignposter.endInterval("model_completion", modelInterval) @@ -570,7 +409,7 @@ struct ContentView: View { } private func shouldThrottleHeavyEditorFeatures(in nsText: NSString? = nil) -> Bool { - if performanceTier.rawValue >= PerformanceTier.light.rawValue { return true } + if largeFileModeEnabled { return true } let length = nsText?.length ?? (currentContentBinding.wrappedValue as NSString).length return length >= 120_000 } @@ -581,8 +420,7 @@ struct ContentView: View { guard selection.length == 0 else { return false } let location = selection.location guard location > 0, location <= nsText.length else { return false } - if performanceTier.rawValue >= PerformanceTier.strong.rawValue { return false } - if shouldThrottleHeavyEditorFeatures(in: nsText) && nsText.length >= 180_000 { return false } + if shouldThrottleHeavyEditorFeatures(in: nsText) { return false } let prevChar = nsText.substring(with: NSRange(location: location - 1, length: 1)) let triggerChars: Set = [".", "(", ")", "{", "}", "[", "]", ":", ",", "\n", "\t", " "] @@ -590,9 +428,6 @@ struct ContentView: View { let wordChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) if prevChar.rangeOfCharacter(from: wordChars) == nil { return false } - if let prefixLength = identifierLengthBeforeCaret(in: nsText, caretLocation: location), prefixLength < 3 { - return false - } if location >= nsText.length { return true } let nextChar = nsText.substring(with: NSRange(location: location, length: 1)) @@ -602,24 +437,27 @@ struct ContentView: View { private func completionDebounceInterval(for textView: NSTextView) -> TimeInterval { let docLength = (textView.string as NSString).length - if performanceTier == .maximum { return 1.3 } - if performanceTier == .strong || docLength >= 120_000 { return 1.05 } - if performanceTier == .light || docLength >= 50_000 { return 0.8 } - return 0.55 + if docLength >= 80_000 { return 0.9 } + if docLength >= 25_000 { return 0.7 } + return 0.45 } - private func identifierLengthBeforeCaret(in text: NSString, caretLocation: Int) -> Int? { - guard caretLocation > 0, caretLocation <= text.length else { return nil } - let wordChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) - var index = caretLocation - 1 - var length = 0 - while index >= 0 { - let current = text.substring(with: NSRange(location: index, length: 1)) - if current.rangeOfCharacter(from: wordChars) == nil { break } - length += 1 - index -= 1 + private func completionTriggerSignature(for textView: NSTextView) -> String { + let nsText = textView.string as NSString + let selection = textView.selectedRange() + guard selection.length == 0 else { return "" } + let location = selection.location + guard location > 0, location <= nsText.length else { return "" } + + let prevChar = nsText.substring(with: NSRange(location: location - 1, length: 1)) + let nextChar: String + if location < nsText.length { + nextChar = nsText.substring(with: NSRange(location: location, length: 1)) + } else { + nextChar = "" } - return length > 0 ? length : nil + // Keep signature cheap while specific enough to skip duplicate notifications. + return "\(location)|\(prevChar)|\(nextChar)|\(nsText.length)" } #endif @@ -1215,26 +1053,10 @@ struct ContentView: View { } } - func updateLargeFileMode(for text: String) { - let bytes = text.utf8.count - let computedTier: PerformanceTier - if forceLargeFileMode { - computedTier = .maximum - } else if bytes >= 2_000_000 { - computedTier = .maximum - } else if bytes >= 1_000_000 { - computedTier = .strong - } else if bytes >= 250_000 { - computedTier = .light - } else { - computedTier = .normal - } - if performanceTier != computedTier { - performanceTier = computedTier - } - let effectiveLargeMode = computedTier.rawValue >= PerformanceTier.strong.rawValue - if largeFileModeEnabled != effectiveLargeMode { - largeFileModeEnabled = effectiveLargeMode + private func updateLargeFileMode(for text: String) { + let isLarge = text.utf8.count >= 2_000_000 + if largeFileModeEnabled != isLarge { + largeFileModeEnabled = isLarge scheduleHighlightRefresh() } } @@ -1353,16 +1175,22 @@ struct ContentView: View { return } guard shouldScheduleCompletion(for: changedTextView) else { return } - lastCompletionWorkItem?.cancel() + let signature = completionTriggerSignature(for: changedTextView) + guard !signature.isEmpty else { return } + if signature == lastCompletionTriggerSignature { + return + } + lastCompletionTriggerSignature = signature + completionDebounceTask?.cancel() completionTask?.cancel() let debounce = completionDebounceInterval(for: changedTextView) - let work = DispatchWorkItem { - Task { @MainActor in - performInlineCompletion(for: changedTextView) - } + completionDebounceTask = Task { @MainActor [weak changedTextView] in + let delay = UInt64((debounce * 1_000_000_000).rounded()) + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled, let changedTextView else { return } + lastCompletionTriggerSignature = "" + performInlineCompletion(for: changedTextView) } - lastCompletionWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: work) } #else view @@ -1380,7 +1208,7 @@ struct ContentView: View { editorView } .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) - .background(shouldUseLiquidGlass ? AnyShapeStyle(primaryGlassMaterial) : AnyShapeStyle(Color.clear)) + .background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear)) } else { editorView } @@ -1396,7 +1224,7 @@ struct ContentView: View { editorView } .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) - .background(shouldUseLiquidGlass ? AnyShapeStyle(primaryGlassMaterial) : AnyShapeStyle(Color.clear)) + .background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear)) } else { editorView } @@ -1406,182 +1234,131 @@ struct ContentView: View { #endif } - private var primaryContent: some View { - AnyView(platformLayout) - .alert("AI Error", isPresented: showGrokError) { - Button("OK") { } - } message: { - Text(grokErrorMessage.wrappedValue) - } - .alert( - "Whitespace Scalars", - isPresented: Binding( - get: { whitespaceInspectorMessage != nil }, - set: { if !$0 { whitespaceInspectorMessage = nil } } - ) - ) { - Button("OK", role: .cancel) { } - } message: { - Text(whitespaceInspectorMessage ?? "") - } - .navigationTitle("Neon Vision Editor") - .onAppear { - if UserDefaults.standard.object(forKey: "SettingsAutoIndent") == nil { - autoIndentEnabled = true - } - // Always start with completion disabled on app launch/open. - isAutoCompletionEnabled = false - UserDefaults.standard.set(false, forKey: "SettingsCompletionEnabled") - // Keep whitespace marker rendering disabled by default and after migrations. - UserDefaults.standard.set(false, forKey: "SettingsShowInvisibleCharacters") - UserDefaults.standard.set(false, forKey: "NSShowAllInvisibles") - UserDefaults.standard.set(false, forKey: "NSShowControlCharacters") - viewModel.isLineWrapEnabled = settingsLineWrapEnabled - syncAppleCompletionAvailability() - } - .onChange(of: settingsLineWrapEnabled) { _, enabled in - if viewModel.isLineWrapEnabled != enabled { - viewModel.isLineWrapEnabled = enabled - } - } - .onReceive(NotificationCenter.default.publisher(for: .whitespaceScalarInspectionResult)) { notif in - guard matchesCurrentWindow(notif) else { return } - if let msg = notif.userInfo?[EditorCommandUserInfo.inspectionMessage] as? String { - whitespaceInspectorMessage = msg - } - } - .onChange(of: viewModel.isLineWrapEnabled) { _, enabled in - if settingsLineWrapEnabled != enabled { - settingsLineWrapEnabled = enabled - } - } - .onChange(of: forceLargeFileMode) { _, _ in - updateLargeFileMode(for: currentContentBinding.wrappedValue) - } -#if os(iOS) - .onChange(of: viewModel.showSidebar) { _, enabled in - sceneSidebarVisible = enabled - } - .onChange(of: showProjectStructureSidebar) { _, enabled in - sceneProjectSidebarVisible = enabled - } -#endif - .onChange(of: appUpdateManager.automaticPromptToken) { _, _ in - if appUpdateManager.consumeAutomaticPromptIfNeeded() { - showUpdaterDialog(checkNow: false) - } - } - .onChange(of: settingsThemeName) { _, _ in - scheduleHighlightRefresh() - } - .onChange(of: highlightMatchingBrackets) { _, _ in - scheduleHighlightRefresh() - } - .onChange(of: showScopeGuides) { _, _ in - scheduleHighlightRefresh() - } - .onChange(of: highlightScopeBackground) { _, _ in - scheduleHighlightRefresh() - } - .onChange(of: viewModel.isLineWrapEnabled) { _, _ in - scheduleHighlightRefresh() - } - .onReceive(viewModel.$tabs) { _ in - persistSessionIfReady() - } -#if os(iOS) - .onReceive(NotificationCenter.default.publisher(for: Notification.Name.NSProcessInfoPowerStateDidChange)) { _ in - isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled - } - .onReceive(NotificationCenter.default.publisher(for: .editorScrollCompactnessDidChange)) { notif in - guard let value = notif.userInfo?["compactness"] as? CGFloat else { return } - let clamped = min(1, max(0, value)) - if abs(clamped - iosToolbarCompactness) >= 0.01 { - iosToolbarCompactness = clamped - } - } -#endif - } - - private var lifecycleContent: some View { - primaryContent - .modifier(ModalPresentationModifier(contentView: self)) - .onAppear { -#if os(iOS) - viewModel.showSidebar = sceneSidebarVisible - showProjectStructureSidebar = sceneProjectSidebarVisible -#else - // Start with sidebar collapsed by default - viewModel.showSidebar = false - showProjectStructureSidebar = false -#endif - - DispatchQueue.main.async { - applyStartupBehaviorIfNeeded() - } - - // Restore Brain Dump mode from defaults - if UserDefaults.standard.object(forKey: "BrainDumpModeEnabled") != nil { - viewModel.isBrainDumpMode = UserDefaults.standard.bool(forKey: "BrainDumpModeEnabled") - } - - applyWindowTranslucency(enableTranslucentWindow) - if !hasSeenWelcomeTourV1 || welcomeTourSeenRelease != WelcomeTourView.releaseID { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - showWelcomeTour = true - } - } - } - .onDisappear { - projectTreeRefreshTask?.cancel() - } - } - // Layout: NavigationSplitView with optional sidebar and the primary code editor. var body: some View { - lifecycleContent -#if os(macOS) - .background( - WindowAccessor { window in - updateWindowRegistration(window) - } - .frame(width: 0, height: 0) + AnyView(platformLayout) + .alert("AI Error", isPresented: showGrokError) { + Button("OK") { } + } message: { + Text(grokErrorMessage.wrappedValue) + } + .alert( + "Whitespace Scalars", + isPresented: Binding( + get: { whitespaceInspectorMessage != nil }, + set: { if !$0 { whitespaceInspectorMessage = nil } } ) - .onDisappear { - lastCompletionWorkItem?.cancel() - completionTask?.cancel() - inFlightCompletionKey = nil - pendingHighlightRefresh?.cancel() - completionCache.removeAll(keepingCapacity: false) - if let number = hostWindowNumber { - WindowViewModelRegistry.shared.unregister(windowNumber: number) + ) { + Button("OK", role: .cancel) { } + } message: { + Text(whitespaceInspectorMessage ?? "") + } + .navigationTitle("Neon Vision Editor") + .onAppear { + if UserDefaults.standard.object(forKey: "SettingsAutoIndent") == nil { + autoIndentEnabled = true + } + // Always start with completion disabled on app launch/open. + isAutoCompletionEnabled = false + UserDefaults.standard.set(false, forKey: "SettingsCompletionEnabled") + // Keep whitespace marker rendering disabled by default and after migrations. + UserDefaults.standard.set(false, forKey: "SettingsShowInvisibleCharacters") + UserDefaults.standard.set(false, forKey: "NSShowAllInvisibles") + UserDefaults.standard.set(false, forKey: "NSShowControlCharacters") + viewModel.isLineWrapEnabled = settingsLineWrapEnabled + syncAppleCompletionAvailability() + } + .onChange(of: settingsLineWrapEnabled) { _, enabled in + if viewModel.isLineWrapEnabled != enabled { + viewModel.isLineWrapEnabled = enabled + } + } + .onReceive(NotificationCenter.default.publisher(for: .whitespaceScalarInspectionResult)) { notif in + guard matchesCurrentWindow(notif) else { return } + if let msg = notif.userInfo?[EditorCommandUserInfo.inspectionMessage] as? String { + whitespaceInspectorMessage = msg + } + } + .onChange(of: viewModel.isLineWrapEnabled) { _, enabled in + if settingsLineWrapEnabled != enabled { + settingsLineWrapEnabled = enabled + } + } + .onChange(of: appUpdateManager.automaticPromptToken) { _, _ in + if appUpdateManager.consumeAutomaticPromptIfNeeded() { + showUpdaterDialog(checkNow: false) + } + } + .onChange(of: settingsThemeName) { _, _ in + scheduleHighlightRefresh() + } + .onChange(of: highlightMatchingBrackets) { _, _ in + scheduleHighlightRefresh() + } + .onChange(of: showScopeGuides) { _, _ in + scheduleHighlightRefresh() + } + .onChange(of: highlightScopeBackground) { _, _ in + scheduleHighlightRefresh() + } + .onChange(of: viewModel.isLineWrapEnabled) { _, _ in + scheduleHighlightRefresh() + } + .onReceive(viewModel.$tabs) { _ in + persistSessionIfReady() + } + .modifier(ModalPresentationModifier(contentView: self)) + .onAppear { + // Start with sidebar collapsed by default + viewModel.showSidebar = false + showProjectStructureSidebar = false + + applyStartupBehaviorIfNeeded() + + // Restore Brain Dump mode from defaults + if UserDefaults.standard.object(forKey: "BrainDumpModeEnabled") != nil { + viewModel.isBrainDumpMode = UserDefaults.standard.bool(forKey: "BrainDumpModeEnabled") + } + + applyWindowTranslucency(enableTranslucentWindow) + if !hasSeenWelcomeTourV1 || welcomeTourSeenRelease != WelcomeTourView.releaseID { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showWelcomeTour = true } } + } +#if os(macOS) + .background( + WindowAccessor { window in + updateWindowRegistration(window) + } + .frame(width: 0, height: 0) + ) + .onDisappear { + completionDebounceTask?.cancel() + completionTask?.cancel() + lastCompletionTriggerSignature = "" + pendingHighlightRefresh?.cancel() + completionCache.removeAll(keepingCapacity: false) + if let number = hostWindowNumber { + WindowViewModelRegistry.shared.unregister(windowNumber: number) + } + } #endif } private func scheduleHighlightRefresh(delay: TimeInterval = 0.05) { pendingHighlightRefresh?.cancel() - let adjustedDelay: TimeInterval = { - if performanceTier == .maximum { return max(delay, 0.2) } - if performanceTier == .strong { return max(delay, 0.14) } - return delay - }() let work = DispatchWorkItem { - let now = Date() - if now.timeIntervalSince(self.lastHighlightRefreshAt) < 0.05 { - return - } - self.lastHighlightRefreshAt = now highlightRefreshToken &+= 1 } pendingHighlightRefresh = work - DispatchQueue.main.asyncAfter(deadline: .now() + adjustedDelay, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) } #if !os(macOS) private func shouldThrottleHeavyEditorFeatures(in nsText: NSString? = nil) -> Bool { - if performanceTier.rawValue >= PerformanceTier.light.rawValue { return true } + if largeFileModeEnabled { return true } let length = nsText?.length ?? (currentContentBinding.wrappedValue as NSString).length return length >= 120_000 } @@ -1621,6 +1398,7 @@ struct ContentView: View { supportsTranslucency: false ) .environmentObject(contentView.supportPurchaseManager) + .tint(.blue) #if os(iOS) .presentationDetents([.large]) .presentationDragIndicator(.visible) @@ -1859,17 +1637,6 @@ struct ContentView: View { #endif } - func recordDiagnostic(_ message: String) { - guard diagnosticsEnabled else { return } - let timestamp = ISO8601DateFormatter().string(from: Date()) - var logs = UserDefaults.standard.stringArray(forKey: "SettingsDiagnosticsLog") ?? [] - logs.append("[\(timestamp)] \(message)") - if logs.count > 250 { - logs.removeFirst(logs.count - 250) - } - UserDefaults.standard.set(logs, forKey: "SettingsDiagnosticsLog") - } - private func applyLanguageSelection(language: String, insertTemplate: Bool) { let contentIsEmpty = currentContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if let tab = viewModel.selectedTab { @@ -2258,7 +2025,6 @@ struct ContentView: View { indentWidth: indentWidth, autoIndentEnabled: autoIndentEnabled, autoCloseBracketsEnabled: autoCloseBracketsEnabled, - showKeyboardAccessoryBar: showKeyboardAccessoryBarIOS && showBottomActionBarIOS, highlightRefreshToken: highlightRefreshToken ) .id(currentLanguage) @@ -2268,31 +2034,15 @@ struct ContentView: View { .padding(.vertical, viewModel.isBrainDumpMode ? 40 : 0) .background( Group { - #if os(macOS) if enableTranslucentWindow { - Color(nsColor: .windowBackgroundColor) - .opacity(macTranslucencyOpacity(for: .editor, colorScheme: colorScheme)) - } else if shouldUseLiquidGlass { - Color.clear.background(primaryGlassMaterial) + Color.clear.background(.ultraThinMaterial) } else { Color.clear } - #else - if shouldUseLiquidGlass { - Color.clear.background(primaryGlassMaterial) - } else { - Color.clear - } - #endif } ) if !viewModel.isBrainDumpMode { -#if os(iOS) - if showBottomActionBarIOS { - iosBottomActionBar - } -#endif wordCountView } } @@ -2306,36 +2056,24 @@ struct ContentView: View { Divider() ProjectStructureSidebarView( rootFolderURL: projectRootFolderURL, - nodes: $projectTreeNodes, + nodes: projectTreeNodes, selectedFileURL: viewModel.selectedTab?.fileURL, - translucentBackgroundEnabled: enableTranslucentWindow && !shouldUseLiquidGlass, + translucentBackgroundEnabled: enableTranslucentWindow, onOpenFile: { openFileFromToolbar() }, onOpenFolder: { openProjectFolder() }, onOpenProjectFile: { openProjectFile(url: $0) }, - onRefreshTree: { refreshProjectTree() }, - onLoadChildren: { loadProjectTreeChildren(for: $0) } + onRefreshTree: { refreshProjectTree() } ) .frame(minWidth: 220, idealWidth: 260, maxWidth: 340) } } .background( Group { - #if os(macOS) - if enableTranslucentWindow { - Color(nsColor: .windowBackgroundColor) - .opacity(macTranslucencyOpacity(for: .canvas, colorScheme: colorScheme)) - } else if shouldUseBrainDumpGlassLayer { - Color.clear.background(primaryGlassMaterial) + if viewModel.isBrainDumpMode && enableTranslucentWindow { + Color.clear.background(.ultraThinMaterial) } else { Color.clear } - #else - if shouldUseBrainDumpGlassLayer { - Color.clear.background(primaryGlassMaterial) - } else { - Color.clear - } - #endif } ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -2357,45 +2095,31 @@ struct ContentView: View { } .overlay(alignment: Alignment.topTrailing) { if droppedFileLoadInProgress { - GlassSurface( - enabled: shouldUseProgressOverlayGlassLayer, - material: primaryGlassMaterial, - fallbackColor: chromeFallbackColor, - shape: .capsule - ) { - HStack(spacing: 8) { - if droppedFileProgressDeterminate { - ProgressView(value: droppedFileLoadProgress) - .progressViewStyle(.linear) - .frame(width: 120) - } else { - ProgressView() - .frame(width: 16) - } - Text(droppedFileProgressDeterminate ? "\(droppedFileLoadLabel) \(importProgressPercentText)" : "\(droppedFileLoadLabel) Loading…") - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) + HStack(spacing: 8) { + if droppedFileProgressDeterminate { + ProgressView(value: droppedFileLoadProgress) + .progressViewStyle(.linear) + .frame(width: 120) + } else { + ProgressView() + .frame(width: 16) } - .padding(.horizontal, 10) - .padding(.vertical, 7) + Text(droppedFileProgressDeterminate ? "\(droppedFileLoadLabel) \(importProgressPercentText)" : "\(droppedFileLoadLabel) Loading…") + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.ultraThinMaterial, in: Capsule(style: .continuous)) .padding(.top, viewModel.isBrainDumpMode ? 12 : 50) .padding(.trailing, 12) } } #if os(macOS) - .toolbarBackground( - enableTranslucentWindow - ? AnyShapeStyle(Color(nsColor: .windowBackgroundColor).opacity(macTranslucencyOpacity(for: .toolbar, colorScheme: colorScheme))) - : AnyShapeStyle(Color(nsColor: .windowBackgroundColor)), - for: ToolbarPlacement.windowToolbar - ) - .toolbarBackgroundVisibility(.visible, for: ToolbarPlacement.windowToolbar) + .toolbarBackground(AnyShapeStyle(Color(nsColor: .windowBackgroundColor)), for: ToolbarPlacement.windowToolbar) + .toolbarBackgroundVisibility(enableTranslucentWindow ? .hidden : .visible, for: ToolbarPlacement.windowToolbar) #else - .toolbarBackground( - shouldUseLiquidGlass ? AnyShapeStyle(primaryGlassMaterial) : AnyShapeStyle(Color(.systemBackground)), - for: ToolbarPlacement.navigationBar - ) + .toolbarBackground(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(.systemBackground)), for: ToolbarPlacement.navigationBar) #endif } @@ -2421,8 +2145,8 @@ struct ContentView: View { .padding(.leading, 12) } - if performanceTier != .normal { - Text(performanceStatusLabel) + if largeFileModeEnabled { + Text("Large File Mode") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.secondary) .padding(.horizontal, 8) @@ -2441,46 +2165,9 @@ struct ContentView: View { .padding(.bottom, 8) .padding(.trailing, 16) } - .background(shouldUseStatusGlassLayer ? AnyShapeStyle(primaryGlassMaterial) : AnyShapeStyle(Color.clear)) + .background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear)) } -#if os(iOS) - @ViewBuilder - private var iosBottomActionBar: some View { - GlassSurface( - enabled: shouldUseBottomBarGlassLayer, - material: primaryGlassMaterial, - fallbackColor: chromeFallbackColor, - shape: .rounded(0) - ) { - HStack { - Spacer(minLength: 0) - HStack(spacing: 8) { - Button(action: { openFileFromToolbar() }) { - Label("Open", systemImage: "folder") - } - Button(action: { saveCurrentTabFromToolbar() }) { - Label("Save", systemImage: "square.and.arrow.down") - } - .disabled(viewModel.selectedTab == nil) - Button(action: { showFindReplace = true }) { - Label("Find", systemImage: "magnifyingglass") - } - Button(action: { toggleAutoCompletion() }) { - Label("Complete", systemImage: "text.badge.plus") - } - } - Spacer(minLength: 0) - } - .font(.system(size: 12, weight: .medium)) - .buttonStyle(.bordered) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - } -#endif - @ViewBuilder var tabBarView: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -2517,13 +2204,9 @@ struct ContentView: View { .padding(.vertical, 6) } #if os(macOS) - .background( - enableTranslucentWindow - ? AnyShapeStyle(Color(nsColor: .windowBackgroundColor).opacity(macTranslucencyOpacity(for: .tabStrip, colorScheme: colorScheme))) - : AnyShapeStyle(Color(nsColor: .windowBackgroundColor)) - ) + .background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(nsColor: .windowBackgroundColor))) #else - .background(shouldUseLiquidGlass ? AnyShapeStyle(Color.secondary.opacity(0.08)) : AnyShapeStyle(Color(.systemBackground))) + .background(enableTranslucentWindow ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color(.systemBackground))) #endif } @@ -2536,16 +2219,6 @@ struct ContentView: View { #endif } - private var performanceStatusLabel: String { - if forceLargeFileMode { return "Performance Mode" } - switch performanceTier { - case .normal: return "" - case .light: return "Light Performance" - case .strong: return "Large File Mode" - case .maximum: return "Max Performance" - } - } - private var importProgressPercentText: String { let clamped = min(max(droppedFileLoadProgress, 0), 1) if clamped > 0, clamped < 0.01 { return "1%" } diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index bad458d..781b287 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -7,7 +7,6 @@ private let syntaxHighlightSignposter = OSSignposter(subsystem: "h3p.Neon-Vision ///MARK: - Paste Notifications extension Notification.Name { static let pastedFileURL = Notification.Name("pastedFileURL") - static let editorScrollCompactnessDidChange = Notification.Name("editorScrollCompactnessDidChange") } ///MARK: - Scope Match Models @@ -1306,7 +1305,6 @@ struct CustomTextEditor: NSViewRepresentable { let indentWidth: Int let autoIndentEnabled: Bool let autoCloseBracketsEnabled: Bool - let showKeyboardAccessoryBar: Bool let highlightRefreshToken: Int private var fontName: String { @@ -1845,6 +1843,27 @@ struct CustomTextEditor: NSViewRepresentable { return NSRange(location: startLine, length: max(0, endLine - startLine)) } + private func preferredHighlightRange( + in textView: NSTextView, + text: NSString, + explicitRange: NSRange?, + immediate: Bool + ) -> NSRange { + let fullRange = NSRange(location: 0, length: text.length) + if let explicitRange { + return explicitRange + } + // For very large buffers, prioritize visible content while typing. + guard !immediate, text.length >= 100_000 else { return fullRange } + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { return fullRange } + layoutManager.ensureLayout(for: textContainer) + let visibleGlyphRange = layoutManager.glyphRange(forBoundingRect: textView.visibleRect, in: textContainer) + let visibleCharacterRange = layoutManager.characterRange(forGlyphRange: visibleGlyphRange, actualGlyphRange: nil) + guard visibleCharacterRange.length > 0 else { return fullRange } + return expandedHighlightRange(around: visibleCharacterRange, in: text, maxUTF16Padding: 12_000) + } + func rehighlight(token: Int, generation: Int, immediate: Bool = false, targetRange: NSRange? = nil) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in @@ -1876,6 +1895,14 @@ struct CustomTextEditor: NSViewRepresentable { type: theme.syntax.type ) let patterns = getSyntaxPatterns(for: language, colors: colors) + let nsText = textSnapshot as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let applyRange = preferredHighlightRange( + in: textView, + text: nsText, + explicitRange: targetRange, + immediate: immediate + ) // Cancel any in-flight work pendingHighlight?.cancel() @@ -1883,9 +1910,6 @@ struct CustomTextEditor: NSViewRepresentable { let work = DispatchWorkItem { [weak self] in let interval = syntaxHighlightSignposter.beginInterval("rehighlight_macos") // Compute matches off the main thread - let nsText = textSnapshot as NSString - let fullRange = NSRange(location: 0, length: nsText.length) - let applyRange = targetRange ?? fullRange var coloredRanges: [(NSRange, Color)] = [] for (pattern, color) in patterns { guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } @@ -1984,9 +2008,6 @@ struct CustomTextEditor: NSViewRepresentable { self.lastHighlightToken = token self.lastSelectionLocation = selectedLocation self.lastTranslucencyEnabled = self.parent.translucentBackgroundEnabled - - // Re-apply visibility preference after recoloring. - self.parent.applyInvisibleCharacterPreference(tv) } } @@ -1995,7 +2016,15 @@ struct CustomTextEditor: NSViewRepresentable { if immediate { highlightQueue.async(execute: work) } else { - highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work) + let delay: TimeInterval + if targetRange != nil { + delay = 0.08 + } else if textSnapshot.utf16.count >= 120_000 { + delay = 0.22 + } else { + delay = 0.12 + } + highlightQueue.asyncAfter(deadline: .now() + delay, execute: work) } } @@ -2283,7 +2312,6 @@ struct CustomTextEditor: UIViewRepresentable { let indentWidth: Int let autoIndentEnabled: Bool let autoCloseBracketsEnabled: Bool - let showKeyboardAccessoryBar: Bool let highlightRefreshToken: Int private var fontName: String { @@ -2310,95 +2338,6 @@ struct CustomTextEditor: UIViewRepresentable { return UIFont.monospacedSystemFont(ofSize: targetSize, weight: .regular) } -#if os(iOS) - private static let keyboardAccessoryTag = 824731 - - private func makeKeyboardAccessoryView(for textView: UITextView) -> UIView { - let container = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) - container.tag = Self.keyboardAccessoryTag - container.backgroundColor = UIColor.secondarySystemBackground - - let stack = UIStackView() - stack.translatesAutoresizingMaskIntoConstraints = false - stack.axis = .horizontal - stack.alignment = .fill - stack.distribution = .fillEqually - stack.spacing = 6 - - func makeButton(_ title: String, action: @escaping () -> Void) -> UIButton { - let button = UIButton(type: .system) - button.setTitle(title, for: .normal) - button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .semibold) - button.backgroundColor = UIColor.tertiarySystemFill - button.layer.cornerRadius = 8 - button.addAction(UIAction { _ in action() }, for: .touchUpInside) - return button - } - - let buttons: [UIButton] = [ - makeButton("Tab") { insertSnippet("\t", in: textView) }, - makeButton("{ }") { insertSnippet("{}", in: textView, caretOffset: -1) }, - makeButton("( )") { insertSnippet("()", in: textView, caretOffset: -1) }, - makeButton("[ ]") { insertSnippet("[]", in: textView, caretOffset: -1) }, - makeButton("->") { insertSnippet(" -> ", in: textView) }, - makeButton(">>") { indentSelection(in: textView) }, - makeButton("<<") { unindentSelection(in: textView) } - ] - buttons.forEach { stack.addArrangedSubview($0) } - container.addSubview(stack) - - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), - stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), - stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 6), - stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -6) - ]) - return container - } - - private func insertSnippet(_ snippet: String, in textView: UITextView, caretOffset: Int = 0) { - let selected = textView.selectedRange - if let textRange = textView.selectedTextRange { - textView.replace(textRange, withText: snippet) - } - let newLocation = max(0, min((textView.text as NSString).length, selected.location + snippet.utf16.count + caretOffset)) - textView.selectedRange = NSRange(location: newLocation, length: 0) - (textView.delegate as? Coordinator)?.textViewDidChange(textView) - } - - private func indentSelection(in textView: UITextView) { - let nsText = textView.text as NSString? ?? "" - let selected = textView.selectedRange - let lineRange = nsText.lineRange(for: selected) - let chunk = nsText.substring(with: lineRange) - let indented = chunk - .components(separatedBy: "\n") - .map { " " + $0 } - .joined(separator: "\n") - textView.textStorage.replaceCharacters(in: lineRange, with: indented) - textView.selectedRange = NSRange(location: lineRange.location + 4, length: selected.length) - (textView.delegate as? Coordinator)?.textViewDidChange(textView) - } - - private func unindentSelection(in textView: UITextView) { - let nsText = textView.text as NSString? ?? "" - let selected = textView.selectedRange - let lineRange = nsText.lineRange(for: selected) - let chunk = nsText.substring(with: lineRange) - let unindented = chunk - .components(separatedBy: "\n") - .map { line -> String in - if line.hasPrefix(" ") { return String(line.dropFirst(4)) } - if line.hasPrefix("\t") { return String(line.dropFirst(1)) } - return line - } - .joined(separator: "\n") - textView.textStorage.replaceCharacters(in: lineRange, with: unindented) - textView.selectedRange = NSRange(location: max(0, lineRange.location), length: selected.length) - (textView.delegate as? Coordinator)?.textViewDidChange(textView) - } -#endif - func makeUIView(context: Context) -> LineNumberedTextViewContainer { let container = LineNumberedTextViewContainer() let textView = container.textView @@ -2426,7 +2365,6 @@ struct CustomTextEditor: UIViewRepresentable { textView.smartDashesType = .no textView.smartQuotesType = .no textView.smartInsertDeleteType = .no - textView.inputAccessoryView = showKeyboardAccessoryBar ? makeKeyboardAccessoryView(for: textView) : nil textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode @@ -2469,13 +2407,6 @@ struct CustomTextEditor: UIViewRepresentable { let baseColor = UIColor(theme.text) textView.tintColor = UIColor(theme.cursor) textView.backgroundColor = translucentBackgroundEnabled ? .clear : UIColor(theme.background) - let hasSnippetAccessory = textView.inputAccessoryView?.tag == Self.keyboardAccessoryTag - if showKeyboardAccessoryBar != hasSnippetAccessory { - textView.inputAccessoryView = showKeyboardAccessoryBar ? makeKeyboardAccessoryView(for: textView) : nil - if textView.isFirstResponder { - textView.reloadInputViews() - } - } textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode textView.typingAttributes[.foregroundColor] = baseColor @@ -2508,7 +2439,6 @@ struct CustomTextEditor: UIViewRepresentable { private var lastTranslucencyEnabled: Bool? private var isApplyingHighlight = false private var highlightGeneration: Int = 0 - private var lastPostedScrollCompactness: CGFloat = -1 init(_ parent: CustomTextEditor) { self.parent = parent @@ -2566,11 +2496,7 @@ struct CustomTextEditor: UIViewRepresentable { pendingHighlight?.cancel() highlightGeneration &+= 1 let generation = highlightGeneration - let targetRange: NSRange? = { - let nsLength = (text as NSString).length - guard nsLength >= 100_000 else { return nil } - return expandedVisibleRange(in: textView, textLength: nsLength) - }() + let applyRange = preferredHighlightRange(textView: textView, text: text as NSString, immediate: immediate) let work = DispatchWorkItem { [weak self] in self?.rehighlight( text: text, @@ -2578,29 +2504,48 @@ struct CustomTextEditor: UIViewRepresentable { colorScheme: scheme, token: token, generation: generation, - targetRange: targetRange + applyRange: applyRange ) } pendingHighlight = work if immediate || lastHighlightedText.isEmpty || lastHighlightToken != token { highlightQueue.async(execute: work) } else { - let delay: TimeInterval = text.utf16.count >= 80_000 ? 0.2 : 0.1 + let delay: TimeInterval + if text.utf16.count >= 120_000 { + delay = 0.24 + } else if text.utf16.count >= 80_000 { + delay = 0.18 + } else { + delay = 0.1 + } highlightQueue.asyncAfter(deadline: .now() + delay, execute: work) } } - private func expandedVisibleRange(in textView: UITextView, textLength: Int) -> NSRange { - let visibleRect = CGRect(origin: textView.contentOffset, size: textView.bounds.size).insetBy(dx: 0, dy: -120) + private func expandedRange(around range: NSRange, in text: NSString, maxUTF16Padding: Int = 8000) -> NSRange { + let start = max(0, range.location - maxUTF16Padding) + let end = min(text.length, NSMaxRange(range) + maxUTF16Padding) + let startLine = text.lineRange(for: NSRange(location: start, length: 0)).location + let endAnchor = max(startLine, min(text.length - 1, max(0, end - 1))) + let endLine = NSMaxRange(text.lineRange(for: NSRange(location: endAnchor, length: 0))) + return NSRange(location: startLine, length: max(0, endLine - startLine)) + } + + private func preferredHighlightRange( + textView: UITextView, + text: NSString, + immediate: Bool + ) -> NSRange { + let fullRange = NSRange(location: 0, length: text.length) + // iOS rehighlight builds attributed text for the whole buffer; for very large files + // keep syntax matching focused on visible content while typing. + guard !immediate, text.length >= 100_000 else { return fullRange } + let visibleRect = CGRect(origin: textView.contentOffset, size: textView.bounds.size).insetBy(dx: 0, dy: -80) let glyphRange = textView.layoutManager.glyphRange(forBoundingRect: visibleRect, in: textView.textContainer) - var charRange = textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - if charRange.location == NSNotFound { - charRange = NSRange(location: 0, length: min(4000, textLength)) - } - let pad = 4000 - let start = max(0, charRange.location - pad) - let end = min(textLength, NSMaxRange(charRange) + pad) - return NSRange(location: start, length: max(0, end - start)) + let charRange = textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + guard charRange.length > 0 else { return fullRange } + return expandedRange(around: charRange, in: text, maxUTF16Padding: 12_000) } private func rehighlight( @@ -2609,13 +2554,12 @@ struct CustomTextEditor: UIViewRepresentable { colorScheme: ColorScheme, token: Int, generation: Int, - targetRange: NSRange? = nil + applyRange: NSRange ) { let interval = syntaxHighlightSignposter.beginInterval("rehighlight_ios") defer { syntaxHighlightSignposter.endInterval("rehighlight_ios", interval) } let nsText = text as NSString let fullRange = NSRange(location: 0, length: nsText.length) - let applyRange = targetRange ?? fullRange let theme = currentEditorTheme(colorScheme: colorScheme) let baseColor = UIColor(theme.text) let baseFont: UIFont @@ -2627,6 +2571,14 @@ struct CustomTextEditor: UIViewRepresentable { baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular) } + let attributed = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: baseColor, + .font: baseFont + ] + ) + let colors = SyntaxColors( keyword: theme.syntax.keyword, string: theme.syntax.string, @@ -2644,13 +2596,12 @@ struct CustomTextEditor: UIViewRepresentable { ) let patterns = getSyntaxPatterns(for: language, colors: colors) - var coloredRanges: [(NSRange, UIColor)] = [] for (pattern, color) in patterns { guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } let matches = regex.matches(in: text, range: applyRange) let uiColor = UIColor(color) for match in matches { - coloredRanges.append((match.range, uiColor)) + attributed.addAttribute(.foregroundColor, value: uiColor, range: match.range) } } @@ -2660,15 +2611,7 @@ struct CustomTextEditor: UIViewRepresentable { guard textView.text == text else { return } let selectedRange = textView.selectedRange self.isApplyingHighlight = true - textView.textStorage.beginEditing() - textView.textStorage.removeAttribute(.foregroundColor, range: applyRange) - textView.textStorage.removeAttribute(.backgroundColor, range: applyRange) - textView.textStorage.removeAttribute(.underlineStyle, range: applyRange) - textView.textStorage.addAttribute(.foregroundColor, value: baseColor, range: applyRange) - textView.textStorage.addAttribute(.font, value: baseFont, range: applyRange) - for (range, color) in coloredRanges { - textView.textStorage.addAttribute(.foregroundColor, value: color, range: range) - } + textView.attributedText = attributed let wantsBracketTokens = self.parent.highlightMatchingBrackets let wantsScopeBackground = self.parent.highlightScopeBackground let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift" @@ -2711,11 +2654,8 @@ struct CustomTextEditor: UIViewRepresentable { if self.parent.highlightCurrentLine { let ns = text as NSString let lineRange = ns.lineRange(for: selectedRange) - if targetRange == nil || NSIntersectionRange(lineRange, applyRange).length > 0 { - textView.textStorage.addAttribute(.backgroundColor, value: UIColor.secondarySystemFill, range: lineRange) - } + textView.textStorage.addAttribute(.backgroundColor, value: UIColor.secondarySystemFill, range: lineRange) } - textView.textStorage.endEditing() textView.selectedRange = selectedRange textView.typingAttributes = [ .foregroundColor: baseColor, @@ -2743,9 +2683,6 @@ struct CustomTextEditor: UIViewRepresentable { func textViewDidChangeSelection(_ textView: UITextView) { guard !isApplyingHighlight else { return } - if textView.selectedRange.length == 0 { - textView.scrollRangeToVisible(textView.selectedRange) - } scheduleHighlightIfNeeded(currentText: textView.text, immediate: true) } @@ -2800,16 +2737,6 @@ struct CustomTextEditor: UIViewRepresentable { func scrollViewDidScroll(_ scrollView: UIScrollView) { syncLineNumberScroll() - let y = max(0, scrollView.contentOffset.y + scrollView.adjustedContentInset.top) - let compactness = min(1, y / 120) - if abs(compactness - lastPostedScrollCompactness) >= 0.02 { - lastPostedScrollCompactness = compactness - NotificationCenter.default.post( - name: .editorScrollCompactnessDidChange, - object: nil, - userInfo: ["compactness": compactness] - ) - } } func syncLineNumberScroll() { diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 6bd3d7c..3daee76 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -10,7 +10,6 @@ struct NeonSettingsView: View { @EnvironmentObject private var supportPurchaseManager: SupportPurchaseManager @EnvironmentObject private var appUpdateManager: AppUpdateManager @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system" @AppStorage("SettingsEditorFontName") private var editorFontName: String = "" @AppStorage("SettingsUseSystemFont") private var useSystemFont: Bool = false @@ -18,7 +17,6 @@ struct NeonSettingsView: View { @AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0 @AppStorage("SettingsAppearance") private var appearance: String = "system" @AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = false - @AppStorage("SettingsTranslucencyStrength") private var translucencyStrength: String = "medium" @AppStorage("SettingsReopenLastSession") private var reopenLastSession: Bool = true @AppStorage("SettingsOpenWithBlankDocument") private var openWithBlankDocument: Bool = true @AppStorage("SettingsDefaultNewFileLanguage") private var defaultNewFileLanguage: String = "plain" @@ -34,11 +32,6 @@ struct NeonSettingsView: View { @AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false @AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false @AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false - @AppStorage("SettingsLiquidGlassEnabled") private var liquidGlassEnabled: Bool = true - @AppStorage("SettingsForceLargeFileMode") private var forceLargeFileMode: Bool = false - @AppStorage("SettingsShowBottomActionBarIOS") private var showBottomActionBarIOS: Bool = false - @AppStorage("SettingsMoreSubtabIOS") private var settingsMoreSubtabIOS: String = "support" - @AppStorage("SettingsEnableDiagnostics") private var diagnosticsEnabled: Bool = false @AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces" @AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4 @AppStorage("SettingsAutoIndent") private var autoIndent: Bool = true @@ -57,8 +50,9 @@ struct NeonSettingsView: View { @State private var geminiAPIToken: String = "" @State private var anthropicAPIToken: String = "" @State private var showSupportPurchaseDialog: Bool = false + @State private var showDataDisclosureDialog: Bool = false @State private var availableEditorFonts: [String] = [] - @State private var isLowPowerModeEnabled: Bool = ProcessInfo.processInfo.isLowPowerModeEnabled + @State private var moreSectionTab: String = "ai" private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md") @AppStorage("SettingsThemeName") private var selectedTheme: String = "Neon Glow" @@ -67,11 +61,11 @@ struct NeonSettingsView: View { @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 = "#66B2FF" + @AppStorage("SettingsThemeStringColor") private var themeStringHex: String = "#4EA4FF" @AppStorage("SettingsThemeNumberColor") private var themeNumberHex: String = "#FFB86C" @AppStorage("SettingsThemeCommentColor") private var themeCommentHex: String = "#7F8C98" @AppStorage("SettingsThemeTypeColor") private var themeTypeHex: String = "#32D269" - @AppStorage("SettingsThemeBuiltinColor") private var themeBuiltinHex: String = "#4EA4FF" + @AppStorage("SettingsThemeBuiltinColor") private var themeBuiltinHex: String = "#EC7887" private var inputFieldBackground: Color { #if os(macOS) @@ -98,23 +92,20 @@ struct NeonSettingsView: View { #endif } - private var shouldUseSettingsGlass: Bool { + private var useTwoColumnSettingsLayout: Bool { #if os(iOS) - supportsTranslucency && translucentWindow && liquidGlassEnabled && !reduceTransparency && !isLowPowerModeEnabled + horizontalSizeClass == .regular #else - supportsTranslucency && translucentWindow && liquidGlassEnabled && !reduceTransparency + false #endif } - private var settingsBackgroundStyle: AnyShapeStyle { - if shouldUseSettingsGlass { - return AnyShapeStyle(.ultraThinMaterial) - } -#if os(iOS) - return AnyShapeStyle(Color(.systemGroupedBackground)) -#else - return AnyShapeStyle(Color.clear) -#endif + private var standardLabelWidth: CGFloat { + useTwoColumnSettingsLayout ? 180 : 140 + } + + private var startupLabelWidth: CGFloat { + useTwoColumnSettingsLayout ? 220 : 180 } private enum UI { @@ -137,6 +128,7 @@ struct NeonSettingsView: View { static let sectionSubheadline = Font.subheadline static let footnote = Font.footnote static let monoBody = Font.system(size: 13, weight: .regular, design: .monospaced) + static let sectionTitle = Font.title3.weight(.semibold) } init( @@ -147,7 +139,7 @@ struct NeonSettingsView: View { self.supportsTranslucency = supportsTranslucency } - var body: some View { + private var settingsTabs: some View { TabView(selection: $settingsActiveTab) { generalTab .tabItem { Label("General", systemImage: "gearshape") } @@ -158,32 +150,34 @@ struct NeonSettingsView: View { templateTab .tabItem { Label("Templates", systemImage: "doc.badge.plus") } .tag("templates") -#if os(iOS) - moreTab - .tabItem { Label("More", systemImage: "ellipsis.circle") } - .tag("more") - aiTab - .tabItem { Label("AI", systemImage: "brain.head.profile") } - .tag("ai") -#else themeTab .tabItem { Label("Themes", systemImage: "paintpalette") } .tag("themes") + #if os(iOS) + moreTab + .tabItem { Label("More", systemImage: "ellipsis.circle") } + .tag("more") + #else aiTab .tabItem { Label("AI", systemImage: "brain.head.profile") } .tag("ai") supportTab .tabItem { Label("Support", systemImage: "heart") } .tag("support") + #endif #if os(macOS) if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution { updatesTab .tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle") } .tag("updates") } -#endif #endif } + } + + var body: some View { + settingsTabs + .tint(.blue) #if os(macOS) .frame(minWidth: 900, idealWidth: 980, minHeight: 820, idealHeight: 880) .background( @@ -194,11 +188,11 @@ struct NeonSettingsView: View { ) ) #endif - .groupBoxStyle(ReadableSettingsGroupBoxStyle()) .preferredColorScheme(preferredColorSchemeOverride) .onAppear { settingsActiveTab = "general" selectedTheme = canonicalThemeName(selectedTheme) + migrateLegacyPinkSettingsIfNeeded() loadAvailableEditorFontsIfNeeded() if supportPurchaseManager.supportProduct == nil { Task { await supportPurchaseManager.refreshStoreState() } @@ -241,15 +235,21 @@ struct NeonSettingsView: View { appUpdateManager.setAutoDownloadEnabled(enabled) } .onChange(of: settingsActiveTab) { _, newValue in + #if os(iOS) + if newValue == "more" && moreSectionTab == "ai" { + loadAPITokensIfNeeded() + } + #else if newValue == "ai" { loadAPITokensIfNeeded() } + #endif } -#if os(iOS) - .onReceive(NotificationCenter.default.publisher(for: Notification.Name.NSProcessInfoPowerStateDidChange)) { _ in - isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + .onChange(of: moreSectionTab) { _, newValue in + if newValue == "ai" && settingsActiveTab == "more" { + loadAPITokensIfNeeded() + } } -#endif .onChange(of: selectedTheme) { _, newValue in let canonical = canonicalThemeName(newValue) if canonical != newValue { @@ -275,6 +275,9 @@ struct NeonSettingsView: View { } message: { Text(supportPurchaseManager.statusMessage ?? "") } + .sheet(isPresented: $showDataDisclosureDialog) { + dataDisclosureDialog + } } private func loadAPITokensIfNeeded() { @@ -308,162 +311,174 @@ struct NeonSettingsView: View { private var generalTab: some View { settingsContainer { - GroupBox("Window") { - VStack(alignment: .leading, spacing: UI.space12) { - if supportsOpenInTabs { - HStack(alignment: .center, spacing: UI.space12) { - 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) - } - } + settingsSectionHeader( + icon: "gearshape", + title: "General", + subtitle: "Window behavior, startup defaults, and confirmation preferences." + ) + if useTwoColumnSettingsLayout { + LazyVGrid(columns: [GridItem(.flexible(), spacing: UI.space16), GridItem(.flexible(), spacing: UI.space16)], spacing: UI.space16) { + windowSection + editorFontSection + startupSection + confirmationsSection + } + } else { + windowSection + editorFontSection + startupSection + confirmationsSection + } + } + } + + private var windowSection: some View { + GroupBox("Window") { + VStack(alignment: .leading, spacing: UI.space12) { + if supportsOpenInTabs { HStack(alignment: .center, spacing: UI.space12) { - Text("Appearance") - .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) - Picker("", selection: $appearance) { - Text("System").tag("system") - Text("Light").tag("light") - Text("Dark").tag("dark") + Text("Open in Tabs") + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) + Picker("", selection: $openInTabs) { + Text("Follow System").tag("system") + Text("Always").tag("always") + Text("Never").tag("never") } .pickerStyle(.segmented) } - - if supportsTranslucency { - Toggle("Translucent Window", isOn: $translucentWindow) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(alignment: .center, spacing: UI.space12) { - Text("Translucency Strength") - .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) - Picker("", selection: $translucencyStrength) { - Text("Light").tag("light") - Text("Medium").tag("medium") - Text("Strong").tag("strong") - } - .pickerStyle(.segmented) - .disabled(!translucentWindow) - } - } } - .padding(UI.groupPadding) - } - GroupBox("Editor Font") { - VStack(alignment: .leading, spacing: UI.space12) { - Toggle("Use System Font", isOn: $useSystemFont) + HStack(alignment: .center, spacing: UI.space12) { + Text("Appearance") + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, 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(UI.groupPadding) + } + } - HStack(alignment: .center, spacing: UI.space12) { - Text("Font") - .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) - VStack(alignment: .leading, spacing: UI.space8) { - HStack(spacing: UI.space8) { - Text(useSystemFont ? "System" : (editorFontName.isEmpty ? "System" : editorFontName)) - .font(Typography.footnote) - .foregroundStyle(.secondary) - Button(showFontList ? "Hide Font List" : "Show Font List") { - showFontList.toggle() - } - .buttonStyle(.borderless) - } - if showFontList { - Picker("", selection: selectedFontBinding) { - Text("System").tag(systemFontSentinel) - ForEach(availableEditorFonts, id: \.self) { fontName in - Text(fontName).tag(fontName) - } - } - .pickerStyle(.menu) - .padding(.vertical, UI.space6) - .padding(.horizontal, UI.space8) - .background(inputFieldBackground) - .overlay( - RoundedRectangle(cornerRadius: UI.fieldCorner) - .stroke(Color.secondary.opacity(0.35), lineWidth: 1) - ) - .cornerRadius(UI.fieldCorner) + private var editorFontSection: some View { + GroupBox("Editor Font") { + VStack(alignment: .leading, spacing: UI.space12) { + Toggle("Use System Font", isOn: $useSystemFont) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .center, spacing: UI.space12) { + Text("Font") + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) + VStack(alignment: .leading, spacing: UI.space8) { + HStack(spacing: UI.space8) { + Text(useSystemFont ? "System" : (editorFontName.isEmpty ? "System" : editorFontName)) + .font(Typography.footnote) + .foregroundStyle(.secondary) + Button(showFontList ? "Hide Font List" : "Show Font List") { + showFontList.toggle() } + .buttonStyle(.borderless) } - .frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading) + if showFontList { + Picker("", selection: selectedFontBinding) { + Text("System").tag(systemFontSentinel) + ForEach(availableEditorFonts, id: \.self) { fontName in + Text(fontName).tag(fontName) + } + } + .pickerStyle(.menu) + .padding(.vertical, UI.space6) + .padding(.horizontal, UI.space8) + .background(inputFieldBackground) + .overlay( + RoundedRectangle(cornerRadius: UI.fieldCorner) + .stroke(Color.secondary.opacity(0.35), lineWidth: 1) + ) + .cornerRadius(UI.fieldCorner) + } + } + .frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading) #if os(macOS) - Button("Choose…") { - useSystemFont = false - showFontList = true - } - .disabled(useSystemFont) -#endif + Button("Choose…") { + useSystemFont = false + showFontList = true } - - HStack(alignment: .center, spacing: UI.space12) { - 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: UI.space12) { - 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(UI.groupPadding) - } - - GroupBox("Startup") { - VStack(alignment: .leading, spacing: UI.space12) { - Toggle("Open with Blank Document", isOn: $openWithBlankDocument) - .disabled(reopenLastSession) - Toggle("Reopen Last Session", isOn: $reopenLastSession) - HStack(alignment: .center, spacing: UI.space12) { - 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(UI.groupPadding) - .onChange(of: openWithBlankDocument) { _, isEnabled in - if isEnabled { - reopenLastSession = false - } - } - .onChange(of: reopenLastSession) { _, isEnabled in - if isEnabled { - openWithBlankDocument = false - } - } - } - - GroupBox("Confirmations") { - VStack(alignment: .leading, spacing: UI.space12) { - Toggle("Confirm Before Closing Dirty Tab", isOn: $confirmCloseDirtyTab) - Toggle("Confirm Before Clearing Editor", isOn: $confirmClearEditor) -#if os(iOS) - Divider() - Toggle("Enable Local Diagnostics (iOS)", isOn: $diagnosticsEnabled) - Text("Stores a local rolling diagnostics log to help troubleshoot iOS-only issues.") - .font(Typography.footnote) - .foregroundStyle(.secondary) + .disabled(useSystemFont) #endif } - .padding(UI.groupPadding) + + HStack(alignment: .center, spacing: UI.space12) { + Text("Font Size") + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, 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: UI.space12) { + Text("Line Height") + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, 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(UI.groupPadding) + } + } + + private var startupSection: some View { + GroupBox("Startup") { + VStack(alignment: .leading, spacing: UI.space12) { + Toggle("Open with Blank Document", isOn: $openWithBlankDocument) + .disabled(reopenLastSession) + Toggle("Reopen Last Session", isOn: $reopenLastSession) + HStack(alignment: .center, spacing: UI.space12) { + Text("Default New File Language") + .frame(width: isCompactSettingsLayout ? nil : startupLabelWidth, alignment: .leading) + Picker("", selection: $defaultNewFileLanguage) { + ForEach(templateLanguages, id: \.self) { lang in + Text(languageLabel(for: lang)).tag(lang) + } + } + .pickerStyle(.menu) + } + Text("Tip: Enable only one startup mode to keep app launch behavior predictable.") + .font(Typography.footnote) + .foregroundStyle(.secondary) + } + .padding(UI.groupPadding) + .onChange(of: openWithBlankDocument) { _, isEnabled in + if isEnabled { + reopenLastSession = false + } + } + .onChange(of: reopenLastSession) { _, isEnabled in + if isEnabled { + openWithBlankDocument = false + } + } + } + } + + private var confirmationsSection: some View { + GroupBox("Confirmations") { + VStack(alignment: .leading, spacing: UI.space12) { + Toggle("Confirm Before Closing Dirty Tab", isOn: $confirmCloseDirtyTab) + Toggle("Confirm Before Clearing Editor", isOn: $confirmClearEditor) + } + .padding(UI.groupPadding) } } @@ -545,6 +560,11 @@ struct NeonSettingsView: View { private var editorTab: some View { settingsContainer(maxWidth: 760) { + settingsSectionHeader( + icon: "slider.horizontal.3", + title: "Editor", + subtitle: "Display, indentation, editing behavior, and completion sources." + ) GroupBox("Editor") { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: UI.space10) { @@ -605,27 +625,11 @@ struct NeonSettingsView: View { Toggle("Enable Completion", isOn: $completionEnabled) Toggle("Include Words in Document", isOn: $completionFromDocument) Toggle("Include Syntax Keywords", isOn: $completionFromSyntax) - } - .frame(maxWidth: .infinity, alignment: .leading) - -#if os(iOS) - Divider() - - VStack(alignment: .leading, spacing: UI.space10) { - Text("iOS Toolbar & Performance") - .font(Typography.sectionHeadline) - Toggle("Enable Liquid Glass Panels", isOn: $liquidGlassEnabled) - Toggle("Show Bottom Action Bar", isOn: $showBottomActionBarIOS) - Toggle("Force Performance Mode", isOn: $forceLargeFileMode) - Text("Performance mode reduces expensive editor features for smoother interaction on larger files/devices.") - .font(Typography.footnote) - .foregroundStyle(.secondary) - Text("Liquid Glass is automatically reduced when iOS transparency reduction or Low Power Mode is active.") + Text("For lower latency on large files, keep only one completion source enabled.") .font(Typography.footnote) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) -#endif } .frame(maxWidth: .infinity, alignment: .leading) .padding(UI.groupPadding) @@ -635,11 +639,16 @@ struct NeonSettingsView: View { private var templateTab: some View { settingsContainer(maxWidth: 640) { + settingsSectionHeader( + icon: "doc.badge.plus", + title: "Templates", + subtitle: "Control language-specific starter content used when inserting templates." + ) GroupBox("Completion Template") { VStack(alignment: .leading, spacing: UI.space12) { HStack(alignment: .center, spacing: UI.space12) { Text("Language") - .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) Picker("", selection: $settingsTemplateLanguage) { ForEach(templateLanguages, id: \.self) { lang in Text(languageLabel(for: lang)).tag(lang) @@ -668,7 +677,7 @@ struct NeonSettingsView: View { ) HStack(spacing: UI.space12) { - Button("Reset to Default") { + Button("Reset to Default", role: .destructive) { UserDefaults.standard.removeObject(forKey: templateOverrideKey(for: settingsTemplateLanguage)) } Button("Use Default Template") { @@ -676,6 +685,7 @@ struct NeonSettingsView: View { UserDefaults.standard.set(fallback, forKey: templateOverrideKey(for: settingsTemplateLanguage)) } } + .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -688,87 +698,172 @@ struct NeonSettingsView: View { let isCustom = selectedTheme == "Custom" let palette = themePaletteColors(for: selectedTheme) return settingsContainer(maxWidth: 760) { - HStack(spacing: UI.space16) { + settingsSectionHeader( + icon: "paintpalette", + title: "Themes", + subtitle: "Pick a preset or customize token colors for your editing environment." + ) + HStack(alignment: .top, spacing: UI.space16) { + Group { #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 + let listView = List(themes, id: \.self, selection: $selectedTheme) { theme in HStack { Text(theme) - Spacer() + Spacer(minLength: 8) if theme == selectedTheme { - Image(systemName: "checkmark") + Image(systemName: "checkmark.circle.fill") .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 - } + .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 + } + .padding(UI.space8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 1) + ) + ) - VStack(alignment: .leading, spacing: UI.space16) { - Text("Theme Colors") - .font(Typography.sectionHeadline) - Spacer(minLength: 6) + VStack(alignment: .leading, spacing: UI.space12) { + HStack(alignment: .firstTextBaseline, spacing: UI.space8) { + Text("Theme Colors") + .font(Typography.sectionHeadline) + Text(isCustom ? "Custom" : "Preset") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule() + .fill(isCustom ? Color.blue.opacity(0.18) : Color.secondary.opacity(0.16)) + ) + .foregroundStyle(isCustom ? .blue : .secondary) + } - 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) + HStack(spacing: UI.space8) { + Circle().fill(palette.background).frame(width: 12, height: 12) + Circle().fill(palette.text).frame(width: 12, height: 12) + Circle().fill(palette.cursor).frame(width: 12, height: 12) + Circle().fill(palette.selection).frame(width: 12, height: 12) + Spacer() + Text(selectedTheme) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, UI.space10) + .padding(.vertical, UI.space8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(.thinMaterial) + ) - Divider() + themePreviewSnippet(palette: palette) - Text("Syntax") - .font(Typography.sectionSubheadline) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: UI.space10) { + Text("Base") + .font(Typography.sectionSubheadline) + .foregroundStyle(.secondary) - colorRow(title: "Keywords", color: isCustom ? hexBinding($themeKeywordHex, fallback: .yellow) : .constant(palette.keyword)) - .disabled(!isCustom) - colorRow(title: "Strings", color: isCustom ? hexBinding($themeStringHex, fallback: .blue) : .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: isCustom ? hexBinding($themeTypeHex, fallback: .green) : .constant(palette.type)) - .disabled(!isCustom) - colorRow(title: "Builtins", color: isCustom ? hexBinding($themeBuiltinHex, fallback: .red) : .constant(palette.builtin)) - .disabled(!isCustom) + 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) + } + .padding(UI.space12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 1) + ) + ) + + VStack(alignment: .leading, spacing: UI.space10) { + Text("Syntax") + .font(Typography.sectionSubheadline) + .foregroundStyle(.secondary) + + colorRow(title: "Keywords", color: isCustom ? hexBinding($themeKeywordHex, fallback: .yellow) : .constant(palette.keyword)) + .disabled(!isCustom) + colorRow(title: "Strings", color: isCustom ? hexBinding($themeStringHex, fallback: .blue) : .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: isCustom ? hexBinding($themeTypeHex, fallback: .green) : .constant(palette.type)) + .disabled(!isCustom) + colorRow(title: "Builtins", color: isCustom ? hexBinding($themeBuiltinHex, fallback: .red) : .constant(palette.builtin)) + .disabled(!isCustom) + } + .padding(UI.space12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 1) + ) + ) - Spacer() Text(isCustom ? "Custom theme applies immediately." : "Select Custom to edit colors.") .font(Typography.footnote) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) + .padding(UI.space12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + ) } #if os(iOS) .padding(.top, 20) @@ -776,8 +871,74 @@ struct NeonSettingsView: View { } } + private var selectedAIModelBinding: Binding { + Binding( + get: { AIModel(rawValue: selectedAIModelRaw) ?? .appleIntelligence }, + set: { selectedAIModelRaw = $0.rawValue } + ) + } + + private var moreTab: some View { + settingsContainer(maxWidth: 560) { + VStack(alignment: .leading, spacing: UI.space12) { + settingsSectionHeader( + icon: "ellipsis.circle", + title: "More", + subtitle: "AI setup, provider credentials, and support options." + ) + Picker("More Section", selection: $moreSectionTab) { + Text("AI").tag("ai") + Text("Support").tag("support") + } + .pickerStyle(.segmented) + } + .padding(UI.groupPadding) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + ) + ) + + ZStack { + if moreSectionTab == "ai" { + aiSection + .transition(.opacity) + } else { + supportSection + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.15), value: moreSectionTab) + } + } + private var aiTab: some View { - settingsContainer(maxWidth: 520) { + settingsContainer(maxWidth: 560) { + settingsSectionHeader( + icon: "brain.head.profile", + title: "AI", + subtitle: "AI model, privacy disclosure, and provider credentials." + ) + aiSection + } + } + + private var supportTab: some View { + settingsContainer(maxWidth: 560) { + settingsSectionHeader( + icon: "heart", + title: "Support", + subtitle: "Optional one-time support purchase and build-specific options." + ) + supportSection + } + } + + private var aiSection: some View { + VStack(spacing: UI.space20) { GroupBox("AI Model") { VStack(alignment: .leading, spacing: UI.space12) { Picker("Model", selection: selectedAIModelBinding) { @@ -789,14 +950,18 @@ struct NeonSettingsView: View { } .pickerStyle(.menu) - Text("Choose the default model used by editor AI actions.") + Text("The selected AI model is used for AI-assisted code completion.") .font(Typography.footnote) .foregroundStyle(.secondary) + + Button("Data Disclosure") { + showDataDisclosureDialog = true + } + .buttonStyle(.bordered) } .padding(UI.groupPadding) } - .frame(maxWidth: 420) - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) GroupBox("AI Provider API Keys") { VStack(alignment: .center, spacing: UI.space12) { @@ -808,75 +973,12 @@ struct NeonSettingsView: View { .frame(maxWidth: .infinity, alignment: .center) .padding(UI.groupPadding) } - .frame(maxWidth: 420) - .frame(maxWidth: .infinity, alignment: .center) - } - } - - private var selectedAIModelBinding: Binding { - Binding( - get: { AIModel(rawValue: selectedAIModelRaw) ?? .appleIntelligence }, - set: { selectedAIModelRaw = $0.rawValue } - ) - } - -#if os(iOS) - private var moreTab: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: UI.space12) { - Text("More") - .font(.title3.weight(.semibold)) - Text("Themes and support settings") - .font(Typography.footnote) - .foregroundStyle(.secondary) - - HStack(spacing: UI.space8) { - moreSegmentButton(title: "Support", icon: "heart", tag: "support") - moreSegmentButton(title: "Themes", icon: "paintpalette", tag: "themes") - } - .padding(6) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color.secondary.opacity(0.12)) - ) - } - .padding(.horizontal, isCompactSettingsLayout ? UI.sidePaddingCompact : UI.sidePaddingRegular) - .padding(.top, 12) - .padding(.bottom, 4) .frame(maxWidth: .infinity, alignment: .leading) - - if settingsMoreSubtabIOS == "support" { - supportTab - } else { - themeTab - } } } - private func moreSegmentButton(title: String, icon: String, tag: String) -> some View { - let isSelected = settingsMoreSubtabIOS == tag - return Button { - settingsMoreSubtabIOS = tag - } label: { - HStack(spacing: 6) { - Image(systemName: icon) - Text(title) - .font(.callout.weight(.semibold)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .foregroundStyle(isSelected ? Color.blue : Color.primary) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(isSelected ? Color.blue.opacity(0.16) : Color.clear) - ) - } - .buttonStyle(.plain) - } -#endif - - private var supportTab: some View { - settingsContainer(maxWidth: 520) { + private var supportSection: some View { + VStack(spacing: 0) { GroupBox("Support Development") { VStack(alignment: .leading, spacing: UI.space12) { Text("In-App Purchase is optional and only used to support the app.") @@ -895,11 +997,13 @@ struct NeonSettingsView: View { Button(supportPurchaseManager.isPurchasing ? "Purchasing…" : "Support the App") { showSupportPurchaseDialog = true } + .buttonStyle(.borderedProminent) .disabled(supportPurchaseManager.isPurchasing || supportPurchaseManager.isLoadingProducts) Button("Refresh Price") { Task { await supportPurchaseManager.refreshPrice() } } + .buttonStyle(.bordered) .disabled(supportPurchaseManager.isLoadingProducts) } } else { @@ -945,7 +1049,7 @@ struct NeonSettingsView: View { HStack(alignment: .center, spacing: UI.space12) { Text("Check Interval") - .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) Picker("", selection: $updateCheckIntervalRaw) { ForEach(AppUpdateCheckInterval.allCases) { interval in Text(interval.title).tag(interval.rawValue) @@ -993,43 +1097,74 @@ struct NeonSettingsView: View { } #endif + private var dataDisclosureDialog: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: UI.space16) { + Text("Data Disclosure") + .font(.title3.weight(.semibold)) + + Text("The application does not collect analytics data, usage telemetry, advertising identifiers, device fingerprints, or background behavioral metrics. No automatic data transmission to developer-controlled servers occurs.") + Text("AI-assisted code completion is an optional feature. External network communication only occurs when a user explicitly enables AI completion and selects an external AI provider within the application settings.") + Text("When AI completion is triggered, the application transmits only the minimal contextual text necessary to generate a completion suggestion. This typically includes the code immediately surrounding the cursor position or the active selection.") + Text("The application does not automatically transmit full project folders, unrelated files, entire file system contents, contact data, location data, or device-specific identifiers.") + Text("Authentication credentials (API keys) for external AI providers are stored securely in the system keychain and are transmitted only to the user-selected provider for the purpose of completing the AI request.") + Text("All external communication is performed over encrypted HTTPS connections. If AI completion is disabled, the application performs no external AI-related network requests.") + } + .font(Typography.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(UI.space20) + } + .navigationTitle("AI Data Disclosure") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + showDataDisclosureDialog = false + } + } + } + #endif + } + } + + private func settingsSectionHeader(icon: String, title: String, subtitle: String) -> some View { + HStack(alignment: .top, spacing: UI.space12) { + Image(systemName: icon) + .font(.title3.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 28, alignment: .center) + VStack(alignment: .leading, spacing: UI.space6) { + Text(title) + .font(Typography.sectionTitle) + Text(subtitle) + .font(Typography.footnote) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private func settingsContainer(maxWidth: CGFloat = 560, @ViewBuilder _ content: () -> Content) -> some View { ScrollView { - VStack(alignment: isCompactSettingsLayout ? .leading : .center, spacing: UI.space20) { + VStack(alignment: settingsShouldUseLeadingAlignment ? .leading : .center, spacing: UI.space20) { content() } .frame(maxWidth: maxWidth, alignment: .center) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isCompactSettingsLayout ? .topLeading : .top) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: settingsShouldUseLeadingAlignment ? .topLeading : .top) .padding(.top, UI.topPadding) .padding(.bottom, UI.bottomPadding) .padding(.horizontal, isCompactSettingsLayout ? UI.sidePaddingCompact : UI.sidePaddingRegular) } - .background(settingsBackgroundStyle) - } - - private struct ReadableSettingsGroupBoxStyle: GroupBoxStyle { - func makeBody(configuration: Configuration) -> some View { - VStack(alignment: .leading, spacing: 10) { - configuration.label - .font(.headline) - configuration.content - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color.secondary.opacity(0.14)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.secondary.opacity(0.28), lineWidth: 1) - ) - } + .background(.ultraThinMaterial) } private func colorRow(title: String, color: Binding) -> some View { HStack { Text(title) - .frame(width: isCompactSettingsLayout ? nil : 120, alignment: .leading) + .frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading) ColorPicker("", selection: color) .labelsHidden() Spacer() @@ -1058,7 +1193,7 @@ struct NeonSettingsView: View { } else { HStack(spacing: UI.space12) { Text(title) - .frame(width: 120, alignment: .leading) + .frame(width: standardLabelWidth, alignment: .leading) SecureField(placeholder, text: value) .textFieldStyle(.plain) .padding(.vertical, UI.space6) @@ -1069,7 +1204,7 @@ struct NeonSettingsView: View { .stroke(Color.secondary.opacity(0.35), lineWidth: 1) ) .cornerRadius(UI.fieldCorner) - .frame(width: 200) + .frame(maxWidth: 360) .onChange(of: value.wrappedValue) { _, new in SecureTokenStore.setToken(new, for: provider) } @@ -1110,6 +1245,20 @@ struct NeonSettingsView: View { } } + private var settingsShouldUseLeadingAlignment: Bool { +#if os(iOS) + true +#else + isCompactSettingsLayout +#endif + } + + private func migrateLegacyPinkSettingsIfNeeded() { + if themeStringHex.uppercased() == "#FF7AD9" { + themeStringHex = "#4EA4FF" + } + } + private func templateOverrideKey(for language: String) -> String { "TemplateOverride_\(language)" } @@ -1182,6 +1331,37 @@ struct NeonSettingsView: View { set: { newColor in hex.wrappedValue = colorToHex(newColor) } ) } + + private func themePreviewSnippet(palette: ThemePaletteColors) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Preview") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text("func computeTotal(_ values: [Int]) -> Int {") + .foregroundStyle(palette.keyword) + Text(" let sum = values.reduce(0, +)") + .foregroundStyle(palette.text) + Text(" // tax adjustment") + .foregroundStyle(palette.comment) + Text(" return sum + 42") + .foregroundStyle(palette.number) + Text("}") + .foregroundStyle(palette.keyword) + } + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .padding(UI.space10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(palette.background) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(palette.selection.opacity(0.7), lineWidth: 1) + ) + ) + } + } } #if os(macOS) @@ -1216,7 +1396,11 @@ struct SettingsWindowConfigurator: NSViewRepresentable { let targetWidth = max(window.frame.size.width, idealSize.width) let targetHeight = max(window.frame.size.height, idealSize.height) if targetWidth != window.frame.size.width || targetHeight != window.frame.size.height { - window.setContentSize(NSSize(width: targetWidth, height: targetHeight)) + // Keep sizing in frame-space; mixing frame checks with content-size assignment + // causes growth when titlebar/translucency style changes. + var frame = window.frame + frame.size = NSSize(width: targetWidth, height: targetHeight) + window.setFrame(frame, display: true) } // Keep settings-window translucency in sync without relying on editor view events. diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index ae48978..304ed7b 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -5,6 +5,27 @@ import UniformTypeIdentifiers import AppKit #endif +enum NeonUIStyle { + static let accentBlue = Color(red: 0.17, green: 0.49, blue: 0.98) + static let accentBlueSoft = Color(red: 0.44, green: 0.72, blue: 0.99) + + static func surfaceFill(for scheme: ColorScheme) -> LinearGradient { + let top = scheme == .dark + ? Color(red: 0.09, green: 0.14, blue: 0.23).opacity(0.82) + : Color(red: 0.94, green: 0.97, blue: 1.00).opacity(0.94) + let bottom = scheme == .dark + ? Color(red: 0.06, green: 0.10, blue: 0.18).opacity(0.88) + : Color(red: 0.88, green: 0.94, blue: 1.00).opacity(0.96) + return LinearGradient(colors: [top, bottom], startPoint: .topLeading, endPoint: .bottomTrailing) + } + + static func surfaceStroke(for scheme: ColorScheme) -> Color { + scheme == .dark + ? accentBlueSoft.opacity(0.34) + : accentBlue.opacity(0.22) + } +} + struct PlainTextDocument: FileDocument { static var readableContentTypes: [UTType] { [.plainText, .text, .sourceCode] } diff --git a/Neon Vision Editor/UI/SidebarViews.swift b/Neon Vision Editor/UI/SidebarViews.swift index 3e43f02..836effe 100644 --- a/Neon Vision Editor/UI/SidebarViews.swift +++ b/Neon Vision Editor/UI/SidebarViews.swift @@ -4,9 +4,13 @@ import Foundation struct SidebarView: View { let content: String let language: String + @Environment(\.colorScheme) private var colorScheme + @State private var tocItems: [String] = ["No content available"] + @State private var tocRefreshTask: Task? + var body: some View { List { - ForEach(generateTableOfContents(), id: \.self) { item in + ForEach(tocItems, id: \.self) { item in Button { jump(to: item) } label: { @@ -23,6 +27,26 @@ struct SidebarView: View { .scrollContentBackground(.hidden) .background(Color.clear) .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(NeonUIStyle.surfaceFill(for: colorScheme)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(NeonUIStyle.surfaceStroke(for: colorScheme), lineWidth: 1) + ) + ) + .onAppear { + scheduleTOCRefresh() + } + .onChange(of: content) { _, _ in + scheduleTOCRefresh() + } + .onChange(of: language) { _, _ in + scheduleTOCRefresh() + } + .onDisappear { + tocRefreshTask?.cancel() + } } private func jump(to item: String) { @@ -39,8 +63,22 @@ struct SidebarView: View { } } + private func scheduleTOCRefresh() { + tocRefreshTask?.cancel() + let snapshotContent = content + let snapshotLanguage = language + tocRefreshTask = Task(priority: .utility) { + try? await Task.sleep(nanoseconds: 120_000_000) + guard !Task.isCancelled else { return } + let generated = SidebarView.generateTableOfContents(content: snapshotContent, language: snapshotLanguage) + await MainActor.run { + tocItems = generated + } + } + } + // Naive line-scanning TOC: looks for language-specific declarations or headers. - func generateTableOfContents() -> [String] { + static func generateTableOfContents(content: String, language: String) -> [String] { guard !content.isEmpty else { return ["No content available"] } if (content as NSString).length >= 400_000 { return ["Large file detected: TOC disabled for performance"] @@ -195,26 +233,15 @@ struct SidebarView: View { } struct ProjectStructureSidebarView: View { let rootFolderURL: URL? - @Binding var nodes: [ProjectTreeNode] + let nodes: [ProjectTreeNode] let selectedFileURL: URL? let translucentBackgroundEnabled: Bool let onOpenFile: () -> Void let onOpenFolder: () -> Void let onOpenProjectFile: (URL) -> Void let onRefreshTree: () -> Void - let onLoadChildren: (URL) -> [ProjectTreeNode] - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @AppStorage("SettingsLiquidGlassEnabled") private var liquidGlassEnabled: Bool = true - @State private var isLowPowerModeEnabled: Bool = ProcessInfo.processInfo.isLowPowerModeEnabled @State private var expandedDirectories: Set = [] - - private var shouldUseSidebarGlass: Bool { -#if os(iOS) - translucentBackgroundEnabled && liquidGlassEnabled && !reduceTransparency && !isLowPowerModeEnabled -#else - translucentBackgroundEnabled && liquidGlassEnabled && !reduceTransparency -#endif - } + @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -267,14 +294,16 @@ struct ProjectStructureSidebarView: View { } .listStyle(.sidebar) .scrollContentBackground(.hidden) - .background(shouldUseSidebarGlass ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear)) + .background(translucentBackgroundEnabled ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear)) } - .background(shouldUseSidebarGlass ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(Color.clear)) -#if os(iOS) - .onReceive(NotificationCenter.default.publisher(for: Notification.Name.NSProcessInfoPowerStateDidChange)) { _ in - isLowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled - } -#endif + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(NeonUIStyle.surfaceFill(for: colorScheme)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(NeonUIStyle.surfaceStroke(for: colorScheme), lineWidth: 1) + ) + ) } private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView { @@ -285,10 +314,6 @@ struct ProjectStructureSidebarView: View { set: { isExpanded in if isExpanded { expandedDirectories.insert(node.id) - if !node.isChildrenLoaded { - let children = onLoadChildren(node.url) - updateChildren(for: node.id, children: children) - } } else { expandedDirectories.remove(node.id) } @@ -327,29 +352,11 @@ struct ProjectStructureSidebarView: View { ) } } - - private func updateChildren(for nodeID: String, children: [ProjectTreeNode]) { - func patch(nodes: inout [ProjectTreeNode]) -> Bool { - for idx in nodes.indices { - if nodes[idx].id == nodeID { - nodes[idx].children = children - nodes[idx].isChildrenLoaded = true - return true - } - if patch(nodes: &nodes[idx].children) { - return true - } - } - return false - } - _ = patch(nodes: &nodes) - } } struct ProjectTreeNode: Identifiable { let url: URL let isDirectory: Bool var children: [ProjectTreeNode] - var isChildrenLoaded: Bool var id: String { url.path } } diff --git a/Neon Vision Editor/UI/ThemeSettings.swift b/Neon Vision Editor/UI/ThemeSettings.swift index 5a7b065..3385697 100644 --- a/Neon Vision Editor/UI/ThemeSettings.swift +++ b/Neon Vision Editor/UI/ThemeSettings.swift @@ -437,11 +437,11 @@ private func paletteForThemeName(_ name: String, defaults: UserDefaults) -> Them let cursor = colorFromHex(defaults.string(forKey: "SettingsThemeCursorColor") ?? "#4EA4FF", fallback: .blue) let selection = colorFromHex(defaults.string(forKey: "SettingsThemeSelectionColor") ?? "#2A3340", fallback: .gray) let keyword = colorFromHex(defaults.string(forKey: "SettingsThemeKeywordColor") ?? "#F5D90A", fallback: .yellow) - let string = colorFromHex(defaults.string(forKey: "SettingsThemeStringColor") ?? "#66B2FF", fallback: .blue) + let string = colorFromHex(defaults.string(forKey: "SettingsThemeStringColor") ?? "#4EA4FF", fallback: .blue) let number = colorFromHex(defaults.string(forKey: "SettingsThemeNumberColor") ?? "#FFB86C", fallback: .orange) let comment = colorFromHex(defaults.string(forKey: "SettingsThemeCommentColor") ?? "#7F8C98", fallback: .gray) let type = colorFromHex(defaults.string(forKey: "SettingsThemeTypeColor") ?? "#32D269", fallback: .green) - let builtin = colorFromHex(defaults.string(forKey: "SettingsThemeBuiltinColor") ?? "#4EA4FF", fallback: .blue) + let builtin = colorFromHex(defaults.string(forKey: "SettingsThemeBuiltinColor") ?? "#EC7887", fallback: .red) return ThemePalette( text: text, background: background,