diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index e2f8554..96bc9d6 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -259,7 +259,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 25; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -296,7 +296,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.2; PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -330,7 +330,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 25; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -367,7 +367,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.2; PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme b/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme new file mode 100644 index 0000000..ee839cb --- /dev/null +++ b/Neon Vision Editor.xcodeproj/xcshareddata/xcschemes/Neon Vision Editor.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Neon Vision Editor.xcodeproj/xcuserdata/h3p.xcuserdatad/xcschemes/xcschememanagement.plist b/Neon Vision Editor.xcodeproj/xcuserdata/h3p.xcuserdatad/xcschemes/xcschememanagement.plist index 08b3943..b6ff149 100644 --- a/Neon Vision Editor.xcodeproj/xcuserdata/h3p.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Neon Vision Editor.xcodeproj/xcuserdata/h3p.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,13 @@ 0 + SuppressBuildableAutocreation + + 98EAE6322E5F15E80050E579 + + primary + + + diff --git a/Neon Vision Editor/ContentView.swift b/Neon Vision Editor/ContentView.swift index ecfc14a..9a09579 100644 --- a/Neon Vision Editor/ContentView.swift +++ b/Neon Vision Editor/ContentView.swift @@ -1,8 +1,13 @@ -// FIXES APPLIED: Consistent rename and content persistence for tab creation and language updates +// Simplified: Single-document editor without tabs; removed AIModel references and fixed compile errors import SwiftUI -<<<<<<< HEAD import AppKit +enum AIModel: String, CaseIterable, Identifiable { + case appleIntelligence + case grok + var id: String { rawValue } +} + // Extension to calculate string width extension String { func width(usingFont font: NSFont) -> CGFloat { @@ -17,23 +22,22 @@ struct ContentView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.showGrokError) private var showGrokError @Environment(\.grokErrorMessage) private var grokErrorMessage - @Environment(\.selectedAIModel) private var selectedAIModel - + + // Fallback single-document state in case the view model doesn't expose one + @State private var selectedModel: AIModel = .appleIntelligence @State private var singleContent: String = "" @State private var singleLanguage: String = "swift" - + @State private var caretStatus: String = "Ln 1, Col 1" + @State private var editorFontSize: CGFloat = 14 + var body: some View { NavigationSplitView { sidebarView } detail: { editorView } + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600) .frame(minWidth: 600, minHeight: 400) - .background(.ultraThinMaterial) - .overlay(.ultraThinMaterial.opacity(0.2)) // Fallback for liquidGlassEffect - .sheet(isPresented: $viewModel.showingRename) { - renameSheet - } .alert("AI Error", isPresented: showGrokError) { Button("OK") { } } message: { @@ -41,62 +45,110 @@ struct ContentView: View { } .navigationTitle("NeonVision Editor") } - + @ViewBuilder private var sidebarView: some View { if viewModel.showSidebar && !viewModel.isBrainDumpMode { - SidebarView(content: (viewModel.selectedTab?.content ?? singleContent), - language: (viewModel.selectedTab?.language ?? singleLanguage)) - .frame(minWidth: 200, idealWidth: 250) - .background(.ultraThinMaterial) - .overlay(.ultraThinMaterial.opacity(0.2)) + SidebarView(content: currentContent, + language: currentLanguage) + .frame(minWidth: 200, idealWidth: 250, maxWidth: 600) .animation(.spring(), value: viewModel.showSidebar) .safeAreaInset(edge: .bottom) { Divider() } } } - + + private var currentContentBinding: Binding { + if let tab = viewModel.selectedTab { + return Binding( + get: { tab.content }, + set: { newValue in viewModel.updateTabContent(tab: tab, content: newValue) } + ) + } else { + return $singleContent + } + } + + private var currentLanguageBinding: Binding { + if let selectedID = viewModel.selectedTabID, let idx = viewModel.tabs.firstIndex(where: { $0.id == selectedID }) { + return Binding( + get: { viewModel.tabs[idx].language }, + set: { newValue in viewModel.tabs[idx].language = newValue } + ) + } else { + return $singleLanguage + } + } + + private var currentContent: String { currentContentBinding.wrappedValue } + private var currentLanguage: String { currentLanguageBinding.wrappedValue } + @ViewBuilder private var editorView: some View { VStack(spacing: 0) { - tabViewContent + // Single editor (no TabView) + CustomTextEditor( + text: currentContentBinding, + language: currentLanguage, + colorScheme: colorScheme, + fontSize: editorFontSize, + isLineWrapEnabled: $viewModel.isLineWrapEnabled + ) + .frame(maxWidth: viewModel.isBrainDumpMode ? 800 : .infinity) + .frame(maxHeight: .infinity) + .padding(.horizontal, viewModel.isBrainDumpMode ? 100 : 0) + .padding(.vertical, viewModel.isBrainDumpMode ? 40 : 0) + if !viewModel.isBrainDumpMode { wordCountView } } + .onReceive(NotificationCenter.default.publisher(for: .caretPositionDidChange)) { notif in + if let line = notif.userInfo?["line"] as? Int, let col = notif.userInfo?["column"] as? Int { + caretStatus = "Ln \(line), Col \(col)" + } + } .toolbar { - ToolbarItem(placement: .primaryAction) { - Picker("Language", selection: Binding( - get: { - viewModel.selectedTab?.language ?? singleLanguage - }, - set: { newLang in - if let selectedID = viewModel.selectedTabID, let idx = viewModel.tabs.firstIndex(where: { $0.id == selectedID }) { - viewModel.tabs[idx].language = newLang - } else { - singleLanguage = newLang + ToolbarItemGroup(placement: .primaryAction) { + HStack(spacing: 8) { + Picker("AI Model", selection: $selectedModel) { + Text("Apple Intelligence").tag(AIModel.appleIntelligence) + Text("Grok").tag(AIModel.grok) + } + .labelsHidden() + .controlSize(.large) + .frame(width: 170) + .padding(.vertical, 2) + + Picker("Language", selection: currentLanguageBinding) { + ForEach(["swift", "python", "javascript", "html", "css", "c", "cpp", "json", "markdown"], id: \.self) { lang in + Text(lang.capitalized).tag(lang) } } - )) { - ForEach(["swift", "python", "javascript", "html", "css", "c", "cpp", "json", "markdown"], id: \.self) { lang in - Text(lang.capitalized).tag(lang) + .labelsHidden() + .controlSize(.large) + .frame(width: 140) + .padding(.vertical, 2) + + Divider() + Button(action: { editorFontSize = max(8, editorFontSize - 1) }) { + Image(systemName: "textformat.size.smaller") } + .help("Decrease Font Size") + Button(action: { editorFontSize = min(48, editorFontSize + 1) }) { + Image(systemName: "textformat.size.larger") + } + .help("Increase Font Size") } - .frame(width: 150) - } - ToolbarItem(placement: .primaryAction) { - Picker("AI Model", selection: selectedAIModel) { - Text("Apple Intelligence").tag(AIModel.appleIntelligence) - Text("Grok").tag(AIModel.grok) - } - .frame(width: 150) } ToolbarItemGroup(placement: .automatic) { Button(action: { viewModel.openFile() }) { Image(systemName: "folder") } - Button(action: { if let tab = viewModel.selectedTab { viewModel.saveFile(tab: tab) } }) { + Button(action: { + if let tab = viewModel.selectedTab { viewModel.saveFile(tab: tab) } + }) { Image(systemName: "square.and.arrow.down") } .disabled(viewModel.selectedTab == nil) @@ -108,289 +160,81 @@ struct ContentView: View { } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) // Added to expand editorView + .toolbarBackground(.visible, for: .windowToolbar) + .toolbarBackground(Color(nsColor: .windowBackgroundColor), for: .windowToolbar) } - - @ViewBuilder - private var tabViewContent: some View { - VStack(spacing: 0) { - CustomTextEditor( - text: Binding( - get: { - if let selID = viewModel.selectedTabID, let tab = viewModel.tabs.first(where: { $0.id == selID }) { - return tab.content - } else { - return singleContent - } - }, - set: { newValue in - if let selID = viewModel.selectedTabID, let tab = viewModel.tabs.first(where: { $0.id == selID }) { - viewModel.updateTabContent(tab: tab, content: newValue) - } else { - singleContent = newValue - } - } - ), - language: viewModel.selectedTab?.language ?? singleLanguage, - colorScheme: colorScheme - ) - .frame(maxWidth: viewModel.isBrainDumpMode ? 800 : .infinity) - .padding(viewModel.isBrainDumpMode ? .horizontal : [], 100) -======= -import AppKit // Added for NSFont and NSAttributedString - -// Extension to calculate string width -extension String { - func width(usingFont font: NSFont) -> CGFloat { - let attributes = [NSAttributedString.Key.font: font] - let size = (self as NSString).size(withAttributes: attributes) - return size.width - } -} - -struct ContentView: View { - @EnvironmentObject private var viewModel: EditorViewModel - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - NavigationSplitView { - // Sidebar - if viewModel.showSidebar && !viewModel.isBrainDumpMode { - SidebarView(content: viewModel.selectedTab?.content ?? "", - language: viewModel.selectedTab?.language ?? "swift") - .frame(minWidth: 200) - .toolbar { - ToolbarItem { - Picker("Language", selection: Binding( - get: { viewModel.selectedTab?.language ?? "swift" }, - set: { if let tab = viewModel.selectedTab { viewModel.updateTabLanguage(tab: tab, language: $0) } - })) { - ForEach(["swift", "python", "javascript", "html", "css", "c", "cpp", "json", "markdown"], id: \.self) { lang in - Text(lang.capitalized).tag(lang) - } - } - } - } - } - } detail: { - // Main Editor with Tabs - VStack { - if !viewModel.tabs.isEmpty { - TabView(selection: $viewModel.selectedTabID) { - ForEach(viewModel.tabs) { tab in - HighlightedTextEditor( - text: Binding( - get: { tab.content }, - set: { viewModel.updateTabContent(tab: tab, content: $0) } - ), - language: tab.language, - colorScheme: colorScheme - ) - .frame(maxWidth: viewModel.isBrainDumpMode ? 800 : .infinity) - .padding(viewModel.isBrainDumpMode ? .horizontal : [], 100) - .tabItem { - Text(tab.name + (tab.fileURL == nil && !tab.content.isEmpty ? " *" : "")) - } - .tag(tab.id) - } - } - - if !viewModel.isBrainDumpMode { - HStack { - Spacer() - Text("Words: \(viewModel.wordCount(for: viewModel.selectedTab?.content ?? ""))") - .foregroundColor(.secondary) - .padding(.bottom, 8) - .padding(.trailing, 8) - } - } - } else { - Text("Select a tab or create a new one") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .toolbar { - ToolbarItemGroup { - Button(action: { viewModel.addNewTab(); viewModel.showingRename = true; viewModel.renameText = viewModel.selectedTab?.name ?? "Untitled" }) { - Image(systemName: "plus") - } - Button(action: { viewModel.openFile() }) { - Image(systemName: "folder") - } - Button(action: { if let tab = viewModel.selectedTab { viewModel.saveFile(tab: tab) } }) { - Image(systemName: "square.and.arrow.down") - } - .disabled(viewModel.selectedTab == nil) - Button(action: { viewModel.showSidebar.toggle() }) { - Image(systemName: viewModel.showSidebar ? "sidebar.left" : "sidebar.right") - } - Button(action: { viewModel.isBrainDumpMode.toggle() }) { - Image(systemName: "note.text") - } - } - } - } - .frame(minWidth: 600, minHeight: 400) - .background(.ultraThinMaterial) - .sheet(isPresented: $viewModel.showingRename) { - VStack { - Text("Rename Tab") - .font(.headline) - TextField("Name", text: $viewModel.renameText) - .textFieldStyle(.roundedBorder) - .padding() - HStack { - Button("Cancel") { viewModel.showingRename = false } - Button("OK") { - if let tab = viewModel.selectedTab { - viewModel.renameTab(tab: tab, newName: viewModel.renameText) - } - viewModel.showingRename = false - } - .disabled(viewModel.renameText.isEmpty) - } - } - .padding() - .frame(width: 300) - } - .navigationTitle(viewModel.selectedTab?.name ?? "NeonVision Editor") - } -} - -struct SidebarView: View { - let content: String - let language: String - @State private var selectedTOCItem: String? - - var body: some View { - List(generateTableOfContents(), id: \.self, selection: $selectedTOCItem) { item in - Text(item) - .foregroundColor(.secondary) - .padding(.vertical, 2) - .onTapGesture { - if let lineNumber = lineNumber(for: item) { - NotificationCenter.default.post(name: .moveCursorToLine, object: lineNumber) - } - } ->>>>>>> main - } - .listStyle(.sidebar) - } - -<<<<<<< HEAD @ViewBuilder private var wordCountView: some View { HStack { Spacer() - Text("Words: \(viewModel.wordCount(for: viewModel.selectedTab?.content ?? ""))") + Text("\(caretStatus) • Words: \(viewModel.wordCount(for: currentContent))") .font(.system(size: 12)) .foregroundColor(.secondary) .padding(.bottom, 8) .padding(.trailing, 16) } } - - @ViewBuilder - private var renameSheet: some View { - VStack { - Text("Rename Tab") - .font(.headline) - TextField("Name", text: $viewModel.renameText) - .textFieldStyle(.roundedBorder) - .padding() - HStack(spacing: 12) { - Button("Cancel") { - viewModel.showingRename = false - } - .buttonStyle(.bordered) - - Button("OK") { - // Ensure we have a selected tab; if not, select the first available tab - if viewModel.selectedTab == nil, let first = viewModel.tabs.first { - viewModel.selectedTabID = first.id - } - if let tab = viewModel.selectedTab { - if viewModel.selectedTabID != tab.id { - viewModel.selectedTabID = tab.id - } - viewModel.renameTab(tab: tab, newName: viewModel.renameText) - } - viewModel.showingRename = false - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.renameText.isEmpty) - } - } - .padding() - .frame(width: 320) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .shadow(radius: 12) - .interactiveDismissDisabled(false) - .allowsHitTesting(true) - } } struct SidebarView: View { let content: String let language: String @State private var selectedTOCItem: String? - + var body: some View { List(generateTableOfContents(), id: \.self, selection: $selectedTOCItem) { item in - Text(item) - .font(.system(size: 13)) - .foregroundColor(.primary) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .onTapGesture { - if let lineNumber = lineNumber(for: item) { - NotificationCenter.default.post(name: .moveCursorToLine, object: lineNumber) + Button(action: { + // Expect item format: "... (Line N)" + if let startRange = item.range(of: "(Line "), + let endRange = item.range(of: ")", range: startRange.upperBound.. 0 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .moveCursorToLine, object: lineOneBased) + } } } + }) { + Text(item) + .font(.system(size: 13)) + .foregroundColor(.primary) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .tag(item) + } + .buttonStyle(.plain) } .listStyle(.sidebar) .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: selectedTOCItem) { oldValue, newValue in + guard let item = newValue else { return } + if let startRange = item.range(of: "(Line "), + let endRange = item.range(of: ")", range: startRange.upperBound.. 0 { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .moveCursorToLine, object: lineOneBased) + } + } + } + } } - + func generateTableOfContents() -> [String] { guard !content.isEmpty else { return ["No content available"] } let lines = content.components(separatedBy: .newlines) var toc: [String] = [] - + switch language { case "swift": toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("func ") || trimmed.hasPrefix("struct ") || trimmed.hasPrefix("class ") || trimmed.hasPrefix("enum ") { -======= - func generateTableOfContents() -> [String] { - if content.isEmpty { - return ["No content"] - } - let lines = content.components(separatedBy: .newlines) - switch language { - case "swift": - return lines.enumerated().compactMap { index, line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("func") || trimmed.hasPrefix("struct") || - trimmed.hasPrefix("class") || trimmed.hasPrefix("enum") { return "\(trimmed) (Line \(index + 1))" } return nil } - case "python": - return lines.enumerated().compactMap { index, line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("def") || trimmed.hasPrefix("class") { ->>>>>>> main - return "\(trimmed) (Line \(index + 1))" - } - return nil - } -<<<<<<< HEAD case "python": toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -403,36 +247,20 @@ struct SidebarView: View { toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("function ") || trimmed.hasPrefix("class ") { -======= - case "javascript": - return lines.enumerated().compactMap { index, line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("function") || trimmed.hasPrefix("class") { ->>>>>>> main return "\(trimmed) (Line \(index + 1))" } return nil } case "c", "cpp": -<<<<<<< HEAD toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.contains("(") && !trimmed.contains(";") && (trimmed.hasPrefix("void ") || trimmed.hasPrefix("int ") || trimmed.hasPrefix("float ") || trimmed.hasPrefix("double ") || trimmed.hasPrefix("char ") || trimmed.contains("{")) { -======= - return lines.enumerated().compactMap { index, line in - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.contains("(") && !trimmed.contains(";") { ->>>>>>> main return "\(trimmed) (Line \(index + 1))" } return nil } case "html", "css", "json", "markdown": -<<<<<<< HEAD toc = lines.enumerated().compactMap { index, line in -======= - return lines.enumerated().compactMap { index, line in ->>>>>>> main let trimmed = line.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty && (trimmed.hasPrefix("#") || trimmed.hasPrefix(">>>>>> main } - + return toc.isEmpty ? ["No headers found"] : toc } - - func lineNumber(for item: String) -> Int? { - let lines = content.components(separatedBy: .newlines) - return lines.firstIndex { $0.trimmingCharacters(in: .whitespaces) == item.components(separatedBy: " (Line").first } -<<<<<<< HEAD +} + +final class AcceptingTextView: NSTextView { + override var acceptsFirstResponder: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + override var isOpaque: Bool { false } + + override func insertText(_ insertString: Any, replacementRange: NSRange) { + guard let s = insertString as? String else { + super.insertText(insertString, replacementRange: replacementRange) + return + } + if s == "\n" { + // Auto-indent: copy leading whitespace from current line + let ns = (string as NSString) + let sel = selectedRange() + let lineRange = ns.lineRange(for: NSRange(location: sel.location, length: 0)) + let currentLine = ns.substring(with: NSRange(location: lineRange.location, length: sel.location - lineRange.location)) + let indent = currentLine.prefix { $0 == " " || $0 == "\t" } + super.insertText("\n" + indent, replacementRange: replacementRange) + return + } + // Bracket/quote pairing + let pairs: [String: String] = ["(": ")", "[": "]", "{": "}", "\"": "\"", "'": "'"] + if let closing = pairs[s] { + let sel = selectedRange() + super.insertText(s + closing, replacementRange: replacementRange) + setSelectedRange(NSRange(location: sel.location + 1, length: 0)) + return + } + super.insertText(insertString, replacementRange: replacementRange) } } @@ -461,131 +311,318 @@ struct CustomTextEditor: NSViewRepresentable { @Binding var text: String let language: String let colorScheme: ColorScheme - + let fontSize: CGFloat + @Binding var isLineWrapEnabled: Bool + + private func applyWrapMode(isWrapped: Bool, textView: NSTextView, scrollView: NSScrollView) { + if isWrapped { + // Wrap: track the text view width, no horizontal scrolling + textView.isHorizontallyResizable = false + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + scrollView.hasHorizontalScroller = false + // Ensure the container width matches the visible content width + let contentWidth = scrollView.contentSize.width + let width = contentWidth > 0 ? contentWidth : scrollView.frame.size.width + textView.textContainer?.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) + } else { + // No wrap: allow horizontal expansion and horizontal scrolling + textView.isHorizontallyResizable = true + textView.textContainer?.widthTracksTextView = false + textView.textContainer?.heightTracksTextView = false + scrollView.hasHorizontalScroller = true + textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + } + } + func makeNSView(context: Context) -> NSScrollView { - // Use AppKit's factory to get a properly configured scroll view + text view - let scrollView = NSTextView.scrollableTextView() - let textView = scrollView.documentView as! NSTextView + // Use AppKit's factory to get a correctly configured scrollable plain text editor + let scrollView = NSTextView.scrollablePlainDocumentContentTextView() + scrollView.drawsBackground = false + scrollView.autohidesScrollers = true + scrollView.hasVerticalScroller = true + scrollView.contentView.postsBoundsChangedNotifications = true - // Configure text view + guard let textView = scrollView.documentView as? NSTextView else { + return scrollView + } + + // Configure the text view textView.isEditable = true - textView.isRulerVisible = false - textView.font = NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) - textView.backgroundColor = .clear - textView.textContainerInset = NSSize(width: 12, height: 12) - textView.textColor = .labelColor - - // Plain text configuration and initial value textView.isRichText = false - textView.usesRuler = false textView.usesFindBar = true - textView.allowsUndo = true - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDataDetectionEnabled = false - textView.isAutomaticLinkDetectionEnabled = false - textView.string = self.text - - // Disable smart replacements/spell checking for code - textView.textContainer?.lineFragmentPadding = 0 - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - - // Sizing behavior: allow vertical growth and wrap to width - textView.minSize = NSSize(width: 0, height: 0) - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isVerticallyResizable = true textView.isHorizontallyResizable = false - textView.autoresizingMask = [.width] - textView.postsFrameChangedNotifications = true + textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + textView.backgroundColor = .textBackgroundColor + textView.textContainerInset = NSSize(width: 12, height: 12) + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isSelectable = true + textView.allowsUndo = true + textView.textColor = .labelColor + textView.insertionPointColor = .controlAccentColor + textView.drawsBackground = true + textView.isAutomaticTextCompletionEnabled = false - if let container = textView.textContainer { - container.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) - container.widthTracksTextView = true - container.heightTracksTextView = false - } + // Disable smart substitutions/detections that can interfere with selection when recoloring + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticDataDetectionEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + textView.isGrammarCheckingEnabled = false + textView.isContinuousSpellCheckingEnabled = false + textView.smartInsertDeleteEnabled = false - // Configure scroll view - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.backgroundColor = .clear - - // Keep container width in sync with scroll view size changes - scrollView.contentView.postsBoundsChangedNotifications = true - NotificationCenter.default.addObserver(context.coordinator, - selector: #selector(context.coordinator.scrollViewBoundsDidChange(_:)), - name: NSView.boundsDidChangeNotification, - object: scrollView.contentView) - - // Coordinator and notifications textView.delegate = context.coordinator - NotificationCenter.default.addObserver(context.coordinator, selector: #selector(context.coordinator.updateTextContainerSize), name: NSView.frameDidChangeNotification, object: textView) - context.coordinator.textView = textView - // Apply initial syntax highlighting - context.coordinator.applySyntaxHighlighting() + // Add line number ruler + scrollView.hasVerticalRuler = true + scrollView.rulersVisible = true + scrollView.verticalRulerView = LineNumberRulerView(textView: textView) - DispatchQueue.main.async { - textView.window?.makeFirstResponder(textView) + // Apply wrapping mode configuration + applyWrapMode(isWrapped: isLineWrapEnabled, textView: textView, scrollView: scrollView) + + // Seed initial text + textView.string = text + DispatchQueue.main.async { [weak scrollView, weak textView] in + guard let sv = scrollView, let tv = textView else { return } + sv.window?.makeFirstResponder(tv) } + context.coordinator.scheduleHighlightIfNeeded(currentText: text) - return scrollView - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - if let textView = nsView.documentView as? NSTextView { - // Only push SwiftUI -> AppKit when the source of truth changed - if textView.string != self.text { - textView.string = self.text - context.coordinator.applySyntaxHighlighting() - } - // Do not write back here. Coordinator's textDidChange handles AppKit -> SwiftUI updates. - - if let container = textView.textContainer { - let width = nsView.contentSize.width - if container.containerSize.width != width { - container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - container.widthTracksTextView = true + NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main) { [weak textView, weak scrollView] _ in + guard let tv = textView, let sv = scrollView else { return } + if tv.textContainer?.widthTracksTextView == true { + tv.textContainer?.containerSize.width = sv.contentSize.width + if let container = tv.textContainer { + tv.layoutManager?.ensureLayout(for: container) } } + } + + context.coordinator.textView = textView + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + if let textView = nsView.documentView as? NSTextView { + if textView.string != text { + textView.string = text + } + if textView.font?.pointSize != fontSize { + textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + } + // Keep the text container width in sync & relayout + applyWrapMode(isWrapped: isLineWrapEnabled, textView: textView, scrollView: nsView) + if let container = textView.textContainer { + textView.layoutManager?.ensureLayout(for: container) + } textView.invalidateIntrinsicContentSize() - textView.layoutManager?.ensureLayout(for: textView.textContainer!) + // Only schedule highlight if needed (e.g., language/color scheme changes or external text updates) + context.coordinator.scheduleHighlightIfNeeded() } } - + func makeCoordinator() -> Coordinator { Coordinator(self) } - + class Coordinator: NSObject, NSTextViewDelegate { var parent: CustomTextEditor weak var textView: NSTextView? - + + private let highlightQueue = DispatchQueue(label: "NeonVision.SyntaxHighlight", qos: .userInitiated) + private var pendingHighlight: DispatchWorkItem? + private var lastHighlightedText: String = "" + private var lastLanguage: String? + private var lastColorScheme: ColorScheme? + init(_ parent: CustomTextEditor) { self.parent = parent super.init() NotificationCenter.default.addObserver(self, selector: #selector(moveToLine(_:)), name: .moveCursorToLine, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(streamSuggestion(_:)), name: .streamSuggestion, object: nil) } - - @objc func moveToLine(_ notification: Notification) { - guard let lineNumber = notification.object as? Int, - let textView = textView, - !parent.text.isEmpty else { return } - - let lines = parent.text.components(separatedBy: .newlines) - guard lineNumber >= 0 && lineNumber < lines.count else { return } - - let lineStart = lines[0.. 0 ? 1 : 0) - textView.setSelectedRange(NSRange(location: lineStart, length: 0)) - textView.scrollRangeToVisible(NSRange(location: lineStart, length: 0)) + + deinit { + NotificationCenter.default.removeObserver(self) } - + + func scheduleHighlightIfNeeded(currentText: String? = nil) { + guard textView != nil else { return } + // Defer highlighting while a modal panel is presented (e.g., NSSavePanel) + if NSApp.modalWindow != nil { + pendingHighlight?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.scheduleHighlightIfNeeded(currentText: currentText) + } + pendingHighlight = work + highlightQueue.asyncAfter(deadline: .now() + 0.3, execute: work) + return + } + + let lang = parent.language + let scheme = parent.colorScheme + let text = currentText ?? textView?.string ?? "" + if text == lastHighlightedText && lastLanguage == lang && lastColorScheme == scheme { + return + } + rehighlight() + } + + func rehighlight() { + guard let textView = textView else { return } + // Snapshot current state + let textSnapshot = textView.string + let language = parent.language + let scheme = parent.colorScheme + let selected = textView.selectedRange() + let colors = SyntaxColors.fromVibrantLightTheme(colorScheme: scheme) + let patterns = getSyntaxPatterns(for: language, colors: colors) + + // Cancel any in-flight work + pendingHighlight?.cancel() + + let work = DispatchWorkItem { [weak self] in + // Compute matches off the main thread + let nsText = textSnapshot as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + var coloredRanges: [(NSRange, Color)] = [] + for (pattern, color) in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { continue } + let matches = regex.matches(in: textSnapshot, range: fullRange) + for match in matches { + coloredRanges.append((match.range, color)) + } + } + + DispatchQueue.main.async { [weak self] in + guard let self = self, let tv = self.textView else { return } + // Discard if text changed since we started + guard tv.string == textSnapshot else { return } + + tv.textStorage?.beginEditing() + // Clear previous coloring and apply base color + tv.textStorage?.removeAttribute(.foregroundColor, range: fullRange) + tv.textStorage?.addAttribute(.foregroundColor, value: tv.textColor ?? NSColor.labelColor, range: fullRange) + // Apply colored ranges + for (range, color) in coloredRanges { + tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range) + } + tv.textStorage?.endEditing() + + // Restore selection only if it hasn't changed since we started + if NSEqualRanges(tv.selectedRange(), selected) { + tv.setSelectedRange(selected) + } + + // Update last highlighted state + self.lastHighlightedText = textSnapshot + self.lastLanguage = language + self.lastColorScheme = scheme + } + } + + pendingHighlight = work + // Debounce slightly to avoid thrashing while typing + highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work) + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + updateCaretStatusAndHighlight() + scheduleHighlightIfNeeded(currentText: parent.text) + } + + func textViewDidChangeSelection(_ notification: Notification) { + updateCaretStatusAndHighlight() + } + + private func updateCaretStatusAndHighlight() { + guard let tv = textView else { return } + let ns = tv.string as NSString + let sel = tv.selectedRange() + let location = sel.location + let prefix = ns.substring(to: min(location, ns.length)) + let line = prefix.reduce(1) { $1 == "\n" ? $0 + 1 : $0 } + let col: Int = { + if let lastNL = prefix.lastIndex(of: "\n") { + return prefix.distance(from: lastNL, to: prefix.endIndex) - 1 + } else { + return prefix.count + } + }() + NotificationCenter.default.post(name: .caretPositionDidChange, object: nil, userInfo: ["line": line, "column": col]) + + // Highlight current line + let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) + let fullRange = NSRange(location: 0, length: ns.length) + tv.textStorage?.beginEditing() + tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange) + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange) + tv.textStorage?.endEditing() + } + + @objc func moveToLine(_ notification: Notification) { + guard let lineOneBased = notification.object as? Int, + let textView = textView else { return } + + // If there's no text, nothing to do + let currentText = textView.string + guard !currentText.isEmpty else { return } + + // Cancel any in-flight highlight to prevent it from restoring an old selection + pendingHighlight?.cancel() + + // Work with NSString/UTF-16 indices to match NSTextView expectations + let ns = currentText as NSString + let totalLength = ns.length + + // Clamp target line to available line count (1-based input) + let linesArray = currentText.components(separatedBy: .newlines) + let clampedLineIndex = max(1, min(lineOneBased, linesArray.count)) - 1 // 0-based index + + // Compute the UTF-16 location by summing UTF-16 lengths of preceding lines + newline characters + var location = 0 + if clampedLineIndex > 0 { + for i in 0..<(clampedLineIndex) { + let lineNSString = linesArray[i] as NSString + location += lineNSString.length + // Add one for the newline that separates lines, as components(separatedBy:) drops separators + location += 1 + } + } + // Safety clamp + location = max(0, min(location, totalLength)) + + // Move caret and scroll into view on the main thread + DispatchQueue.main.async { [weak self] in + guard let self = self, let tv = self.textView else { return } + tv.window?.makeFirstResponder(tv) + // Ensure layout is up-to-date before scrolling + if let container = tv.textContainer { + tv.layoutManager?.ensureLayout(for: container) + } + tv.setSelectedRange(NSRange(location: location, length: 0)) + tv.scrollRangeToVisible(NSRange(location: location, length: 0)) + + // Stronger highlight for the entire target line + let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) + let fullRange = NSRange(location: 0, length: totalLength) + tv.textStorage?.beginEditing() + tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange) + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.18), range: lineRange) + tv.textStorage?.endEditing() + } + } + @objc func streamSuggestion(_ notification: Notification) { guard let stream = notification.object as? AsyncStream, let textView = textView else { return } - + Task { for await chunk in stream { textView.textStorage?.append(NSAttributedString(string: chunk)) @@ -594,51 +631,50 @@ struct CustomTextEditor: NSViewRepresentable { } } } - - @objc func updateTextContainerSize() { - if let tv = textView, let sv = tv.enclosingScrollView { - tv.textContainer?.containerSize = NSSize(width: sv.contentSize.width, height: .greatestFiniteMagnitude) - tv.textContainer?.widthTracksTextView = true - } + } +} + +final class LineNumberRulerView: NSRulerView { + private weak var textView: NSTextView? + private let font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + private let textColor = NSColor.secondaryLabelColor + private let inset: CGFloat = 4 + + init(textView: NSTextView) { + self.textView = textView + super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler) + self.clientView = textView + self.ruleThickness = 44 + NotificationCenter.default.addObserver(self, selector: #selector(redraw), name: NSText.didChangeNotification, object: textView) + NotificationCenter.default.addObserver(self, selector: #selector(redraw), name: NSView.boundsDidChangeNotification, object: scrollView?.contentView) + } + + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + @objc private func redraw() { needsDisplay = true } + + override func drawHashMarksAndLabels(in rect: NSRect) { + guard let tv = textView, let lm = tv.layoutManager, let tc = tv.textContainer else { return } + let ctx = NSString(string: tv.string) + let visibleGlyphRange = lm.glyphRange(forBoundingRect: tv.visibleRect, in: tc) + var lineNumber = 1 + if visibleGlyphRange.location > 0 { + let charIndex = lm.characterIndexForGlyph(at: visibleGlyphRange.location) + let prefix = ctx.substring(to: charIndex) + lineNumber = prefix.reduce(1) { $1 == "\n" ? $0 + 1 : $0 } } - - @objc func scrollViewBoundsDidChange(_ notification: Notification) { - if let tv = textView, let sv = tv.enclosingScrollView { - tv.textContainer?.containerSize = NSSize(width: sv.contentSize.width, height: .greatestFiniteMagnitude) - tv.textContainer?.widthTracksTextView = true - tv.invalidateIntrinsicContentSize() - tv.layoutManager?.ensureLayout(for: tv.textContainer!) - } - } - - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? NSTextView else { return } - parent.text = textView.string - - if let container = textView.textContainer, let scrollView = textView.enclosingScrollView { - container.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) - container.widthTracksTextView = true - textView.invalidateIntrinsicContentSize() - textView.layoutManager?.ensureLayout(for: container) - } - - applySyntaxHighlighting() - } - - func applySyntaxHighlighting() { - guard let textView = textView else { return } - let fullRange = NSRange(location: 0, length: (textView.string as NSString).length) - // Replace the line below with adaptive label color instead of removing attribute - textView.textStorage?.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) - let colors = SyntaxColors.fromVibrantLightTheme(colorScheme: parent.colorScheme) - let patterns = getSyntaxPatterns(for: parent.language, colors: colors) - for (pattern, color) in patterns { - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { continue } - let matches = regex.matches(in: textView.string, range: fullRange) - for match in matches { - textView.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: match.range) - } - } + var glyphIndex = visibleGlyphRange.location + while glyphIndex < visibleGlyphRange.upperBound { + var effectiveRange = NSRange(location: 0, length: 0) + let lineRect = lm.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveRange, withoutAdditionalLayout: true) + let y = (lineRect.minY - tv.visibleRect.origin.y) + 2 - tv.textContainerInset.height + let numberString = "\(lineNumber)" as NSString + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] + let size = numberString.size(withAttributes: attributes) + let drawPoint = NSPoint(x: bounds.maxX - size.width - inset, y: y) + numberString.draw(at: drawPoint, withAttributes: attributes) + glyphIndex = effectiveRange.upperBound + lineNumber += 1 } } } @@ -657,7 +693,7 @@ struct SyntaxColors { let atom: Color let builtin: Color let type: Color - + static func fromVibrantLightTheme(colorScheme: ColorScheme) -> SyntaxColors { let baseColors: [String: (light: Color, dark: Color)] = [ "keyword": (light: Color(red: 251/255, green: 0/255, blue: 186/255), dark: Color(red: 251/255, green: 0/255, blue: 186/255)), @@ -674,7 +710,7 @@ struct SyntaxColors { "builtin": (light: Color(red: 255/255, green: 130/255, blue: 0/255), dark: Color(red: 255/255, green: 130/255, blue: 0/255)), "type": (light: Color(red: 170/255, green: 0/255, blue: 160/255), dark: Color(red: 170/255, green: 0/255, blue: 160/255)) ] - + return SyntaxColors( keyword: colorScheme == .dark ? baseColors["keyword"]!.dark : baseColors["keyword"]!.light, string: colorScheme == .dark ? baseColors["string"]!.dark : baseColors["string"]!.light, @@ -697,14 +733,55 @@ func getSyntaxPatterns(for language: String, colors: SyntaxColors) -> [String: C switch language { case "swift": return [ - "\\b(func|struct|class|enum|protocol|extension|if|else|for|while|switch|case|default|guard|defer|throw|try|catch|return|init|deinit)\\b": colors.keyword, + // Keywords (extended to include `import`) + "\\b(func|struct|class|enum|protocol|extension|if|else|for|while|switch|case|default|guard|defer|throw|try|catch|return|init|deinit|import)\\b": colors.keyword, + + // Strings and Characters "\"[^\"]*\"": colors.string, + "'[^'\\](?:\\.[^'\\])*'": colors.string, + + // Numbers "\\b([0-9]+(\\.[0-9]+)?)\\b": colors.number, + + // Comments (single and multi-line) "//.*": colors.comment, "/\\*([^*]|(\\*+[^*/]))*\\*+/": colors.comment, + + // Documentation markup (triple slash and doc blocks) + "(?m)^(///).*$": colors.comment, + "/\\*\\*([\\s\\S]*?)\\*+/": colors.comment, + // Documentation keywords inside docs (e.g., - Parameter:, - Returns:) + "(?m)\\-\\s*(Parameter|Parameters|Returns|Throws|Note|Warning|See\\salso)\\s*:": colors.meta, + + // Marks / TODO / FIXME + "(?m)//\\s*(MARK|TODO|FIXME)\\s*:.*$": colors.meta, + + // URLs + "https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+": colors.atom, + "file://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+": colors.atom, + + // Preprocessor statements (conditionals and directives) + "(?m)^#(if|elseif|else|endif|warning|error|available)\\b.*$": colors.keyword, + + // Attributes like @available, @MainActor, etc. "@\\w+": colors.attribute, + + // Variable declarations "\\b(var|let)\\b": colors.variable, - "\\b(String|Int|Double|Bool)\\b": colors.type + + // Common Swift types + "\\b(String|Int|Double|Bool)\\b": colors.type, + + // Regex literals and components (Swift /…/) + "/[^/\\n]*/": colors.builtin, // whole regex literal + "\\(\\?<([A-Za-z_][A-Za-z0-9_]*)>": colors.def, // named capture start (? + "\\[[^\\]]*\\]": colors.property, // character classes + "[|*+?]": colors.meta, // regex operators + + // Common SwiftUI property names like `body` + "\\bbody\\b": colors.property, + // Project-specific identifier you mentioned: `viewModel` + "\\bviewModel\\b": colors.property ] case "python": return [ @@ -755,203 +832,6 @@ func getSyntaxPatterns(for language: String, colors: SyntaxColors) -> [String: C extension Notification.Name { static let moveCursorToLine = Notification.Name("moveCursorToLine") static let streamSuggestion = Notification.Name("streamSuggestion") + static let caretPositionDidChange = Notification.Name("caretPositionDidChange") } -======= - } -} - -struct HighlightedTextEditor: View { - @Binding var text: String - let language: String - let colorScheme: ColorScheme - - var body: some View { - TextEditor(text: $text) - .font(.custom("SF Mono", size: 13)) - .padding(10) - .background(.ultraThinMaterial) - .overlay( - GeometryReader { geo in - HighlightOverlay(text: text, language: language, colorScheme: colorScheme) - .frame(width: geo.size.width, height: geo.size.height) - } - ) - } -} - -struct HighlightOverlay: View { - let text: String - let language: String - let colorScheme: ColorScheme - - var body: some View { - Text(text) - .font(.custom("SF Mono", size: 13)) - .foregroundColor(.clear) - .overlay( - GeometryReader { geo in - ZStack(alignment: .topLeading) { - ForEach(highlightedRanges, id: \.id) { range in - Text(range.text) - .font(.custom("SF Mono", size: 13)) - .foregroundColor(range.color) - .offset(range.offset) - } - } - } - ) - } - - private var highlightedRanges: [HighlightedRange] { - var ranges: [HighlightedRange] = [] - let colors = SyntaxColors.fromVibrantLightTheme(colorScheme: colorScheme) - let patterns = getSyntaxPatterns(for: language, colors: colors) - - for (pattern, color) in patterns { - if let regex = try? NSRegularExpression(pattern: pattern, options: []) { - let nsString = text as NSString - let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsString.length)) - - for match in matches { - let range = match.range - let matchedText = nsString.substring(with: range) - let lines = text[.. 0 ? 1 : 0) - let xOffset = CGFloat(nsString.substring(with: NSRange(location: lineStart, length: range.location - lineStart)).width(usingFont: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular))) - let yOffset = CGFloat(lineNumber) * 20 // Approximate line height - - ranges.append(HighlightedRange( - id: UUID(), - text: matchedText, - color: Color(color), - offset: CGSize(width: xOffset, height: yOffset) - )) - } - } - } - return ranges - } -} - -struct HighlightedRange: Identifiable { - let id: UUID - let text: String - let color: Color - let offset: CGSize -} - -struct SyntaxColors { - let keyword: Color - let string: Color - let number: Color - let comment: Color - let attribute: Color - let variable: Color - let def: Color - let property: Color - let meta: Color - let tag: Color - let atom: Color - let builtin: Color - let type: Color - - static func fromVibrantLightTheme(colorScheme: ColorScheme) -> SyntaxColors { - let baseColors: [String: (light: Color, dark: Color)] = [ - "keyword": (light: Color(red: 251/255, green: 0/255, blue: 186/255), dark: Color(red: 251/255, green: 0/255, blue: 186/255)), - "string": (light: Color(red: 190/255, green: 0/255, blue: 255/255), dark: Color(red: 190/255, green: 0/255, blue: 255/255)), - "number": (light: Color(red: 28/255, green: 0/255, blue: 207/255), dark: Color(red: 28/255, green: 0/255, blue: 207/255)), - "comment": (light: Color(red: 93/255, green: 108/255, blue: 121/255), dark: Color(red: 150/255, green: 160/255, blue: 170/255)), - "attribute": (light: Color(red: 57/255, green: 0/255, blue: 255/255), dark: Color(red: 57/255, green: 0/255, blue: 255/255)), - "variable": (light: Color(red: 19/255, green: 0/255, blue: 255/255), dark: Color(red: 19/255, green: 0/255, blue: 255/255)), - "def": (light: Color(red: 29/255, green: 196/255, blue: 83/255), dark: Color(red: 29/255, green: 196/255, blue: 83/255)), - "property": (light: Color(red: 29/255, green: 196/255, blue: 83/255), dark: Color(red: 29/255, green: 0/255, blue: 160/255)), - "meta": (light: Color(red: 255/255, green: 16/255, blue: 0/255), dark: Color(red: 255/255, green: 16/255, blue: 0/255)), - "tag": (light: Color(red: 170/255, green: 0/255, blue: 160/255), dark: Color(red: 170/255, green: 0/255, blue: 160/255)), - "atom": (light: Color(red: 28/255, green: 0/255, blue: 207/255), dark: Color(red: 28/255, green: 0/255, blue: 207/255)), - "builtin": (light: Color(red: 255/255, green: 130/255, blue: 0/255), dark: Color(red: 255/255, green: 130/255, blue: 0/255)), - "type": (light: Color(red: 170/255, green: 0/255, blue: 160/255), dark: Color(red: 170/255, green: 0/255, blue: 160/255)) - ] - - return SyntaxColors( - keyword: colorScheme == .dark ? baseColors["keyword"]!.dark : baseColors["keyword"]!.light, - string: colorScheme == .dark ? baseColors["string"]!.dark : baseColors["string"]!.light, - number: colorScheme == .dark ? baseColors["number"]!.dark : baseColors["number"]!.light, - comment: colorScheme == .dark ? baseColors["comment"]!.dark : baseColors["comment"]!.light, - attribute: colorScheme == .dark ? baseColors["attribute"]!.dark : baseColors["attribute"]!.light, - variable: colorScheme == .dark ? baseColors["variable"]!.dark : baseColors["variable"]!.light, - def: colorScheme == .dark ? baseColors["def"]!.dark : baseColors["def"]!.light, - property: colorScheme == .dark ? baseColors["property"]!.dark : baseColors["property"]!.light, - meta: colorScheme == .dark ? baseColors["meta"]!.dark : baseColors["meta"]!.light, - tag: colorScheme == .dark ? baseColors["tag"]!.dark : baseColors["tag"]!.light, - atom: colorScheme == .dark ? baseColors["atom"]!.dark : baseColors["atom"]!.light, - builtin: colorScheme == .dark ? baseColors["builtin"]!.dark : baseColors["builtin"]!.light, - type: colorScheme == .dark ? baseColors["type"]!.dark : baseColors["type"]!.light - ) - } -} - -func getSyntaxPatterns(for language: String, colors: SyntaxColors) -> [String: Color] { - switch language { - case "swift": - return [ - "\\b(func|struct|class|enum|protocol|extension|if|else|for|while|switch|case|default|guard|defer|throw|try|catch|return|init|deinit)\\b": colors.keyword, - "\"[^\"]*\"": colors.string, - "\\b([0-9]+(\\.[0-9]+)?)\\b": colors.number, - "//.*": colors.comment, - "/\\*([^*]|(\\*+[^*/]))*\\*+/": colors.comment, - "@\\w+": colors.attribute, - "\\b(var|let)\\b": colors.variable, - "\\b(String|Int|Double|Bool)\\b": colors.type - ] - case "python": - return [ - "\\b(def|class|if|else|for|while|try|except|with|as|import|from)\\b": colors.keyword, - "\\b(int|str|float|bool|list|dict)\\b": colors.type, - "\"[^\"]*\"|'[^']*'": colors.string, - "\\b([0-9]+(\\.[0-9]+)?)\\b": colors.number, - "#.*": colors.comment - ] - case "javascript": - return [ - "\\b(function|var|let|const|if|else|for|while|do|try|catch)\\b": colors.keyword, - "\\b(Number|String|Boolean|Object|Array)\\b": colors.type, - "\"[^\"]*\"|'[^']*'|\\`[^\\`]*\\`": colors.string, - "\\b([0-9]+(\\.[0-9]+)?)\\b": colors.number, - "//.*|/\\*([^*]|(\\*+[^*/]))*\\*+/": colors.comment - ] - case "html": - return ["<[^>]+>": colors.tag] - case "css": - return ["\\b([a-zA-Z-]+\\s*:\\s*[^;]+;)": colors.property] - case "c", "cpp": - return [ - "\\b(int|float|double|char|void|if|else|for|while|do|switch|case|return)\\b": colors.keyword, - "\\b(int|float|double|char)\\b": colors.type, - "\"[^\"]*\"": colors.string, - "\\b([0-9]+(\\.[0-9]+)?)\\b": colors.number, - "//.*|/\\*([^*]|(\\*+[^*/]))*\\*+/": colors.comment - ] - case "json": - return [ - "\"[^\"]+\"\\s*:": colors.property, - "\"[^\"]*\"": colors.string, - "\\b([0-9]+(\\.[0-9]+)?)\\b": colors.number, - "\\b(true|false|null)\\b": colors.keyword - ] - case "markdown": - return [ - "^#+\\s*[^#]+": colors.keyword, - "\\*\\*[^\\*\\*]+\\*\\*": colors.def, - "\\_[^\\_]+\\_": colors.def - ] - default: - return [:] - } -} - -extension Notification.Name { - static let moveCursorToLine = Notification.Name("moveCursorToLine") -} ->>>>>>> main diff --git a/Neon Vision Editor/EditorViewModel.swift b/Neon Vision Editor/EditorViewModel.swift index c15a411..724ab1a 100644 --- a/Neon Vision Editor/EditorViewModel.swift +++ b/Neon Vision Editor/EditorViewModel.swift @@ -18,6 +18,7 @@ class EditorViewModel: ObservableObject { @Published var isBrainDumpMode: Bool = false @Published var showingRename: Bool = false @Published var renameText: String = "" + @Published var isLineWrapEnabled: Bool = true var selectedTab: TabData? { get { tabs.first(where: { $0.id == selectedTabID }) } @@ -65,7 +66,6 @@ class EditorViewModel: ObservableObject { } } -<<<<<<< HEAD func closeTab(tab: TabData) { tabs.removeAll { $0.id == tab.id } if tabs.isEmpty { @@ -75,8 +75,6 @@ class EditorViewModel: ObservableObject { } } -======= ->>>>>>> main func saveFile(tab: TabData) { guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } if let url = tabs[index].fileURL { @@ -95,16 +93,14 @@ class EditorViewModel: ObservableObject { let panel = NSSavePanel() panel.nameFieldStringValue = tabs[index].name panel.allowedContentTypes = [.text, .swiftSource, .pythonScript, .javaScript, .html, .css, .cSource, .json, UTType(importedAs: "public.markdown")] - - Task { - if panel.runModal() == .OK, let url = panel.url { - do { - try tabs[index].content.write(to: url, atomically: true, encoding: .utf8) - tabs[index].fileURL = url - tabs[index].name = url.lastPathComponent - } catch { - print("Error saving file: \(error)") - } + + if panel.runModal() == .OK, let url = panel.url { + do { + try tabs[index].content.write(to: url, atomically: true, encoding: .utf8) + tabs[index].fileURL = url + tabs[index].name = url.lastPathComponent + } catch { + print("Error saving file: \(error)") } } } @@ -114,20 +110,18 @@ class EditorViewModel: ObservableObject { panel.allowedContentTypes = [.text, .sourceCode, .swiftSource, .pythonScript, .javaScript, .html, .css, .cSource, .json, UTType(importedAs: "public.markdown")] panel.allowsMultipleSelection = false panel.canChooseDirectories = false - - Task { - if panel.runModal() == .OK, let url = panel.url { - do { - let content = try String(contentsOf: url, encoding: .utf8) - let newTab = TabData(name: url.lastPathComponent, - content: content, - language: languageMap[url.pathExtension.lowercased()] ?? "swift", - fileURL: url) - tabs.append(newTab) - selectedTabID = newTab.id - } catch { - print("Error opening file: \(error)") - } + + if panel.runModal() == .OK, let url = panel.url { + do { + let content = try String(contentsOf: url, encoding: .utf8) + let newTab = TabData(name: url.lastPathComponent, + content: content, + language: languageMap[url.pathExtension.lowercased()] ?? "swift", + fileURL: url) + tabs.append(newTab) + selectedTabID = newTab.id + } catch { + print("Error opening file: \(error)") } } } diff --git a/Neon Vision Editor/NeonVisionEditorApp.swift b/Neon Vision Editor/NeonVisionEditorApp.swift index 56a0859..9bda359 100644 --- a/Neon Vision Editor/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/NeonVisionEditorApp.swift @@ -1,46 +1,25 @@ import SwiftUI -<<<<<<< HEAD import FoundationModels -enum AIModel: String, Identifiable { - case appleIntelligence = "Apple Intelligence" - case grok = "Grok" - - var id: String { rawValue } -} -======= ->>>>>>> main - @main struct NeonVisionEditorApp: App { @StateObject private var viewModel = EditorViewModel() -<<<<<<< HEAD @State private var showGrokError: Bool = false @State private var grokErrorMessage: String = "" - @State private var selectedAIModel: AIModel = .appleIntelligence -======= ->>>>>>> main + @State private var useAppleIntelligence: Bool = true var body: some Scene { WindowGroup { ContentView() .environmentObject(viewModel) -<<<<<<< HEAD .environment(\.showGrokError, $showGrokError) .environment(\.grokErrorMessage, $grokErrorMessage) - .environment(\.selectedAIModel, $selectedAIModel) .frame(minWidth: 600, minHeight: 400) - .background(.ultraThinMaterial) - .overlay(.ultraThinMaterial.opacity(0.2)) // Fallback for liquidGlassEffect .task { // Pre-warm Apple Intelligence model let session = LanguageModelSession(model: SystemLanguageModel()) session.prewarm() } -======= - .frame(minWidth: 600, minHeight: 400) - .background(.ultraThinMaterial) ->>>>>>> main } .defaultSize(width: 1000, height: 600) .commands { @@ -75,7 +54,6 @@ struct NeonVisionEditorApp: App { viewModel.renameText = viewModel.selectedTab?.name ?? "Untitled" } .disabled(viewModel.selectedTab == nil) -<<<<<<< HEAD Button("Close Tab") { if let tab = viewModel.selectedTab { @@ -84,8 +62,6 @@ struct NeonVisionEditorApp: App { } .keyboardShortcut("w", modifiers: .command) .disabled(viewModel.selectedTab == nil) -======= ->>>>>>> main } CommandMenu("Language") { @@ -105,15 +81,16 @@ struct NeonVisionEditorApp: App { Toggle("Brain Dump Mode", isOn: $viewModel.isBrainDumpMode) .keyboardShortcut("d", modifiers: [.command, .shift]) + + Toggle("Line Wrap", isOn: $viewModel.isLineWrapEnabled) + .keyboardShortcut("l", modifiers: [.command, .option]) } CommandMenu("Tools") { -<<<<<<< HEAD Button("Suggest Code") { Task { if let tab = viewModel.selectedTab { - switch selectedAIModel { - case .appleIntelligence: + if useAppleIntelligence { let session = LanguageModelSession(model: SystemLanguageModel()) let prompt = "System: Output a code suggestion for this \(tab.language) code.\nUser: \(tab.content.prefix(1000))" do { @@ -123,7 +100,7 @@ struct NeonVisionEditorApp: App { grokErrorMessage = error.localizedDescription showGrokError = true } - case .grok: + } else { let client = GrokAPIClient(apiKey: "your-xai-api-key") // Replace with your xAI API key from https://x.ai/api let prompt = "Suggest improvements for this \(tab.language) code: \(tab.content.prefix(1000))" do { @@ -133,26 +110,14 @@ struct NeonVisionEditorApp: App { grokErrorMessage = error.localizedDescription showGrokError = true } -======= - Button("Suggest Code with Grok") { - Task { - let client = GrokAPIClient(apiKey: "your-xai-api-key") // Replace with your actual xAI API key from https://x.ai/api - if let tab = viewModel.selectedTab { - let prompt = "Suggest improvements for this \(tab.language) code: \(tab.content.prefix(1000))" - do { - let suggestion = try await client.generateText(prompt: prompt, maxTokens: 200) - // Append suggestion to the current content - viewModel.updateTabContent(tab: tab, content: tab.content + "\n\n// Grok Suggestion:\n" + suggestion) - } catch { - print("Grok API error: \(error)") - // Optional: Show an alert or sheet for the error ->>>>>>> main } } } } .keyboardShortcut("g", modifiers: [.command, .shift]) .disabled(viewModel.selectedTab == nil) + + Toggle("Use Apple Intelligence", isOn: $useAppleIntelligence) } } } @@ -166,10 +131,6 @@ struct GrokErrorMessageKey: EnvironmentKey { static let defaultValue: Binding = .constant("") } -struct SelectedAIModelKey: EnvironmentKey { - static let defaultValue: Binding = .constant(.appleIntelligence) -} - extension EnvironmentValues { var showGrokError: Binding { get { self[ShowGrokErrorKey.self] } @@ -180,9 +141,4 @@ extension EnvironmentValues { get { self[GrokErrorMessageKey.self] } set { self[GrokErrorMessageKey.self] = newValue } } - - var selectedAIModel: Binding { - get { self[SelectedAIModelKey.self] } - set { self[SelectedAIModelKey.self] = newValue } - } }