Add TeX language support and improve code snapshot UX

This commit is contained in:
h3p 2026-03-15 18:51:17 +01:00
parent 94537eaa21
commit aa23871125
12 changed files with 216 additions and 16 deletions

View file

@ -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;

View file

@ -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"

View file

@ -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": []
]

View file

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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

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

View file

@ -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"
}

View file

@ -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,

View file

@ -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:

View file

@ -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":