mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
New approach for Tabbar. Still Xcode Beta Issues for build Universal app
This commit is contained in:
parent
2e8b3703b9
commit
9190efbbda
5 changed files with 398 additions and 341 deletions
|
|
@ -5,297 +5,215 @@ import Combine
|
|||
import SwiftData
|
||||
import AppKit
|
||||
|
||||
// MARK: - ContentViewModel
|
||||
class ContentViewModel: ObservableObject {
|
||||
@Published var text: String = ""
|
||||
@Published var selectedLanguage: String = "swift"
|
||||
@Published var tabs: [Item] = []
|
||||
@Published var selectedTab: Item? = nil
|
||||
@Published var showSidebar = true
|
||||
@Published var selectedTOCItem: String? // Track selected table of contents item
|
||||
|
||||
let languages: [String] = ["HTML", "C", "Swift", "Python", "C++", "Java", "Bash", "JSON", "Markdown"]
|
||||
let languageMap: [String: String] = [
|
||||
"html": "html",
|
||||
"htm": "html",
|
||||
"c": "c",
|
||||
"h": "c",
|
||||
"swift": "swift",
|
||||
"py": "python",
|
||||
"cpp": "cpp",
|
||||
"java": "java",
|
||||
"sh": "bash",
|
||||
"json": "json",
|
||||
"md": "markdown",
|
||||
"markdown": "markdown"
|
||||
]
|
||||
|
||||
private var modelContext: ModelContext?
|
||||
|
||||
func setModelContext(_ context: ModelContext) {
|
||||
self.modelContext = context
|
||||
loadTabs()
|
||||
}
|
||||
|
||||
func openFile() {
|
||||
let openPanel = NSOpenPanel()
|
||||
openPanel.allowedContentTypes = [.text, .sourceCode, UTType.html, UTType.cSource, UTType.swiftSource, UTType.pythonScript, UTType("public.shell-script")!, UTType("public.markdown")!]
|
||||
openPanel.allowsMultipleSelection = false
|
||||
openPanel.canChooseDirectories = false
|
||||
openPanel.canChooseFiles = true
|
||||
|
||||
if openPanel.runModal() == .OK, let url = openPanel.url {
|
||||
do {
|
||||
let content = try String(contentsOf: url, encoding: .utf8)
|
||||
let lang = languageMap[url.pathExtension.lowercased()] ?? "plaintext"
|
||||
selectedLanguage = lang
|
||||
let newItem = Item(name: url.lastPathComponent, content: content, language: selectedLanguage)
|
||||
tabs.append(newItem)
|
||||
selectedTab = newItem
|
||||
saveToSwiftData(url.lastPathComponent)
|
||||
} catch {
|
||||
print("Error opening file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveFile() {
|
||||
guard let selectedItem = selectedTab else { return }
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.allowedContentTypes = [.text, .sourceCode, UTType.html, UTType.cSource, UTType.swiftSource, UTType.pythonScript, UTType("public.shell-script")!, UTType("public.markdown")!]
|
||||
savePanel.nameFieldStringValue = selectedItem.name
|
||||
|
||||
if savePanel.runModal() == .OK, let url = savePanel.url {
|
||||
do {
|
||||
try selectedItem.content.write(to: url, atomically: true, encoding: .utf8)
|
||||
saveToSwiftData(selectedItem.name)
|
||||
} catch {
|
||||
print("Error saving file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveAsFile() {
|
||||
guard let selectedItem = selectedTab else { return }
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.allowedContentTypes = [.text, .sourceCode, UTType.html, UTType.cSource, UTType.swiftSource, UTType.pythonScript, UTType("public.shell-script")!, UTType("public.markdown")!]
|
||||
savePanel.nameFieldStringValue = selectedItem.name
|
||||
|
||||
if savePanel.runModal() == .OK, let url = savePanel.url {
|
||||
do {
|
||||
try selectedItem.content.write(to: url, atomically: true, encoding: .utf8)
|
||||
saveToSwiftData(selectedItem.name)
|
||||
} catch {
|
||||
print("Error saving as file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addNewTab() {
|
||||
let newItem = Item(name: "Note", content: "", language: selectedLanguage)
|
||||
tabs.append(newItem)
|
||||
selectedTab = newItem
|
||||
saveToSwiftData(newItem.name)
|
||||
}
|
||||
|
||||
func removeTab(_ item: Item) {
|
||||
if let index = tabs.firstIndex(of: item) {
|
||||
tabs.remove(at: index)
|
||||
if selectedTab == item {
|
||||
selectedTab = tabs.last ?? nil
|
||||
}
|
||||
if let context = modelContext {
|
||||
do {
|
||||
try context.save()
|
||||
context.delete(item)
|
||||
} catch {
|
||||
print("Failed to delete from SwiftData: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateContent(_ content: String) {
|
||||
if let selectedItem = selectedTab {
|
||||
selectedItem.content = content
|
||||
saveToSwiftData(selectedItem.name)
|
||||
print("ViewModel updated content to: \(content)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateLanguage(_ language: String) {
|
||||
if let selectedItem = selectedTab {
|
||||
selectedItem.language = language
|
||||
saveToSwiftData(selectedItem.name)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveToSwiftData(_ name: String) {
|
||||
if let selectedItem = selectedTab, let context = modelContext {
|
||||
do {
|
||||
try context.save()
|
||||
if let existingItem = try context.fetch(FetchDescriptor<Item>(sortBy: [SortDescriptor(\Item.name)]))
|
||||
.first(where: { $0.name == name }) {
|
||||
existingItem.content = selectedItem.content
|
||||
existingItem.language = selectedItem.language
|
||||
} else {
|
||||
context.insert(selectedItem)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to save to SwiftData: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadTabs() {
|
||||
if let context = modelContext {
|
||||
do {
|
||||
let descriptor = FetchDescriptor<Item>(sortBy: [SortDescriptor(\Item.name)])
|
||||
tabs = try context.fetch(descriptor)
|
||||
if let firstTab = tabs.first {
|
||||
selectedTab = firstTab
|
||||
} else {
|
||||
addNewTab()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load tabs: \(error)")
|
||||
addNewTab()
|
||||
}
|
||||
} else {
|
||||
addNewTab()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSidebar() {
|
||||
showSidebar = !showSidebar
|
||||
}
|
||||
|
||||
// Generate table of contents based on selected language and content
|
||||
func generateTableOfContents() -> [String] {
|
||||
guard let selectedItem = selectedTab, !selectedItem.content.isEmpty else { return ["No content"] }
|
||||
|
||||
switch selectedItem.language.lowercased() {
|
||||
case "markdown":
|
||||
return selectedItem.content.components(separatedBy: .newlines)
|
||||
.filter { $0.hasPrefix("#") }
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "^#+\\s*", with: "", options: .regularExpression) }
|
||||
case "swift", "c", "cpp", "java", "python":
|
||||
return selectedItem.content.components(separatedBy: .newlines)
|
||||
.filter { $0.contains("func") || $0.contains("def") || $0.contains("class") }
|
||||
.map { line in
|
||||
let components = line.components(separatedBy: .whitespaces)
|
||||
return components.last { !$0.isEmpty && !["func", "def", "class"].contains($0) } ?? line
|
||||
}
|
||||
default:
|
||||
return ["No table of contents available for \(selectedItem.language)"]
|
||||
}
|
||||
}
|
||||
|
||||
// Find line number for a given TOC item
|
||||
func lineNumber(for tocItem: String) -> Int? {
|
||||
guard let selectedItem = selectedTab, !selectedItem.content.isEmpty else { return nil }
|
||||
let lines = selectedItem.content.components(separatedBy: .newlines)
|
||||
return lines.firstIndex { $0.contains(tocItem) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SidebarView
|
||||
struct SidebarView: View {
|
||||
@ObservedObject var viewModel: ContentViewModel
|
||||
|
||||
var body: some View {
|
||||
List(viewModel.generateTableOfContents(), id: \.self, selection: $viewModel.selectedTOCItem) { item in
|
||||
Text(item)
|
||||
.foregroundColor(Color.gray)
|
||||
.onTapGesture {
|
||||
viewModel.selectedTOCItem = item
|
||||
if let lineNumber = viewModel.lineNumber(for: item) {
|
||||
NotificationCenter.default.post(name: .moveCursorToLine, object: lineNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 200)
|
||||
.background(Color(nsColor: .windowBackgroundColor).opacity(0.85))
|
||||
.listStyle(SidebarListStyle())
|
||||
.frame(minHeight: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContentView
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@StateObject private var viewModel = ContentViewModel()
|
||||
@EnvironmentObject var viewModel: ViewModel
|
||||
@State private var showSidebar: Bool = true
|
||||
@State private var selectedTOCItem: String? = nil
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $viewModel.selectedTab) {
|
||||
ForEach(viewModel.tabs, id: \.id) { tab in
|
||||
VStack(spacing: 0) {
|
||||
VStack {
|
||||
HStack(spacing: 0) {
|
||||
if viewModel.showSidebar {
|
||||
SidebarView(viewModel: viewModel)
|
||||
if showSidebar {
|
||||
SidebarView(content: tab.content, language: tab.language, selectedTOCItem: $selectedTOCItem)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
CustomTextEditor(text: Binding(
|
||||
get: { tab.content },
|
||||
set: { viewModel.updateContent($0) }
|
||||
), language: $viewModel.selectedLanguage, highlightr: HighlightrViewModel().highlightr)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
CustomTextEditor(text: Binding(
|
||||
get: { tab.content },
|
||||
set: { tab.content = $0 }
|
||||
), language: Binding(
|
||||
get: { tab.language },
|
||||
set: { tab.language = $0 }
|
||||
), highlightr: HighlightrViewModel().highlightr)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(minWidth: 1000, minHeight: 600)
|
||||
.background(Color(nsColor: .textBackgroundColor).opacity(0.85))
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
HStack(spacing: 10) {
|
||||
Picker("", selection: Binding(
|
||||
get: { tab.language },
|
||||
set: { tab.language = $0 }
|
||||
)) {
|
||||
ForEach(["HTML", "C", "Swift", "Python", "C++", "Java", "Bash", "JSON", "Markdown"], id: \.self) { lang in
|
||||
Text(lang).tag(lang.lowercased())
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.onChange(of: tab.language) { _, newValue in
|
||||
NotificationCenter.default.post(name: .languageChanged, object: newValue)
|
||||
}
|
||||
Button(action: { showSidebar.toggle() }) {
|
||||
Image(systemName: "sidebar.left")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: { openFile(tab) }) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: { saveFile(tab) }) {
|
||||
Image(systemName: "floppydisk")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: { saveAsFile(tab) }) {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Text(tab.name)
|
||||
}
|
||||
.tag(tab as Item?)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1000, minHeight: 600)
|
||||
.background(Color(nsColor: .textBackgroundColor).opacity(0.85))
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .automatic) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Neon Vision Editor")
|
||||
.foregroundColor(.white)
|
||||
Picker("", selection: $viewModel.selectedLanguage) {
|
||||
ForEach(viewModel.languages, id: \.self) { lang in
|
||||
Text(lang).tag(lang.lowercased())
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
Button(action: viewModel.toggleSidebar) {
|
||||
Image(systemName: "sidebar.left")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: viewModel.openFile) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: viewModel.saveFile) {
|
||||
Image(systemName: "floppydisk")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: viewModel.saveAsFile) {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Button(action: viewModel.addNewTab) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.tag(tab as Tab?)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.automatic) // Enables native macOS tabbing
|
||||
.onAppear {
|
||||
viewModel.setModelContext(modelContext)
|
||||
print("ContentView appeared, loading tabs. Tabs count: \(viewModel.tabs.count)")
|
||||
viewModel.loadTabs()
|
||||
print("After load, tabs count: \(viewModel.tabs.count), selectedTab: \(String(describing: viewModel.selectedTab))")
|
||||
}
|
||||
}
|
||||
|
||||
func openFile(_ tab: Tab) {
|
||||
let openPanel = NSOpenPanel()
|
||||
openPanel.allowedContentTypes = [.text, .sourceCode, UTType.html, UTType.cSource, UTType.swiftSource, UTType.pythonScript, UTType("public.shell-script")!, UTType("public.markdown")!]
|
||||
openPanel.allowsMultipleSelection = false
|
||||
openPanel.canChooseDirectories = false
|
||||
openPanel.canChooseFiles = true
|
||||
|
||||
guard openPanel.runModal() == .OK, let url = openPanel.url else { return }
|
||||
do {
|
||||
let content = try String(contentsOf: url, encoding: .utf8)
|
||||
let language = languageMap[url.pathExtension.lowercased()] ?? "plaintext"
|
||||
tab.content = content
|
||||
tab.language = language
|
||||
tab.name = url.lastPathComponent
|
||||
if let window = NSApplication.shared.windows.first {
|
||||
window.title = tab.name
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
window.title = tab.name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Error opening file: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func saveFile(_ tab: Tab) {
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.allowedContentTypes = [.text, .sourceCode, UTType.html, UTType.cSource, UTType.swiftSource, UTType.pythonScript, UTType("public.shell-script")!, UTType("public.markdown")!]
|
||||
savePanel.nameFieldStringValue = tab.name
|
||||
|
||||
guard savePanel.runModal() == .OK, let url = savePanel.url else { return }
|
||||
do {
|
||||
try tab.content.write(to: url, atomically: true, encoding: .utf8)
|
||||
try viewModel.saveTab(tab)
|
||||
tab.name = url.lastPathComponent
|
||||
if let window = NSApplication.shared.windows.first {
|
||||
window.title = tab.name
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
window.title = tab.name
|
||||
}
|
||||
}
|
||||
print("Successfully saved file to: \(url.path)")
|
||||
} catch {
|
||||
print("Error saving file: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func saveAsFile(_ tab: Tab) {
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.allowedContentTypes = [.text, .sourceCode, UTType.html, UTType.cSource, UTType.swiftSource, UTType.pythonScript, UTType("public.shell-script")!, UTType("public.markdown")!]
|
||||
savePanel.nameFieldStringValue = tab.name
|
||||
|
||||
guard savePanel.runModal() == .OK, let url = savePanel.url else { return }
|
||||
do {
|
||||
try tab.content.write(to: url, atomically: true, encoding: .utf8)
|
||||
try viewModel.saveTab(tab)
|
||||
tab.name = url.lastPathComponent
|
||||
if let window = NSApplication.shared.windows.first {
|
||||
window.title = tab.name
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
window.title = tab.name
|
||||
}
|
||||
}
|
||||
print("Successfully saved as file to: \(url.path)")
|
||||
} catch {
|
||||
print("Error saving as file: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let languageMap: [String: String] = [
|
||||
"html": "html", "htm": "html",
|
||||
"c": "c", "h": "c",
|
||||
"swift": "swift",
|
||||
"py": "python",
|
||||
"cpp": "cpp",
|
||||
"java": "java",
|
||||
"sh": "bash",
|
||||
"json": "json",
|
||||
"md": "markdown", "markdown": "markdown"
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - SidebarView
|
||||
struct SidebarView: View {
|
||||
let content: String
|
||||
let language: String
|
||||
@Binding var selectedTOCItem: String?
|
||||
|
||||
var body: some View {
|
||||
List(generateTableOfContents(), id: \.self, selection: $selectedTOCItem) { item in
|
||||
Text(item)
|
||||
.foregroundColor(.gray)
|
||||
.onTapGesture {
|
||||
selectedTOCItem = item
|
||||
if let lineNumber = lineNumber(for: item) {
|
||||
NotificationCenter.default.post(name: .moveCursorToLine, object: lineNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 200)
|
||||
.background(Color(nsColor: .windowBackgroundColor).opacity(0.85))
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
func generateTableOfContents() -> [String] {
|
||||
if content.isEmpty {
|
||||
return ["No content"]
|
||||
}
|
||||
switch language.lowercased() {
|
||||
case "markdown":
|
||||
return content.components(separatedBy: .newlines)
|
||||
.filter { $0.hasPrefix("#") }
|
||||
.map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "^#+\\s*", with: "", options: .regularExpression) }
|
||||
case "swift", "c", "cpp", "java", "python":
|
||||
return content.components(separatedBy: .newlines)
|
||||
.filter { $0.contains("func") || $0.contains("def") || $0.contains("class") }
|
||||
.map { line in
|
||||
let components = line.components(separatedBy: .whitespaces)
|
||||
return components.last { !$0.isEmpty && !["func", "def", "class"].contains($0) } ?? line
|
||||
}
|
||||
default:
|
||||
return ["No table of contents available for \(language)"]
|
||||
}
|
||||
}
|
||||
|
||||
func lineNumber(for tocItem: String) -> Int? {
|
||||
if content.isEmpty {
|
||||
return nil
|
||||
}
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
return lines.firstIndex { $0.contains(tocItem) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomTextEditor
|
||||
|
|
@ -316,13 +234,21 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
let textView = NSTextView(frame: .zero, textContainer: textContainer)
|
||||
textView.isEditable = true
|
||||
textView.isSelectable = true
|
||||
textView.font = NSFont.monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
textView.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.85)
|
||||
textView.textColor = NSColor.gray
|
||||
textView.font = NSFont(name: "SFMono-Regular", size: 12.0) ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
|
||||
let appearance = NSAppearance.currentDrawing()
|
||||
if appearance.name == .darkAqua {
|
||||
textView.backgroundColor = NSColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 0.85)
|
||||
textView.textColor = NSColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 0.85)
|
||||
} else {
|
||||
textView.backgroundColor = NSColor(red: 1, green: 1, blue: 1, alpha: 0.85)
|
||||
textView.textColor = NSColor(red: 0, green: 0, blue: 0, alpha: 0.85)
|
||||
}
|
||||
|
||||
textView.delegate = context.coordinator
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.maxSize = NSSize(width: .greatestFiniteMagnitude, height: .greatestFiniteMagnitude)
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = textView
|
||||
|
|
@ -331,12 +257,20 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
scrollView.autoresizingMask = [.width, .height]
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Add observer for cursor movement
|
||||
NotificationCenter.default.addObserver(forName: .moveCursorToLine, object: nil, queue: .main) { notification in
|
||||
if let lineNumber = notification.object as? Int {
|
||||
textView.scrollToLine(lineNumber)
|
||||
textView.setSelectedRange(NSRange(location: textView.lineStartIndex(at: lineNumber) ?? 0, length: 0))
|
||||
NotificationCenter.default.addObserver(forName: .languageChanged, object: nil, queue: .main) { notification in
|
||||
guard let newLanguage = notification.object as? String else { return }
|
||||
language = newLanguage
|
||||
textView.string = text
|
||||
if let highlightedText = highlightr.highlight(text, as: newLanguage == "markdown" ? "md" : newLanguage) {
|
||||
textStorage.setAttributedString(highlightedText)
|
||||
}
|
||||
textStorage.language = newLanguage == "markdown" ? "md" : newLanguage
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .moveCursorToLine, object: nil, queue: .main) { notification in
|
||||
guard let lineNumber = notification.object as? Int else { return }
|
||||
textView.scrollToLine(lineNumber)
|
||||
textView.setSelectedRange(NSRange(location: textView.lineStartIndex(at: lineNumber) ?? 0, length: 0))
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -353,16 +287,15 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
||||
if let textView = nsView.documentView as? NSTextView, textView.string != text {
|
||||
textView.string = text
|
||||
let textStorage = textView.textStorage as! CodeAttributedString
|
||||
textStorage.beginEditing()
|
||||
if let highlightedText = highlightr.highlight(text, as: language == "markdown" ? "md" : language) {
|
||||
textStorage.setAttributedString(highlightedText)
|
||||
}
|
||||
textStorage.endEditing()
|
||||
textStorage.language = language == "markdown" ? "md" : language
|
||||
guard let textView = nsView.documentView as? NSTextView, textView.string != text else { return }
|
||||
textView.string = text
|
||||
let textStorage = textView.textStorage as! CodeAttributedString
|
||||
textStorage.beginEditing()
|
||||
if let highlightedText = highlightr.highlight(text, as: language == "markdown" ? "md" : language) {
|
||||
textStorage.setAttributedString(highlightedText)
|
||||
}
|
||||
textStorage.endEditing()
|
||||
textStorage.language = language == "markdown" ? "md" : language
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
|
|
@ -370,16 +303,15 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: CustomTextEditor
|
||||
let parent: CustomTextEditor
|
||||
|
||||
init(_ parent: CustomTextEditor) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
if let textView = notification.object as? NSTextView {
|
||||
parent.text = textView.string
|
||||
}
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
parent.text = textView.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -390,35 +322,20 @@ extension NSTextView {
|
|||
guard lineNumber >= 0, lineNumber < string.components(separatedBy: .newlines).count else { return nil }
|
||||
let lines = string.components(separatedBy: .newlines)
|
||||
var position = 0
|
||||
for i in 0...lineNumber {
|
||||
if i < lines.count {
|
||||
position += lines[i].count + 1 // +1 for newline
|
||||
}
|
||||
for i in 0...lineNumber where i < lines.count {
|
||||
position += lines[i].count + 1
|
||||
}
|
||||
return position > string.count ? nil : position
|
||||
}
|
||||
|
||||
func scrollToLine(_ lineNumber: Int) {
|
||||
guard let startIndex = lineStartIndex(at: lineNumber) else { return }
|
||||
let glyphRange = layoutManager?.glyphRange(forCharacterRange: NSRange(location: startIndex, length: 0), actualCharacterRange: nil)
|
||||
if let glyphRange = glyphRange {
|
||||
scrollRangeToVisible(glyphRange)
|
||||
}
|
||||
guard let glyphRange = layoutManager?.glyphRange(forCharacterRange: NSRange(location: startIndex, length: 0), actualCharacterRange: nil) else { return }
|
||||
scrollRangeToVisible(glyphRange)
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let moveCursorToLine = Notification.Name("moveCursorToLine")
|
||||
}
|
||||
|
||||
// MARK: - HighlightrViewModel
|
||||
class HighlightrViewModel {
|
||||
let highlightr: Highlightr = {
|
||||
if let h = Highlightr() {
|
||||
h.setTheme(to: "vs2015")
|
||||
return h
|
||||
} else {
|
||||
fatalError("Highlightr initialization failed. Ensure the package is correctly added via Swift Package Manager.")
|
||||
}
|
||||
}()
|
||||
}
|
||||
static let languageChanged = Notification.Name("languageChanged")
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import SwiftData
|
||||
import Foundation // Added to provide UUID type
|
||||
|
||||
@Model
|
||||
class Item: Identifiable {
|
||||
var id = UUID() // Unique identifier for Identifiable conformance
|
||||
var name: String
|
||||
var content: String
|
||||
var language: String
|
||||
|
||||
init(name: String, content: String, language: String) {
|
||||
self.name = name
|
||||
self.content = content
|
||||
self.language = language
|
||||
}
|
||||
}
|
||||
151
Neon Vision Editor/Neon.swift
Normal file
151
Neon Vision Editor/Neon.swift
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct Neon_Vision_EditorApp: App {
|
||||
let container: ModelContainer
|
||||
@StateObject private var viewModel = ViewModel()
|
||||
@State private var showCloseAlert = false
|
||||
@State private var unsavedTabs: [Tab] = []
|
||||
|
||||
init() {
|
||||
do {
|
||||
container = try ModelContainer(for: Tab.self)
|
||||
} catch {
|
||||
fatalError("Failed to create ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.modelContext, container.mainContext)
|
||||
.environmentObject(viewModel)
|
||||
.frame(minWidth: 1000, minHeight: 600)
|
||||
.alert(isPresented: $showCloseAlert) {
|
||||
Alert(
|
||||
title: Text("Save Changes?"),
|
||||
message: Text("You have unsaved changes. Do you want to save them?"),
|
||||
primaryButton: .default(Text("Save All")) {
|
||||
saveAllTabs()
|
||||
},
|
||||
secondaryButton: .destructive(Text("Discard")) {
|
||||
discardUnsaved()
|
||||
},
|
||||
tertiaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
.defaultSize(width: 1000, height: 600)
|
||||
.handlesExternalEvents(matching: Set(arrayLiteral: "*"))
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Tab") {
|
||||
viewModel.addTab()
|
||||
}
|
||||
.keyboardShortcut("t", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.tabs) { _, _ in
|
||||
unsavedTabs = viewModel.tabs.filter { !$0.content.isEmpty }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { _ in
|
||||
if !unsavedTabs.isEmpty {
|
||||
showCloseAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAllTabs() {
|
||||
for tab in unsavedTabs {
|
||||
let savePanel = NSSavePanel()
|
||||
savePanel.nameFieldStringValue = tab.name
|
||||
if savePanel.runModal() == .OK, let url = savePanel.url {
|
||||
do {
|
||||
try tab.content.write(to: url, atomically: true, encoding: .utf8)
|
||||
try viewModel.saveTab(tab)
|
||||
} catch {
|
||||
print("Error saving tab: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
unsavedTabs.removeAll()
|
||||
}
|
||||
|
||||
private func discardUnsaved() {
|
||||
viewModel.discardUnsaved()
|
||||
unsavedTabs.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
@Observable
|
||||
class ViewModel {
|
||||
var tabs: [Tab] = []
|
||||
var selectedTab: Tab?
|
||||
var modelContext: ModelContext?
|
||||
|
||||
func setModelContext(_ context: ModelContext) {
|
||||
modelContext = context
|
||||
if tabs.isEmpty {
|
||||
addTab()
|
||||
}
|
||||
}
|
||||
|
||||
func addTab() {
|
||||
let newTab = Tab()
|
||||
tabs.append(newTab)
|
||||
selectedTab = newTab
|
||||
if let window = NSApplication.shared.windows.first {
|
||||
window.title = newTab.name
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
window.title = newTab.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeTab(_ tab: Tab) {
|
||||
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
||||
tabs.remove(at: index)
|
||||
selectedTab = tabs.last
|
||||
if let window = NSApplication.shared.windows.first {
|
||||
window.title = selectedTab?.name ?? "Note"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
window.title = selectedTab?.name ?? "Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveTab(_ tab: Tab) throws {
|
||||
guard let context = modelContext else { throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No model context"]) }
|
||||
do {
|
||||
try context.save()
|
||||
if let existingItem = try context.fetch(FetchDescriptor<Tab>(sortBy: [SortDescriptor(\Tab.name)]))
|
||||
.first(where: { $0.name == tab.name }) {
|
||||
existingItem.content = tab.content
|
||||
existingItem.language = tab.language
|
||||
} else {
|
||||
context.insert(tab)
|
||||
}
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func discardUnsaved() {
|
||||
guard let context = modelContext else { return }
|
||||
do {
|
||||
let descriptor = FetchDescriptor<Tab>()
|
||||
let allTabs = try context.fetch(descriptor)
|
||||
for tab in allTabs {
|
||||
context.delete(tab)
|
||||
}
|
||||
try context.save()
|
||||
tabs.removeAll()
|
||||
addTab()
|
||||
} catch {
|
||||
print("Error discarding unsaved tabs: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct Neon_Vision_EditorApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self) // Configure the model container for the Item schema
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Neon Vision Editor/Tab.swift
Normal file
17
Neon Vision Editor/Tab.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Tab
|
||||
@Model
|
||||
class Tab {
|
||||
var id: UUID = UUID()
|
||||
var name: String
|
||||
var content: String
|
||||
var language: String
|
||||
|
||||
init(name: String = "Note", content: String = "", language: String = "swift") {
|
||||
self.name = name
|
||||
self.content = content
|
||||
self.language = language
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue