diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 5017fec..759f61b 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -361,7 +361,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 520; + CURRENT_PROJECT_VERSION = 521; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -444,7 +444,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 520; + CURRENT_PROJECT_VERSION = 521; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/App/AppMenus.swift b/Neon Vision Editor/App/AppMenus.swift index 5650a27..be3e88e 100644 --- a/Neon Vision Editor/App/AppMenus.swift +++ b/Neon Vision Editor/App/AppMenus.swift @@ -28,7 +28,7 @@ struct NeonVisionMacAppCommands: Commands { "swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "csharp", "objective-c", "json", "xml", "yaml", - "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "bash", "zsh", "powershell", + "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "tex", "bash", "zsh", "powershell", "standard", "plain" ] @@ -468,6 +468,7 @@ struct NeonVisionMacAppCommands: Commands { case "vim": return "Vim" case "log": return "Log" case "ipynb": return "Jupyter Notebook" + case "tex": return "TeX" case "html": return "HTML" case "expressionengine": return "ExpressionEngine" case "css": return "CSS" diff --git a/Neon Vision Editor/Core/CompletionHeuristics.swift b/Neon Vision Editor/Core/CompletionHeuristics.swift index 9b1cdbe..6cd54ad 100644 --- a/Neon Vision Editor/Core/CompletionHeuristics.swift +++ b/Neon Vision Editor/Core/CompletionHeuristics.swift @@ -10,6 +10,7 @@ enum CompletionHeuristics { "typescript": ["async", "await", "break", "case", "catch", "class", "const", "continue", "default", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "interface", "let", "namespace", "new", "null", "private", "protected", "public", "readonly", "return", "switch", "this", "throw", "true", "try", "type", "var", "while"], "json": ["false", "null", "true"], "markdown": ["```", "###", "##", "#", "- ", "1. ", "> "], + "tex": ["\\begin{}", "\\end{}", "\\section{}", "\\subsection{}", "\\textbf{}", "\\emph{}", "\\item", "\\cite{}", "\\label{}", "\\ref{}"], "plain": [] ] diff --git a/Neon Vision Editor/Core/LanguageDetector.swift b/Neon Vision Editor/Core/LanguageDetector.swift index ad11432..b2f3596 100644 --- a/Neon Vision Editor/Core/LanguageDetector.swift +++ b/Neon Vision Editor/Core/LanguageDetector.swift @@ -66,6 +66,11 @@ public struct LanguageDetector { "json5": "json", "md": "markdown", "markdown": "markdown", + "tex": "tex", + "latex": "tex", + "bib": "tex", + "sty": "tex", + "cls": "tex", "env": "dotenv", "proto": "proto", "graphql": "graphql", @@ -123,7 +128,7 @@ public struct LanguageDetector { "swift", "csharp", "php", "csv", "python", "javascript", "typescript", "java", "kotlin", "go", "ruby", "rust", "dotenv", "proto", "graphql", "rst", "nginx", "cpp", "c", "css", "markdown", "json", "html", "expressionengine", "sql", "xml", "yaml", "toml", "ini", "vim", - "log", "ipynb", "powershell", "cobol", "objective-c", "bash", "zsh" + "log", "ipynb", "powershell", "cobol", "objective-c", "bash", "zsh", "tex" ] for lang in languages { scores[lang] = 0 } @@ -208,6 +213,14 @@ public struct LanguageDetector { bump("zsh", -40) } + // TeX / LaTeX + if regexBool(lower, pattern: #"\\documentclass(\[[^\]]*\])?\{[^}]+\}"#) { bump("tex", 240) } + if regexBool(lower, pattern: #"\\usepackage(\[[^\]]*\])?\{[^}]+\}"#) { bump("tex", 180) } + if regexBool(lower, pattern: #"\\begin\{document\}|\\end\{document\}"#) { bump("tex", 220) } + if regexBool(lower, pattern: #"\\begin\{[A-Za-z*]+\}|\\end\{[A-Za-z*]+\}"#) { bump("tex", 90) } + if regexBool(lower, pattern: #"\\(section|subsection|chapter|paragraph)\*?\{[^}]+\}"#) { bump("tex", 80) } + if regexBool(lower, pattern: #"\\cite\{[^}]+\}|\\bibliography\{[^}]+\}"#) { bump("tex", 70) } + let dotenvCount = regexCount(lower, pattern: "(?m)^[A-Za-z_][A-Za-z0-9_]*=.+$") if dotenvCount > 0 && tomlSectionCount == 0 { bump("dotenv", min(220, dotenvCount * 30)) diff --git a/Neon Vision Editor/Core/SyntaxHighlighting.swift b/Neon Vision Editor/Core/SyntaxHighlighting.swift index 7adf263..eb46c1e 100644 --- a/Neon Vision Editor/Core/SyntaxHighlighting.swift +++ b/Neon Vision Editor/Core/SyntaxHighlighting.swift @@ -101,6 +101,8 @@ func getSyntaxPatterns( canonical = "typescript" case "ee", "expression-engine", "expression_engine": canonical = "expressionengine" + case "latex", "bibtex": + canonical = "tex" default: canonical = normalized } @@ -249,6 +251,16 @@ func getSyntaxPatterns( #"\[[^\]]+\]\([^)]+\)"#: colors.string, #"(?m)^>\s+.*$"#: colors.comment ] + case "tex": + return [ + #"\\[A-Za-z@]+(\*?)"#: colors.keyword, + #"\\begin\{[^}]+\}|\\end\{[^}]+\}"#: colors.meta, + #"\{[^{}\n]*\}"#: colors.property, + #"\[[^\]\n]*\]"#: colors.attribute, + #"\$[^$\n]+\$|\$\$[\s\S]*?\$\$"#: colors.string, + #"(?m)%.*$"#: colors.comment, + #"\b[0-9]+(\.[0-9]+)?\b"#: colors.number + ] case "bash": return [ // Keywords and flow control diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 95ffd66..47477c2 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -912,6 +912,11 @@ class EditorViewModel { "json5": "json", "md": "markdown", "markdown": "markdown", + "tex": "tex", + "latex": "tex", + "bib": "tex", + "sty": "tex", + "cls": "tex", "env": "dotenv", "proto": "proto", "graphql": "graphql", @@ -1337,7 +1342,8 @@ class EditorViewModel { "log", "vim", "ipynb", "java", "kt", "kts", "go", "rb", "rs", "ps1", "psm1", "html", "htm", "ee", "exp", "tmpl", "css", "c", "cpp", "cc", "hpp", "hh", "h", "m", "mm", "cs", "json", "jsonc", "json5", "md", "markdown", "env", "proto", - "graphql", "gql", "rst", "conf", "nginx", "cob", "cbl", "cobol", "sh", "bash", "zsh" + "graphql", "gql", "rst", "conf", "nginx", "cob", "cbl", "cobol", "sh", "bash", "zsh", + "tex", "latex", "bib", "sty", "cls" ] if knownSupportedExtensions.contains(ext) { return true diff --git a/Neon Vision Editor/UI/CodeSnapshotComposerView.swift b/Neon Vision Editor/UI/CodeSnapshotComposerView.swift index 4ec97a7..83d5fb4 100644 --- a/Neon Vision Editor/UI/CodeSnapshotComposerView.swift +++ b/Neon Vision Editor/UI/CodeSnapshotComposerView.swift @@ -315,6 +315,9 @@ struct CodeSnapshotComposerView: View { let payload: CodeSnapshotPayload @Environment(\.dismiss) private var dismiss +#if os(iOS) + @Environment(\.horizontalSizeClass) private var horizontalSizeClass +#endif @State private var style = CodeSnapshotStyle() @State private var renderedPNGData: Data? @State private var shareURL: URL? @@ -322,16 +325,33 @@ struct CodeSnapshotComposerView: View { var body: some View { NavigationStack { - VStack(spacing: 20) { - snapshotControls - ScrollView([.vertical, .horizontal]) { - CodeSnapshotCardView(payload: payload, style: style) - .frame(maxWidth: 980) - .padding(.horizontal, 12) - .padding(.bottom, 20) + GeometryReader { proxy in + let availableWidth = max(320, proxy.size.width - 40) + let estimatedControlsHeight: CGFloat = usesCompactScrollingLayout ? 180 : 132 + let availablePreviewHeight = max(220, proxy.size.height - estimatedControlsHeight - 44) + let fittedPreviewWidth = min(980, availableWidth, availablePreviewHeight * 1.25) + + VStack(spacing: 16) { + snapshotControls + Group { + if usesCompactScrollingLayout { + ScrollView([.vertical, .horizontal]) { + CodeSnapshotCardView(payload: payload, style: style) + .frame(width: min(980, availableWidth)) + .padding(.bottom, 20) + } + } else { + CodeSnapshotCardView(payload: payload, style: style) + .frame(width: fittedPreviewWidth) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 12) } - .padding(20) .navigationTitle("Code Snapshot") #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -355,6 +375,9 @@ struct CodeSnapshotComposerView: View { } } } +#if os(macOS) + .frame(minWidth: 1020, minHeight: 760) +#endif .task(id: style) { await refreshRenderedSnapshot() } @@ -366,8 +389,77 @@ struct CodeSnapshotComposerView: View { ) { _ in } } + private var usesCompactScrollingLayout: Bool { +#if os(iOS) + return horizontalSizeClass == .compact +#else + return false +#endif + } + private var snapshotControls: some View { VStack(alignment: .leading, spacing: 14) { +#if os(iOS) + if horizontalSizeClass == .compact { + VStack(spacing: 12) { + HStack(spacing: 10) { + Picker("Appearance", selection: $style.appearance) { + ForEach(CodeSnapshotAppearance.allCases) { appearance in + Text(appearance.title).tag(appearance) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("Background", selection: $style.backgroundPreset) { + ForEach(CodeSnapshotBackgroundPreset.allCases) { preset in + Text(preset.title).tag(preset) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(spacing: 10) { + Picker("Frame", selection: $style.frameStyle) { + ForEach(CodeSnapshotFrameStyle.allCases) { frame in + Text(frame.title).tag(frame) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle("Line Numbers", isOn: $style.showLineNumbers) + .toggleStyle(.switch) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } else { + HStack(spacing: 16) { + Picker("Appearance", selection: $style.appearance) { + ForEach(CodeSnapshotAppearance.allCases) { appearance in + Text(appearance.title).tag(appearance) + } + } + Picker("Background", selection: $style.backgroundPreset) { + ForEach(CodeSnapshotBackgroundPreset.allCases) { preset in + Text(preset.title).tag(preset) + } + } + Picker("Frame", selection: $style.frameStyle) { + ForEach(CodeSnapshotFrameStyle.allCases) { frame in + Text(frame.title).tag(frame) + } + } + Toggle("Line Numbers", isOn: $style.showLineNumbers) + .lineLimit(1) + } + } +#else HStack(spacing: 16) { Picker("Appearance", selection: $style.appearance) { ForEach(CodeSnapshotAppearance.allCases) { appearance in @@ -386,6 +478,7 @@ struct CodeSnapshotComposerView: View { } Toggle("Line Numbers", isOn: $style.showLineNumbers) } +#endif HStack(spacing: 12) { Text("Padding") diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 9290351..90679e4 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -930,6 +930,7 @@ extension ContentView { case "log": return "Log" case "ipynb": return "JNB" case "markdown": return "MD" + case "tex": return "TeX" case "bash": return "Sh" case "zsh": return "zsh" case "powershell": return "PS" @@ -1019,6 +1020,13 @@ extension ContentView { .disabled(viewModel.selectedTab == nil) .help("Save File (Cmd+S)") + Button(action: { presentCodeSnapshotComposer() }) { + Label("Code Snapshot", systemImage: "camera.viewfinder") + .foregroundStyle(macToolbarSymbolColor) + } + .disabled(!canCreateCodeSnapshot) + .help("Create Code Snapshot from Selection") + Button(action: { showFindReplace = true }) { diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 563bcbf..fac000d 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -1416,6 +1416,9 @@ struct ContentView: View { let selection = (notif.object as? String) ?? "" currentSelectionSnapshotText = selection } + .onReceive(NotificationCenter.default.publisher(for: .editorRequestCodeSnapshotFromSelection)) { _ in + presentCodeSnapshotComposer() + } .onReceive(NotificationCenter.default.publisher(for: .pastedText)) { notif in handlePastedTextNotification(notif) } @@ -3233,7 +3236,7 @@ struct ContentView: View { } var languageOptions: [String] { - ["swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "csharp", "objective-c", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "bash", "zsh", "powershell", "standard", "plain"] + ["swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "csharp", "objective-c", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "tex", "bash", "zsh", "powershell", "standard", "plain"] } func languageLabel(for lang: String) -> String { @@ -3259,6 +3262,7 @@ struct ContentView: View { case "vim": return "Vim" case "log": return "Log" case "ipynb": return "Jupyter Notebook" + case "tex": return "TeX" case "html": return "HTML" case "expressionengine": return "ExpressionEngine" case "css": return "CSS" @@ -3374,6 +3378,8 @@ struct ContentView: View { return "-- TODO: Add queries here\n" case "markdown": return "# Title\n\nWrite here.\n" + case "tex": + return "\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\n\\begin{document}\n\\section{Title}\n\nTODO\n\n\\end{document}\n" case "yaml": return "# TODO: Add config here\n" case "json": @@ -3427,7 +3433,7 @@ struct ContentView: View { private func detectLanguageWithAppleIntelligence(_ text: String) async -> String { // Supported languages in our picker - let supported = ["swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "objective-c", "csharp", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "bash", "zsh", "powershell", "standard", "plain"] + let supported = ["swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "objective-c", "csharp", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", "markdown", "tex", "bash", "zsh", "powershell", "standard", "plain"] #if USE_FOUNDATION_MODELS && canImport(FoundationModels) // Attempt a lightweight model-based detection via AppleIntelligenceAIClient if available @@ -3470,6 +3476,12 @@ struct ContentView: View { if lower.contains(".. code-block::") || lower.contains(".. toctree::") || (lower.contains("::") && lower.contains("\n====")) { return "rst" } + if lower.contains("\\documentclass") + || lower.contains("\\usepackage") + || lower.contains("\\begin{document}") + || lower.contains("\\end{document}") { + return "tex" + } if lower.contains("\n") && lower.range(of: #"(?m)^[A-Z_][A-Z0-9_]*=.*$"#, options: .regularExpression) != nil { return "dotenv" } diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 2572d13..f645547 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -329,6 +329,7 @@ private enum EmmetExpander { extension Notification.Name { static let pastedFileURL = Notification.Name("pastedFileURL") static let editorSelectionDidChange = Notification.Name("editorSelectionDidChange") + static let editorRequestCodeSnapshotFromSelection = Notification.Name("editorRequestCodeSnapshotFromSelection") } ///MARK: - Scope Match Models @@ -3137,6 +3138,35 @@ struct CustomTextEditor: NSViewRepresentable { updateCaretStatusAndHighlight(triggerHighlight: !parent.isLineWrapEnabled) } + func textView(_ textView: NSTextView, menu: NSMenu, for event: NSEvent, at charIndex: Int) -> NSMenu? { + let selectedRange = textView.selectedRange() + guard selectedRange.location != NSNotFound, + selectedRange.length > 0 else { + return menu + } + let snapshotItem = NSMenuItem( + title: "Create Code Snapshot", + action: #selector(createCodeSnapshotFromContextMenu(_:)), + keyEquivalent: "" + ) + snapshotItem.target = self + snapshotItem.image = NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Create Code Snapshot") + menu.addItem(.separator()) + menu.addItem(snapshotItem) + return menu + } + + @objc private func createCodeSnapshotFromContextMenu(_ sender: Any?) { + guard let tv = textView else { return } + let ns = tv.string as NSString + let selectedRange = tv.selectedRange() + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + NSMaxRange(selectedRange) <= ns.length else { return } + publishSelectionSnapshot(from: ns, selectedRange: selectedRange) + NotificationCenter.default.post(name: .editorRequestCodeSnapshotFromSelection, object: nil) + } + private func publishSelectionSnapshot(from text: NSString, selectedRange: NSRange) { guard selectedRange.location != NSNotFound, selectedRange.length > 0, @@ -4387,6 +4417,25 @@ struct CustomTextEditor: UIViewRepresentable { scheduleHighlightIfNeeded(currentText: textView.text, immediate: immediateHighlight) } + @available(iOS 16.0, *) + func textView( + _ textView: UITextView, + editMenuForTextIn range: NSRange, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + guard range.length > 0 else { return UIMenu(children: suggestedActions) } + let snapshotAction = UIAction( + title: "Create Code Snapshot", + image: UIImage(systemName: "camera.viewfinder") + ) { [weak self, weak textView] _ in + guard let self, let textView else { return } + let nsText = (textView.text ?? "") as NSString + self.publishSelectionSnapshot(from: nsText, selectedRange: textView.selectedRange) + NotificationCenter.default.post(name: .editorRequestCodeSnapshotFromSelection, object: nil) + } + return UIMenu(children: suggestedActions + [snapshotAction]) + } + private func publishSelectionSnapshot(from text: NSString, selectedRange: NSRange) { guard selectedRange.location != NSNotFound, selectedRange.length > 0, diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 37b83c7..3444a7f 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -109,7 +109,7 @@ struct NeonSettingsView: View { "swift", "python", "javascript", "typescript", "php", "java", "kotlin", "go", "ruby", "rust", "cobol", "dotenv", "proto", "graphql", "rst", "nginx", "sql", "html", "expressionengine", "css", "c", "cpp", "csharp", "objective-c", "json", "xml", "yaml", "toml", "csv", "ini", "vim", "log", "ipynb", - "markdown", "bash", "zsh", "powershell", "standard", "plain" + "markdown", "tex", "bash", "zsh", "powershell", "standard", "plain" ] private var isCompactSettingsLayout: Bool { @@ -2305,6 +2305,7 @@ struct NeonSettingsView: View { case "vim": return "Vim" case "log": return "Log" case "ipynb": return "Jupyter Notebook" + case "tex": return "TeX" case "html": return "HTML" case "expressionengine": return "ExpressionEngine" case "css": return "CSS" @@ -2414,6 +2415,8 @@ struct NeonSettingsView: View { return "#!/usr/bin/env \(language)\n\n" case "markdown": return "# Title\n\n" + case "tex": + return "\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\n\\begin{document}\n\\section{Title}\n\n\n\\end{document}\n" case "plain": return "" default: diff --git a/Neon Vision Editor/UI/SidebarViews.swift b/Neon Vision Editor/UI/SidebarViews.swift index 319d24e..9a46caa 100644 --- a/Neon Vision Editor/UI/SidebarViews.swift +++ b/Neon Vision Editor/UI/SidebarViews.swift @@ -708,6 +708,8 @@ struct ProjectStructureSidebarView: View { return .init(symbol: "curlybraces", color: .green) case "md", "markdown": return .init(symbol: "text.alignleft", color: .teal) + case "tex", "latex", "bib", "sty", "cls": + return .init(symbol: "text.book.closed", color: .indigo) case "yml", "yaml", "toml", "ini", "env": return .init(symbol: "slider.horizontal.3", color: .mint) case "html", "htm":