Adjustments to tabbar approach and syntax highlighting of text code

This commit is contained in:
Rodric Krogh 2025-08-27 13:34:59 +02:00
parent 9190efbbda
commit 0faaff61cb
6 changed files with 554 additions and 544 deletions

View file

@ -3,19 +3,33 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 90;
objects = {
/* Begin PBXBuildFile section */
98E2A9AC2E5C5075005FFE78 /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 98E2A9AB2E5C5075005FFE78 /* Highlightr */; };
983EEA302E5F22DA00E19094 /* SwiftData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 98EAE6592E5F1E890050E579 /* SwiftData.framework */; };
983EEA312E5F22DA00E19094 /* SwiftData.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 98EAE6592E5F1E890050E579 /* SwiftData.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
983EEA322E5F22DA00E19094 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
dstPath = "";
dstSubfolder = Frameworks;
files = (
983EEA312E5F22DA00E19094 /* SwiftData.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
98E2A99A2E5C4A98005FFE78 /* Neon Vision Editor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Neon Vision Editor.app"; sourceTree = BUILT_PRODUCTS_DIR; };
98EAE6332E5F15E80050E579 /* Neon Vision Editor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Neon Vision Editor.app"; sourceTree = BUILT_PRODUCTS_DIR; };
98EAE6592E5F1E890050E579 /* SwiftData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftData.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/SwiftData.framework; sourceTree = DEVELOPER_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
98E2A99C2E5C4A98005FFE78 /* Neon Vision Editor */ = {
98EAE6352E5F15E80050E579 /* Neon Vision Editor */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Neon Vision Editor";
sourceTree = "<group>";
@ -23,118 +37,114 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
98E2A9972E5C4A98005FFE78 /* Frameworks */ = {
98EAE6302E5F15E80050E579 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
98E2A9AC2E5C5075005FFE78 /* Highlightr in Frameworks */,
983EEA302E5F22DA00E19094 /* SwiftData.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
98E2A9912E5C4A98005FFE78 = {
98EAE62A2E5F15E80050E579 = {
isa = PBXGroup;
children = (
98E2A99C2E5C4A98005FFE78 /* Neon Vision Editor */,
98E2A99B2E5C4A98005FFE78 /* Products */,
98EAE6352E5F15E80050E579 /* Neon Vision Editor */,
98EAE6532E5F175B0050E579 /* Frameworks */,
98EAE6342E5F15E80050E579 /* Products */,
);
sourceTree = "<group>";
};
98E2A99B2E5C4A98005FFE78 /* Products */ = {
98EAE6342E5F15E80050E579 /* Products */ = {
isa = PBXGroup;
children = (
98E2A99A2E5C4A98005FFE78 /* Neon Vision Editor.app */,
98EAE6332E5F15E80050E579 /* Neon Vision Editor.app */,
);
name = Products;
sourceTree = "<group>";
};
98EAE6532E5F175B0050E579 /* Frameworks */ = {
isa = PBXGroup;
children = (
98EAE6592E5F1E890050E579 /* SwiftData.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
98E2A9992E5C4A98005FFE78 /* Neon Vision Editor */ = {
98EAE6322E5F15E80050E579 /* Neon Vision Editor */ = {
isa = PBXNativeTarget;
buildConfigurationList = 98E2A9A72E5C4A9A005FFE78 /* Build configuration list for PBXNativeTarget "Neon Vision Editor" */;
buildConfigurationList = 98EAE6402E5F15EC0050E579 /* Build configuration list for PBXNativeTarget "Neon Vision Editor" */;
buildPhases = (
98E2A9962E5C4A98005FFE78 /* Sources */,
98E2A9972E5C4A98005FFE78 /* Frameworks */,
98E2A9982E5C4A98005FFE78 /* Resources */,
98EAE62F2E5F15E80050E579 /* Sources */,
98EAE6302E5F15E80050E579 /* Frameworks */,
98EAE6312E5F15E80050E579 /* Resources */,
983EEA322E5F22DA00E19094 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
98E2A99C2E5C4A98005FFE78 /* Neon Vision Editor */,
98EAE6352E5F15E80050E579 /* Neon Vision Editor */,
);
name = "Neon Vision Editor";
packageProductDependencies = (
98E2A9AB2E5C5075005FFE78 /* Highlightr */,
);
productName = "Neon Vision Editor";
productReference = 98E2A99A2E5C4A98005FFE78 /* Neon Vision Editor.app */;
productReference = 98EAE6332E5F15E80050E579 /* Neon Vision Editor.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
98E2A9922E5C4A98005FFE78 /* Project object */ = {
98EAE62B2E5F15E80050E579 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
98E2A9992E5C4A98005FFE78 = {
98EAE6322E5F15E80050E579 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = 98E2A9952E5C4A98005FFE78 /* Build configuration list for PBXProject "Neon Vision Editor" */;
buildConfigurationList = 98EAE62E2E5F15E80050E579 /* Build configuration list for PBXProject "Neon Vision Editor" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 98E2A9912E5C4A98005FFE78;
mainGroup = 98EAE62A2E5F15E80050E579;
minimizedProjectReferenceProxies = 1;
packageReferences = (
98E2A9AA2E5C5075005FFE78 /* XCRemoteSwiftPackageReference "Highlightr" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 98E2A99B2E5C4A98005FFE78 /* Products */;
preferredProjectObjectVersion = 90;
productRefGroup = 98EAE6342E5F15E80050E579 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
98E2A9992E5C4A98005FFE78 /* Neon Vision Editor */,
98EAE6322E5F15E80050E579 /* Neon Vision Editor */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
98E2A9982E5C4A98005FFE78 /* Resources */ = {
98EAE6312E5F15E80050E579 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
98E2A9962E5C4A98005FFE78 /* Sources */ = {
98EAE62F2E5F15E80050E579 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
98E2A9A52E5C4A9A005FFE78 /* Debug */ = {
98EAE63E2E5F15EC0050E579 /* Debug configuration for PBXProject "Neon Vision Editor" */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
@ -169,6 +179,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -195,7 +206,7 @@
};
name = Debug;
};
98E2A9A62E5C4A9A005FFE78 /* Release */ = {
98EAE63F2E5F15EC0050E579 /* Release configuration for PBXProject "Neon Vision Editor" */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
@ -230,6 +241,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -248,20 +260,22 @@
};
name = Release;
};
98E2A9A82E5C4A9A005FFE78 /* Debug */ = {
98EAE6412E5F15EC0050E579 /* Debug configuration for PBXNativeTarget "Neon Vision Editor" */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CFBundleIconName = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@ -269,6 +283,7 @@
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@ -280,34 +295,38 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
98E2A9A92E5C4A9A005FFE78 /* Release */ = {
98EAE6422E5F15EC0050E579 /* Release configuration for PBXNativeTarget "Neon Vision Editor" */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CFBundleIconName = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Neon Vision Editor";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@ -315,6 +334,7 @@
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@ -326,16 +346,18 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
@ -343,44 +365,23 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
98E2A9952E5C4A98005FFE78 /* Build configuration list for PBXProject "Neon Vision Editor" */ = {
98EAE62E2E5F15E80050E579 /* Build configuration list for PBXProject "Neon Vision Editor" */ = {
isa = XCConfigurationList;
buildConfigurations = (
98E2A9A52E5C4A9A005FFE78 /* Debug */,
98E2A9A62E5C4A9A005FFE78 /* Release */,
98EAE63E2E5F15EC0050E579 /* Debug configuration for PBXProject "Neon Vision Editor" */,
98EAE63F2E5F15EC0050E579 /* Release configuration for PBXProject "Neon Vision Editor" */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
98E2A9A72E5C4A9A005FFE78 /* Build configuration list for PBXNativeTarget "Neon Vision Editor" */ = {
98EAE6402E5F15EC0050E579 /* Build configuration list for PBXNativeTarget "Neon Vision Editor" */ = {
isa = XCConfigurationList;
buildConfigurations = (
98E2A9A82E5C4A9A005FFE78 /* Debug */,
98E2A9A92E5C4A9A005FFE78 /* Release */,
98EAE6412E5F15EC0050E579 /* Debug configuration for PBXNativeTarget "Neon Vision Editor" */,
98EAE6422E5F15EC0050E579 /* Release configuration for PBXNativeTarget "Neon Vision Editor" */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
98E2A9AA2E5C5075005FFE78 /* XCRemoteSwiftPackageReference "Highlightr" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/raspu/Highlightr";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.3.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
98E2A9AB2E5C5075005FFE78 /* Highlightr */ = {
isa = XCSwiftPackageProductDependency;
package = 98E2A9AA2E5C5075005FFE78 /* XCRemoteSwiftPackageReference "Highlightr" */;
productName = Highlightr;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 98E2A9922E5C4A98005FFE78 /* Project object */;
rootObject = 98EAE62B2E5F15E80050E579 /* Project object */;
}

View file

@ -1,15 +0,0 @@
{
"originHash" : "0a8fce6d3cd251ad12ea6d2f48f55bfdc4fa43f93e39dce01f4b2baa78e6feb8",
"pins" : [
{
"identity" : "highlightr",
"kind" : "remoteSourceControl",
"location" : "https://github.com/raspu/Highlightr",
"state" : {
"revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
"version" : "2.3.0"
}
}
],
"version" : 3
}

View file

@ -1,341 +1,481 @@
import SwiftUI
import Highlightr
import UniformTypeIdentifiers
import Combine
import SwiftData
import UniformTypeIdentifiers
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
// MARK: - ContentView
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var viewModel: ViewModel
@Query private var tabs: [Tab]
@State private var selectedTab: Tab?
@State private var showSidebar: Bool = true
@State private var selectedTOCItem: String? = nil
#if os(iOS)
@State private var showingDocumentPicker = false
#endif
private let languages: [String] = ["Swift", "Python", "C", "C++", "Java", "HTML", "Markdown", "JSON", "Bash"]
private let languageMap: [String: String] = [
"swift": "swift", "py": "python", "c": "c", "cpp": "cpp", "java": "java",
"html": "html", "htm": "html", "md": "markdown", "json": "json", "sh": "bash"
]
var body: some View {
TabView(selection: $viewModel.selectedTab) {
ForEach(viewModel.tabs, id: \.id) { tab in
VStack {
HStack(spacing: 0) {
if showSidebar {
SidebarView(content: tab.content, language: tab.language, selectedTOCItem: $selectedTOCItem)
}
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()
NavigationSplitView {
List(tabs, selection: $selectedTab) { tab in
Text(tab.name)
.tag(tab)
.foregroundColor(.gray)
}
.listStyle(.sidebar)
.frame(minWidth: 200)
.background(Color(.windowBackgroundColor).opacity(0.85))
} detail: {
if let selectedTab = selectedTab {
CustomTextEditor(
text: Binding(
get: { selectedTab.content },
set: { selectedTab.content = $0 }
),
language: Binding(
get: { selectedTab.language },
set: { selectedTab.language = $0 }
),
isModified: Binding(
get: { selectedTab.isModified },
set: { selectedTab.isModified = $0 }
)
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.textBackgroundColor).opacity(0.85))
.toolbar {
ToolbarItemGroup {
Picker("Language", selection: Binding(
get: { selectedTab.language },
set: { selectedTab.language = $0 }
)) {
ForEach(languages, id: \.self) { lang in
Text(lang).tag(lang.lowercased() as String)
}
.padding(.horizontal, 10)
.background(Color(nsColor: .windowBackgroundColor))
.cornerRadius(8)
}
.labelsHidden()
Button(action: { showSidebar.toggle() }) {
Image(systemName: "sidebar.left")
}
Button(action: { openFile() }) {
Image(systemName: "folder.badge.plus")
}
Button(action: { saveFile(for: selectedTab) }) {
Image(systemName: "floppydisk")
}
Button(action: { saveAsFile(for: selectedTab) }) {
Image(systemName: "square.and.arrow.down")
}
}
}
.tabItem {
Text(tab.name)
.onChange(of: selectedTab.content) { _, _ in
selectedTab.isModified = true
}
.tag(tab as Tab?)
.onChange(of: selectedTab.language) { _, _ in
selectedTab.isModified = true
}
.onAppear {
#if os(macOS)
if let window = NSApplication.shared.windows.first {
window.title = selectedTab.name
}
#endif
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { _ in
if selectedTab.isModified {
promptToSave(for: selectedTab)
}
}
#elseif os(iOS)
.onDisappear {
if selectedTab.isModified {
promptToSave(for: selectedTab)
}
}
.sheet(isPresented: $showingDocumentPicker) {
DocumentPicker { url in
handleDocumentPickerSelection(url)
}
}
#endif
} else {
Text("Select a tab or create a new one")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.tabViewStyle(.automatic) // Enables native macOS tabbing
.onAppear {
viewModel.setModelContext(modelContext)
}
}
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 }
func openFile() {
#if os(macOS)
let panel = NSOpenPanel()
panel.allowedContentTypes = [.text, .sourceCode, .swiftSource, .pythonScript, .html, .cSource, .shellScript, .json, UTType("public.markdown") ?? .text]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
guard panel.runModal() == .OK, let url = panel.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
let newTab = Tab(
name: url.lastPathComponent,
content: content,
language: languageMap[url.pathExtension.lowercased()] ?? "plaintext"
)
modelContext.insert(newTab)
selectedTab = newTab
if let window = NSApplication.shared.windows.first {
window.title = tab.name
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
window.title = tab.name
}
window.title = newTab.name
}
} catch {
print("Error opening file: \(error)")
NSAlert(error: error).runModal()
}
#elseif os(iOS)
showingDocumentPicker = true
#endif
}
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)"]
#if os(iOS)
func handleDocumentPickerSelection(_ url: URL) {
do {
let content = try String(contentsOf: url, encoding: .utf8)
let newTab = Tab(
name: url.lastPathComponent,
content: content,
language: languageMap[url.pathExtension.lowercased()] ?? "plaintext"
)
modelContext.insert(newTab)
selectedTab = newTab
} catch {
let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true)
}
}
func lineNumber(for tocItem: String) -> Int? {
if content.isEmpty {
return nil
#endif
func saveFile(for tab: Tab) {
#if os(macOS)
guard tab.isModified else { return }
let panel = NSSavePanel()
panel.allowedContentTypes = [.text, .sourceCode, .swiftSource, .pythonScript, .html, .cSource, .shellScript, .json, UTType("public.markdown") ?? .text]
panel.nameFieldStringValue = tab.name
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try tab.content.write(to: url, atomically: true, encoding: .utf8)
tab.name = url.lastPathComponent
tab.isModified = false
if let window = NSApplication.shared.windows.first {
window.title = tab.name
}
} catch {
NSAlert(error: error).runModal()
}
let lines = content.components(separatedBy: .newlines)
return lines.firstIndex { $0.contains(tocItem) }
#elseif os(iOS)
// iOS save logic (simplified, as iOS file handling is complex)
// Implement UIDocumentPickerViewController for export
#endif
}
func saveAsFile(for tab: Tab) {
#if os(macOS)
let panel = NSSavePanel()
panel.allowedContentTypes = [.text, .sourceCode, .swiftSource, .pythonScript, .html, .cSource, .shellScript, .json, UTType("public.markdown") ?? .text]
panel.nameFieldStringValue = tab.name
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try tab.content.write(to: url, atomically: true, encoding: .utf8)
tab.name = url.lastPathComponent
tab.isModified = false
if let window = NSApplication.shared.windows.first {
window.title = tab.name
}
} catch {
NSAlert(error: error).runModal()
}
#elseif os(iOS)
// iOS save-as logic (simplified)
#endif
}
func promptToSave(for tab: Tab) {
guard tab.isModified else { return }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Do you want to save changes to \(tab.name)?"
alert.informativeText = "Your changes will be lost if you don't save them."
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Don't Save")
switch alert.runModal() {
case .alertFirstButtonReturn: saveFile(for: tab)
case .alertSecondButtonReturn: break
default: tab.isModified = false
}
#elseif os(iOS)
let alert = UIAlertController(title: "Save Changes?", message: "Do you want to save changes to \(tab.name)?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in saveFile(for: tab) })
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Don't Save", style: .destructive) { _ in tab.isModified = false })
UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true)
#endif
}
}
// MARK: - CustomTextEditor
struct CustomTextEditor: NSViewRepresentable {
@Binding var text: String
@Binding var language: String
let highlightr: Highlightr
func makeNSView(context: Context) -> NSScrollView {
let textStorage = CodeAttributedString(highlightr: highlightr)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer()
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
let textView = NSTextView(frame: .zero, textContainer: textContainer)
textView.isEditable = true
textView.isSelectable = true
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: .greatestFiniteMagnitude, height: .greatestFiniteMagnitude)
let scrollView = NSScrollView()
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autoresizingMask = [.width, .height]
scrollView.translatesAutoresizingMaskIntoConstraints = false
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 {
textView.string = text
textStorage.beginEditing()
if let highlightedText = highlightr.highlight(text, as: language == "markdown" ? "md" : language) {
textStorage.setAttributedString(highlightedText)
}
textStorage.endEditing()
textStorage.language = language == "markdown" ? "md" : language
}
return scrollView
#if os(iOS)
struct DocumentPicker: UIViewControllerRepresentable {
let onSelect: (URL) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.text, .sourceCode, .swiftSource, .pythonScript, .html, .cSource, .shellScript, .json, UTType("public.markdown") ?? .text])
picker.delegate = context.coordinator
return picker
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
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 updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
let parent: CustomTextEditor
init(_ parent: CustomTextEditor) {
class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: DocumentPicker
init(_ parent: DocumentPicker) {
self.parent = parent
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
if let url = urls.first {
parent.onSelect(url)
}
}
}
}
#endif
struct CustomTextEditor: View {
@Binding var text: String
@Binding var language: String
@Binding var isModified: Bool
@Environment(\.colorScheme) private var colorScheme: SwiftUI.ColorScheme
var body: some View {
#if os(macOS)
CustomTextViewRepresentable(
text: $text,
language: language,
isModified: $isModified,
colorScheme: colorScheme
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.85))
#elseif os(iOS)
CustomTextViewRepresentableiOS(
text: $text,
language: language,
isModified: $isModified,
colorScheme: colorScheme
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.85))
#endif
}
}
#if os(macOS)
struct CustomTextViewRepresentable: NSViewRepresentable {
@Binding var text: String
let language: String
@Binding var isModified: Bool
let colorScheme: SwiftUI.ColorScheme
func makeNSView(context: Context) -> NSScrollView {
let textView = NSTextView()
textView.isEditable = true
textView.isRichText = false
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.delegate = context.coordinator
textView.string = text
updateHighlighting(textView: textView)
let scrollView = NSScrollView()
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = true
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
if let textView = nsView.documentView as? NSTextView {
if textView.string != text {
textView.string = text
updateHighlighting(textView: textView)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: CustomTextViewRepresentable
init(_ parent: CustomTextViewRepresentable) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
parent.isModified = true
parent.updateHighlighting(textView: textView)
}
}
}
// MARK: - Extensions
extension NSTextView {
func lineStartIndex(at lineNumber: Int) -> Int? {
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 where i < lines.count {
position += lines[i].count + 1
private func updateHighlighting(textView: NSTextView) {
let attributedString = NSMutableAttributedString(string: textView.string)
let range = NSRange(location: 0, length: textView.string.utf16.count)
// Base attributes
attributedString.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular), range: range)
attributedString.addAttribute(.foregroundColor, value: colorScheme == .dark ? NSColor.white : NSColor.black, range: range)
// Vibrant Light highlighting
if language == "swift" {
// Keywords (pink: 0.983822 0 0.72776 1)
let keywords = ["func", "class", "struct", "let", "var"]
for keyword in keywords {
let regex = try? NSRegularExpression(pattern: "\\b\(keyword)\\b", options: [])
regex?.enumerateMatches(in: textView.string, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: NSColor(red: 0.983822, green: 0, blue: 0.72776, alpha: 1), range: matchRange)
attributedString.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold), range: matchRange)
}
}
// Strings (green: 0 0.743633 0 1)
let stringRegex = try? NSRegularExpression(pattern: "\".*?\"", options: [])
stringRegex?.enumerateMatches(in: textView.string, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: NSColor(red: 0, green: 0.743633, blue: 0, alpha: 1), range: matchRange)
}
// Comments (gray: 0.36526 0.421879 0.475154 1)
let commentRegex = try? NSRegularExpression(pattern: "//.*?\n|/\\*.*?\\*/", options: [.dotMatchesLineSeparators])
commentRegex?.enumerateMatches(in: textView.string, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: NSColor(red: 0.36526, green: 0.421879, blue: 0.475154, alpha: 1), range: matchRange)
}
// Numbers (blue: 0.11 0 0.81 1)
let numberRegex = try? NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: [])
numberRegex?.enumerateMatches(in: textView.string, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: NSColor(red: 0.11, green: 0, blue: 0.81, alpha: 1), range: matchRange)
}
}
return position > string.count ? nil : position
}
func scrollToLine(_ lineNumber: Int) {
guard let startIndex = lineStartIndex(at: lineNumber) else { return }
guard let glyphRange = layoutManager?.glyphRange(forCharacterRange: NSRange(location: startIndex, length: 0), actualCharacterRange: nil) else { return }
scrollRangeToVisible(glyphRange)
textView.textStorage?.setAttributedString(attributedString)
}
}
#elseif os(iOS)
struct CustomTextViewRepresentableiOS: UIViewRepresentable {
@Binding var text: String
let language: String
@Binding var isModified: Bool
let colorScheme: SwiftUI.ColorScheme
extension Notification.Name {
static let moveCursorToLine = Notification.Name("moveCursorToLine")
static let languageChanged = Notification.Name("languageChanged")
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.isEditable = true
textView.font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.delegate = context.coordinator
textView.text = text
updateHighlighting(textView: textView)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text {
uiView.text = text
updateHighlighting(textView: uiView)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: CustomTextViewRepresentableiOS
init(_ parent: CustomTextViewRepresentableiOS) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
parent.isModified = true
parent.updateHighlighting(textView: textView)
}
}
private func updateHighlighting(textView: UITextView) {
let attributedString = NSMutableAttributedString(string: textView.text)
let range = NSRange(location: 0, length: textView.text.utf16.count)
// Base attributes
attributedString.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: 12, weight: .regular), range: range)
attributedString.addAttribute(.foregroundColor, value: colorScheme == .dark ? UIColor.white : UIColor.black, range: range)
// Vibrant Light highlighting
if language == "swift" {
// Keywords (pink: 0.983822 0 0.72776 1)
let keywords = ["func", "class", "struct", "let", "var"]
for keyword in keywords {
let regex = try? NSRegularExpression(pattern: "\\b\(keyword)\\b", options: [])
regex?.enumerateMatches(in: textView.text, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: UIColor(red: 0.983822, green: 0, blue: 0.72776, alpha: 1), range: matchRange)
attributedString.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: 12, weight: .semibold), range: matchRange)
}
}
// Strings (green: 0 0.743633 0 1)
let stringRegex = try? NSRegularExpression(pattern: "\".*?\"", options: [])
stringRegex?.enumerateMatches(in: textView.text, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: UIColor(red: 0, green: 0.743633, blue: 0, alpha: 1), range: matchRange)
}
// Comments (gray: 0.36526 0.421879 0.475154 1)
let commentRegex = try? NSRegularExpression(pattern: "//.*?\n|/\\*.*?\\*/", options: [.dotMatchesLineSeparators])
commentRegex?.enumerateMatches(in: textView.text, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: UIColor(red: 0.36526, green: 0.421879, blue: 0.475154, alpha: 1), range: matchRange)
}
// Numbers (blue: 0.11 0 0.81 1)
let numberRegex = try? NSRegularExpression(pattern: "\\b\\d+\\.?\\d*\\b", options: [])
numberRegex?.enumerateMatches(in: textView.text, options: [], range: range) { match, _, _ in
guard let matchRange = match?.range else { return }
attributedString.addAttribute(.foregroundColor, value: UIColor(red: 0.11, green: 0, blue: 0.81, alpha: 1), range: matchRange)
}
}
textView.attributedText = attributedString
}
}
#endif

View file

@ -1,17 +1,18 @@
import Foundation
import SwiftData
// MARK: - Tab
@Model
class Tab {
final class Tab {
var id: UUID = UUID()
var name: String
var content: String
var language: String
init(name: String = "Note", content: String = "", language: String = "swift") {
var isModified: Bool
init(name: String = "Untitled", content: String = "", language: String = "swift") {
self.name = name
self.content = content
self.language = language
self.isModified = false
}
}
}

View file

@ -1,151 +0,0 @@
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)")
}
}
}

View file

@ -0,0 +1,34 @@
import SwiftUI
import SwiftData
@main
struct Neon_Vision_EditorApp: App {
let modelContainer: ModelContainer
init() {
do {
modelContainer = try ModelContainer(for: Tab.self)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.modelContext, modelContainer.mainContext)
.frame(minWidth: 1000, minHeight: 600)
}
.defaultSize(width: 1000, height: 600)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Tab") {
let context = try? ModelContainer(for: Tab.self).mainContext
let newTab = Tab(name: "Untitled \(Int.random(in: 1...1000))")
context?.insert(newTab)
}
.keyboardShortcut("t", modifiers: .command)
}
}
}
}