From 41966cd06cc113d3927d439594f43e9cddef4258 Mon Sep 17 00:00:00 2001 From: h3p Date: Tue, 17 Mar 2026 18:40:32 +0100 Subject: [PATCH] feat: prepare v0.5.6 milestone release --- CHANGELOG.md | 29 + Neon Vision Editor.xcodeproj/project.pbxproj | 4 +- .../App/NeonVisionEditorApp.swift | 36 +- .../Core/ProjectFileIndex.swift | 56 ++ .../Core/RecentFilesStore.swift | 89 ++- .../Core/ReleaseRuntimePolicy.swift | 25 + .../Core/RuntimeReliabilityMonitor.swift | 43 + .../Core/SyntaxHighlighting.swift | 314 +++++++- .../UI/ContentView+Actions.swift | 180 ++++- .../ContentView+MarkdownPreviewExport.swift | 683 ++++++++++++++++ .../UI/ContentView+StartupOverlay.swift | 159 ++++ .../UI/ContentView+Toolbar.swift | 11 + Neon Vision Editor/UI/ContentView.swift | 741 +++++------------- Neon Vision Editor/UI/EditorTextView.swift | 410 ++++++++++ .../UI/MarkdownPreviewPDFRenderer.swift | 601 ++++++++++++++ Neon Vision Editor/UI/NeonSettingsView.swift | 135 +++- Neon Vision Editor/UI/PanelsAndHelpers.swift | 28 +- Neon Vision Editor/UI/ThemeSettings.swift | 14 +- .../de.lproj/Localizable.strings | 45 ++ .../en.lproj/Localizable.strings | 45 ++ .../ReleaseRuntimePolicyTests.swift | 54 ++ README.md | 31 +- 22 files changed, 3093 insertions(+), 640 deletions(-) create mode 100644 Neon Vision Editor/Core/ProjectFileIndex.swift create mode 100644 Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift create mode 100644 Neon Vision Editor/UI/ContentView+StartupOverlay.swift create mode 100644 Neon Vision Editor/UI/MarkdownPreviewPDFRenderer.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b108ed7..0c4d794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to **Neon Vision Editor** are documented in this file. The format follows *Keep a Changelog*. Versions use semantic versioning with prerelease tags. +## [v0.5.6] - 2026-03-17 + +### Hero Screenshot +- ![v0.5.6 hero screenshot](docs/images/iphone-themes-light.png) + +### Why Upgrade +- Safe Mode now recovers from repeated failed launches without getting stuck on every normal restart. +- Large project folders now get a background file index that feeds `Quick Open` and `Find in Files` instead of relying only on live folder scans. +- Theme formatting and Settings polish now apply immediately, with better localization and an iPad hardware-keyboard Vim MVP. + +### Highlights +- Added Safe Mode startup recovery with repeated-failure detection, blank-document launch fallback, a dedicated startup explanation, and a `Normal Next Launch` recovery action. +- Added a background project file index for larger folders and wired it into `Quick Open`, `Find in Files`, and project refresh flows. +- Added an iPad hardware-keyboard Vim MVP with core normal-mode navigation/editing commands and shared mode-state reporting. +- Added theme formatting controls for bold keywords, italic comments, underlined links, and bold Markdown headings across active themes. + +### Fixes +- Fixed Safe Mode so a successful launch clears recovery state and normal restarts no longer re-enter Safe Mode unnecessarily. +- Fixed theme-formatting updates so editor styling refreshes immediately without requiring a theme switch. +- Fixed the editor font-size regression introduced by theme-formatting changes by restoring the base font before applying emphasis overrides. +- Fixed duplicated Settings tab headings, icon/title alignment, and formatting-card placement to reduce scrolling and keep the Designs tab denser. +- Fixed German Settings localization gaps and converted previously hard-coded diagnostics strings to localizable text. + +### Breaking changes +- None. + +### Migration +- None. + ## [v0.5.5] - 2026-03-16 ### Highlights diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 032b585..40f5085 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -361,7 +361,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 530; + CURRENT_PROJECT_VERSION = 531; 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 = 530; + CURRENT_PROJECT_VERSION = 531; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index 03a1c25..215630c 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -90,6 +90,10 @@ struct NeonVisionEditorApp: App { @StateObject private var supportPurchaseManager = SupportPurchaseManager() @StateObject private var appUpdateManager = AppUpdateManager() @AppStorage("SettingsAppearance") private var appearance: String = "system" + @Environment(\.scenePhase) private var scenePhase + private let mainStartupBehavior: ContentView.StartupBehavior + private let startupSafeModeMessage: String? + @State private var didMarkLaunchCompleted: Bool = false #if os(macOS) @Environment(\.openWindow) private var openWindow @State private var useAppleIntelligence: Bool = true @@ -104,6 +108,12 @@ struct NeonVisionEditorApp: App { ReleaseRuntimePolicy.preferredColorScheme(for: appearance) } + private func completeLaunchReliabilityTrackingIfNeeded() { + guard !didMarkLaunchCompleted else { return } + didMarkLaunchCompleted = true + RuntimeReliabilityMonitor.shared.markLaunchCompleted() + } + #if os(macOS) private var appKitAppearance: NSAppearance? { switch appearance { @@ -228,6 +238,9 @@ struct NeonVisionEditorApp: App { defaults.set(true, forKey: whitespaceMigrationKey) } RuntimeReliabilityMonitor.shared.markLaunch() + let safeModeDecision = RuntimeReliabilityMonitor.shared.consumeSafeModeLaunchDecision() + self.mainStartupBehavior = safeModeDecision.isEnabled ? .safeMode : .standard + self.startupSafeModeMessage = safeModeDecision.message RuntimeReliabilityMonitor.shared.startMainThreadWatchdog() EditorPerformanceMonitor.shared.markLaunchConfigured() } @@ -257,7 +270,10 @@ struct NeonVisionEditorApp: App { var body: some Scene { #if os(macOS) WindowGroup { - ContentView() + ContentView( + startupBehavior: mainStartupBehavior, + safeModeMessage: startupSafeModeMessage + ) .environment(viewModel) .environmentObject(supportPurchaseManager) .environmentObject(appUpdateManager) @@ -272,8 +288,14 @@ struct NeonVisionEditorApp: App { .environment(\.grokErrorMessage, $grokErrorMessage) .tint(.blue) .preferredColorScheme(preferredAppearance) + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + completeLaunchReliabilityTrackingIfNeeded() + } .frame(minWidth: 600, minHeight: 400) .task { + completeLaunchReliabilityTrackingIfNeeded() + guard mainStartupBehavior != .safeMode else { return } if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution { appUpdateManager.startAutomaticChecks() } @@ -404,7 +426,10 @@ struct NeonVisionEditorApp: App { } #else WindowGroup { - ContentView() + ContentView( + startupBehavior: mainStartupBehavior, + safeModeMessage: startupSafeModeMessage + ) .environment(viewModel) .environmentObject(supportPurchaseManager) .environmentObject(appUpdateManager) @@ -412,11 +437,18 @@ struct NeonVisionEditorApp: App { .environment(\.grokErrorMessage, $grokErrorMessage) .tint(.blue) .onAppear { applyIOSAppearanceOverride() } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + completeLaunchReliabilityTrackingIfNeeded() + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in RuntimeReliabilityMonitor.shared.markGracefulTermination() } .onChange(of: appearance) { _, _ in applyIOSAppearanceOverride() } .preferredColorScheme(preferredAppearance) + .task { + completeLaunchReliabilityTrackingIfNeeded() + } } .commands { CommandGroup(replacing: .undoRedo) { diff --git a/Neon Vision Editor/Core/ProjectFileIndex.swift b/Neon Vision Editor/Core/ProjectFileIndex.swift new file mode 100644 index 0000000..c6d99b6 --- /dev/null +++ b/Neon Vision Editor/Core/ProjectFileIndex.swift @@ -0,0 +1,56 @@ +import Foundation + +struct ProjectFileIndex { + static func buildFileURLs( + at root: URL, + supportedOnly: Bool, + isSupportedFile: @escaping @Sendable (URL) -> Bool + ) async -> [URL] { + await Task.detached(priority: .utility) { + let resourceKeys: [URLResourceKey] = [ + .isRegularFileKey, + .isDirectoryKey, + .isHiddenKey, + .nameKey + ] + let options: FileManager.DirectoryEnumerationOptions = [ + .skipsHiddenFiles, + .skipsPackageDescendants + ] + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: resourceKeys, + options: options + ) else { + return [] + } + + var results: [URL] = [] + results.reserveCapacity(512) + + while let fileURL = enumerator.nextObject() as? URL { + if Task.isCancelled { + return [] + } + guard let values = try? fileURL.resourceValues(forKeys: Set(resourceKeys)) else { + continue + } + if values.isHidden == true { + if values.isDirectory == true { + enumerator.skipDescendants() + } + continue + } + guard values.isRegularFile == true else { continue } + if supportedOnly && !isSupportedFile(fileURL) { + continue + } + results.append(fileURL) + } + + return results.sorted { + $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending + } + }.value + } +} diff --git a/Neon Vision Editor/Core/RecentFilesStore.swift b/Neon Vision Editor/Core/RecentFilesStore.swift index 2ab0d96..9918de8 100644 --- a/Neon Vision Editor/Core/RecentFilesStore.swift +++ b/Neon Vision Editor/Core/RecentFilesStore.swift @@ -12,6 +12,7 @@ struct RecentFilesStore { private static let recentPathsKey = "RecentFilesPathsV1" private static let pinnedPathsKey = "PinnedRecentFilesPathsV1" + private static let bookmarkMapKey = "RecentFilesBookmarksV1" private static let maximumItemCount = 30 static func items(limit: Int = maximumItemCount) -> [Item] { @@ -19,9 +20,10 @@ struct RecentFilesStore { let recentPaths = sanitizedPaths(from: defaults.stringArray(forKey: recentPathsKey) ?? []) let pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? []) let pinnedSet = Set(pinnedPaths) + let bookmarkMap = loadBookmarkMap(from: defaults) let orderedPaths = pinnedPaths + recentPaths.filter { !pinnedSet.contains($0) } - let urls = orderedPaths.prefix(limit).map { URL(fileURLWithPath: $0) } + let urls = orderedPaths.prefix(limit).map { resolveURL(forPath: $0, bookmarkMap: bookmarkMap) } return urls.map { Item(url: $0, isPinned: pinnedSet.contains($0.standardizedFileURL.path)) } } @@ -40,6 +42,12 @@ struct RecentFilesStore { defaults.set(trimmedRecent, forKey: recentPathsKey) defaults.set(pinnedPaths, forKey: pinnedPathsKey) + var bookmarkMap = loadBookmarkMap(from: defaults) + if let bookmark = makeSecurityScopedBookmark(for: url) { + bookmarkMap[standardizedPath] = bookmark + } + pruneBookmarks(&bookmarkMap, keeping: Set(trimmedRecent).union(pinnedPaths)) + saveBookmarkMap(bookmarkMap, to: defaults) postDidChange() } @@ -66,12 +74,19 @@ struct RecentFilesStore { defaults.set(recentPaths, forKey: recentPathsKey) defaults.set(pinnedPaths, forKey: pinnedPathsKey) + var bookmarkMap = loadBookmarkMap(from: defaults) + pruneBookmarks(&bookmarkMap, keeping: Set(recentPaths).union(pinnedPaths)) + saveBookmarkMap(bookmarkMap, to: defaults) postDidChange() } static func clearUnpinned() { let defaults = UserDefaults.standard + let pinnedPaths = sanitizedPaths(from: defaults.stringArray(forKey: pinnedPathsKey) ?? []) defaults.removeObject(forKey: recentPathsKey) + var bookmarkMap = loadBookmarkMap(from: defaults) + pruneBookmarks(&bookmarkMap, keeping: Set(pinnedPaths)) + saveBookmarkMap(bookmarkMap, to: defaults) postDidChange() } @@ -93,4 +108,76 @@ struct RecentFilesStore { NotificationCenter.default.post(name: .recentFilesDidChange, object: nil) } } + + private static func loadBookmarkMap(from defaults: UserDefaults) -> [String: Data] { + guard let raw = defaults.dictionary(forKey: bookmarkMapKey) else { return [:] } + var output: [String: Data] = [:] + output.reserveCapacity(raw.count) + for (path, value) in raw { + guard let data = value as? Data else { continue } + output[path] = data + } + return output + } + + private static func saveBookmarkMap(_ map: [String: Data], to defaults: UserDefaults) { + defaults.set(map, forKey: bookmarkMapKey) + } + + private static func pruneBookmarks(_ map: inout [String: Data], keeping paths: Set) { + map = map.filter { paths.contains($0.key) } + } + + private static func resolveURL(forPath path: String, bookmarkMap: [String: Data]) -> URL { + guard let bookmarkData = bookmarkMap[path] else { + return URL(fileURLWithPath: path) + } + var isStale = false + guard + let resolved = try? URL( + resolvingBookmarkData: bookmarkData, + options: bookmarkResolutionOptions, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + else { + return URL(fileURLWithPath: path) + } + return resolved.standardizedFileURL + } + + private static func makeSecurityScopedBookmark(for url: URL) -> Data? { + let didAccess: Bool + #if os(macOS) + didAccess = url.startAccessingSecurityScopedResource() + #else + didAccess = false + #endif + defer { + if didAccess { + url.stopAccessingSecurityScopedResource() + } + } + return try? url.bookmarkData( + options: bookmarkCreationOptions, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } + + private static var bookmarkResolutionOptions: URL.BookmarkResolutionOptions { + #if os(macOS) + return [.withSecurityScope] + #else + return [] + #endif + } + + private static var bookmarkCreationOptions: URL.BookmarkCreationOptions { + #if os(macOS) + return [.withSecurityScope] + #else + return [] + #endif + } } diff --git a/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift b/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift index 045db64..d5f5aa7 100644 --- a/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift +++ b/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift @@ -6,6 +6,8 @@ import SwiftUI /// MARK: - Types enum ReleaseRuntimePolicy { + static let safeModeFailureThreshold = 2 + static var isUpdaterEnabledForCurrentDistribution: Bool { #if os(macOS) return !isMacAppStoreDistribution @@ -80,4 +82,27 @@ enum ReleaseRuntimePolicy { ) -> Bool { canUseInAppPurchases && !isPurchasing && !isLoadingProducts } + + static func shouldEnterSafeMode( + consecutiveFailedLaunches: Int, + requestedManually: Bool + ) -> Bool { + requestedManually || consecutiveFailedLaunches >= safeModeFailureThreshold + } + + static func safeModeStartupMessage( + consecutiveFailedLaunches: Int, + requestedManually: Bool + ) -> String? { + guard shouldEnterSafeMode( + consecutiveFailedLaunches: consecutiveFailedLaunches, + requestedManually: requestedManually + ) else { + return nil + } + if requestedManually { + return "Safe Mode is active for this launch. Session restore and startup diagnostics are paused." + } + return "Safe Mode is active because the last \(consecutiveFailedLaunches) launch attempts did not finish cleanly. Session restore and startup diagnostics are paused." + } } diff --git a/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift b/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift index bb447e6..574ab1b 100644 --- a/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift +++ b/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift @@ -7,12 +7,21 @@ import OSLog /// MARK: - Types final class RuntimeReliabilityMonitor { + struct SafeModeLaunchDecision { + let isEnabled: Bool + let message: String? + let consecutiveFailedLaunches: Int + let requestedManually: Bool + } + static let shared = RuntimeReliabilityMonitor() private let logger = Logger(subsystem: "h3p.Neon-Vision-Editor", category: "Reliability") private let defaults = UserDefaults.standard private let activeRunKey = "Reliability.ActiveRunMarkerV1" private let crashBucketPrefix = "Reliability.CrashBucketV1." + private let consecutiveFailedLaunchesKey = "Reliability.ConsecutiveFailedLaunchesV1" + private let safeModeNextLaunchKey = "Reliability.SafeModeNextLaunchV1" private var watchdogTimer: DispatchSourceTimer? private var lastMainThreadPingUptime = ProcessInfo.processInfo.systemUptime @@ -23,6 +32,7 @@ final class RuntimeReliabilityMonitor { let key = crashBucketPrefix + currentBucketID() let current = defaults.integer(forKey: key) defaults.set(current + 1, forKey: key) + defaults.set(defaults.integer(forKey: consecutiveFailedLaunchesKey) + 1, forKey: consecutiveFailedLaunchesKey) #if DEBUG logger.warning("reliability.previous_run_unfinished bucket=\(self.currentBucketID(), privacy: .public) count=\(current + 1, privacy: .public)") #endif @@ -32,6 +42,12 @@ final class RuntimeReliabilityMonitor { func markGracefulTermination() { defaults.set(false, forKey: activeRunKey) + defaults.set(0, forKey: consecutiveFailedLaunchesKey) + } + + func markLaunchCompleted() { + defaults.set(false, forKey: activeRunKey) + defaults.set(0, forKey: consecutiveFailedLaunchesKey) } func startMainThreadWatchdog() { @@ -55,6 +71,33 @@ final class RuntimeReliabilityMonitor { #endif } + func requestSafeModeOnNextLaunch() { + defaults.set(true, forKey: safeModeNextLaunchKey) + } + + func clearSafeModeRecoveryState() { + defaults.set(0, forKey: consecutiveFailedLaunchesKey) + defaults.set(false, forKey: safeModeNextLaunchKey) + } + + func consumeSafeModeLaunchDecision() -> SafeModeLaunchDecision { + let requestedManually = defaults.bool(forKey: safeModeNextLaunchKey) + if requestedManually { + defaults.set(false, forKey: safeModeNextLaunchKey) + } + let consecutiveFailedLaunches = defaults.integer(forKey: consecutiveFailedLaunchesKey) + let message = ReleaseRuntimePolicy.safeModeStartupMessage( + consecutiveFailedLaunches: consecutiveFailedLaunches, + requestedManually: requestedManually + ) + return SafeModeLaunchDecision( + isEnabled: message != nil, + message: message, + consecutiveFailedLaunches: consecutiveFailedLaunches, + requestedManually: requestedManually + ) + } + private func currentBucketID() -> String { let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" #if os(macOS) diff --git a/Neon Vision Editor/Core/SyntaxHighlighting.swift b/Neon Vision Editor/Core/SyntaxHighlighting.swift index eb46c1e..3eb30f0 100644 --- a/Neon Vision Editor/Core/SyntaxHighlighting.swift +++ b/Neon Vision Editor/Core/SyntaxHighlighting.swift @@ -82,30 +82,308 @@ enum SyntaxPatternProfile { case jsonFast } +struct SyntaxEmphasisPatterns { + let keyword: [String] + let comment: [String] + let link: [String] + let markdownHeading: [String] +} + +private func canonicalSyntaxLanguage(_ language: String) -> String { + let normalized = language + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch normalized { + case "py", "python3": + return "python" + case "js", "mjs", "cjs": + return "javascript" + case "ts", "tsx": + return "typescript" + case "ee", "expression-engine", "expression_engine": + return "expressionengine" + case "latex", "bibtex": + return "tex" + default: + return normalized + } +} + +func syntaxEmphasisPatterns( + for language: String, + profile: SyntaxPatternProfile = .full +) -> SyntaxEmphasisPatterns { + switch canonicalSyntaxLanguage(language) { + case "swift": + return SyntaxEmphasisPatterns( + keyword: [ + "\\b(func|struct|class|enum|protocol|extension|actor|if|else|for|while|switch|case|default|guard|defer|throw|try|catch|return|init|deinit|import|typealias|associatedtype|where|public|private|fileprivate|internal|open|static|mutating|nonmutating|inout|async|await|throws|rethrows)\\b", + "(?m)^#(if|elseif|else|endif|warning|error|available)\\b.*$" + ], + comment: [ + "//.*", + "/\\*([^*]|(\\*+[^*/]))*\\*+/", + "(?m)^(///).*$", + "/\\*\\*([\\s\\S]*?)\\*+/" + ], + link: [ + "https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+", + "file://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+" + ], + markdownHeading: [] + ) + case "python": + return SyntaxEmphasisPatterns( + keyword: ["\\b(def|class|if|else|elif|for|while|try|except|with|as|import|from|return|yield|async|await)\\b"], + comment: ["#.*"], + link: [], + markdownHeading: [] + ) + case "javascript": + return SyntaxEmphasisPatterns( + keyword: ["\\b(function|var|let|const|if|else|for|while|do|try|catch|finally|return|class|extends|new|import|export|async|await)\\b"], + comment: ["//.*|/\\*([^*]|(\\*+[^*/]))*\\*+/"], + link: [], + markdownHeading: [] + ) + case "php": + return SyntaxEmphasisPatterns( + keyword: [#"\b(function|class|interface|trait|namespace|use|public|private|protected|static|final|abstract|if|else|elseif|for|foreach|while|do|switch|case|default|return|try|catch|throw|new|echo)\b"#], + comment: [#"//.*|#.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "expressionengine": + return SyntaxEmphasisPatterns( + keyword: [#"\{if(?::elseif)?\b[^}]*\}|\{\/if\}|\{:else\}"#], + comment: [#"\{!--[\s\S]*?--\}"#], + link: [], + markdownHeading: [] + ) + case "html": + return SyntaxEmphasisPatterns( + keyword: [], + comment: profile == .htmlFast ? [] : [], + link: [], + markdownHeading: [] + ) + case "css": + return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: []) + case "c", "cpp": + return SyntaxEmphasisPatterns( + keyword: ["\\b(int|float|double|char|void|if|else|for|while|do|switch|case|return)\\b"], + comment: ["//.*|/\\*([^*]|(\\*+[^*/]))*\\*+/"], + link: [], + markdownHeading: [] + ) + case "json": + return SyntaxEmphasisPatterns(keyword: [#"\b(true|false|null)\b"#], comment: [], link: [], markdownHeading: []) + case "markdown": + return SyntaxEmphasisPatterns( + keyword: [ + #"(?m)^```[A-Za-z0-9_-]*\s*$|(?m)^~~~[A-Za-z0-9_-]*\s*$"#, + #"(?m)^\s*[-*+]\s+.*$|(?m)^\s*\d+\.\s+.*$"# + ], + comment: [#"(?m)^>\s+.*$"#], + link: [ + #"\[[^\]]+\]\([^)]+\)"#, + #"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"# + ], + markdownHeading: [ + #"(?m)^\s{0,3}#{1,6}\s+.*$"#, + #"(?m)^\s{0,3}(=+|-+)\s*$"# + ] + ) + case "tex": + return SyntaxEmphasisPatterns( + keyword: [#"\\[A-Za-z@]+(\*?)"#], + comment: [#"(?m)%.*$"#], + link: [], + markdownHeading: [] + ) + case "bash": + return SyntaxEmphasisPatterns( + keyword: [#"\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|in|select|until|time)\b"#], + comment: [#"#.*"#], + link: [], + markdownHeading: [] + ) + case "zsh": + return SyntaxEmphasisPatterns( + keyword: ["\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|in|autoload|typeset|setopt|unsetopt)\\b"], + comment: ["#.*"], + link: [], + markdownHeading: [] + ) + case "powershell": + return SyntaxEmphasisPatterns( + keyword: [#"\b(function|param|if|else|elseif|foreach|for|while|switch|break|continue|return|try|catch|finally)\b"#], + comment: [#"#.*"#], + link: [], + markdownHeading: [] + ) + case "java": + return SyntaxEmphasisPatterns( + keyword: [#"\b(class|interface|enum|public|private|protected|static|final|void|int|double|float|boolean|new|return|if|else|for|while|switch|case)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "kotlin": + return SyntaxEmphasisPatterns( + keyword: [#"\b(class|object|fun|val|var|when|if|else|for|while|return|import|package|interface)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "go": + return SyntaxEmphasisPatterns( + keyword: [#"\b(package|import|func|var|const|type|struct|interface|if|else|for|switch|case|return|go|defer)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "ruby": + return SyntaxEmphasisPatterns( + keyword: [#"\b(def|class|module|if|else|elsif|end|do|while|until|case|when|begin|rescue|ensure|return)\b"#], + comment: [#"#.*"#], + link: [], + markdownHeading: [] + ) + case "rust": + return SyntaxEmphasisPatterns( + keyword: [#"\b(fn|let|mut|struct|enum|impl|trait|pub|use|mod|if|else|match|loop|while|for|return)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "typescript": + return SyntaxEmphasisPatterns( + keyword: [#"\b(function|class|interface|type|enum|const|let|var|if|else|for|while|do|try|catch|return|extends|implements)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "objective-c": + return SyntaxEmphasisPatterns( + keyword: [#"\b(if|else|for|while|switch|case|return)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "sql": + return SyntaxEmphasisPatterns( + keyword: [#"\b(SELECT|INSERT|UPDATE|DELETE|CREATE|TABLE|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|GROUP|BY|ORDER|LIMIT|VALUES|INTO)\b"#], + comment: [#"--.*"#], + link: [], + markdownHeading: [] + ) + case "xml": + return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: []) + case "yaml": + return SyntaxEmphasisPatterns( + keyword: [#"(?m)^\s*-\s+.*$"#, #"\b(true|false|null|yes|no|on|off)\b"#], + comment: [#"(?m)^\s*#.*$"#], + link: [], + markdownHeading: [] + ) + case "toml": + return SyntaxEmphasisPatterns( + keyword: [#"\b(true|false)\b"#], + comment: [#"(?m)#.*$"#], + link: [], + markdownHeading: [] + ) + case "csv": + return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: []) + case "ini": + return SyntaxEmphasisPatterns(keyword: [], comment: ["^;.*$"], link: [], markdownHeading: []) + case "vim": + return SyntaxEmphasisPatterns( + keyword: [#"\b(set|let|if|endif|for|endfor|while|endwhile|function|endfunction|command|autocmd|syntax|highlight|nnoremap|inoremap|vnoremap|map|nmap|imap|vmap)\b"#], + comment: [#"^\s*\".*$"#], + link: [], + markdownHeading: [] + ) + case "log": + return SyntaxEmphasisPatterns( + keyword: [#"\b(ERROR|ERR|FATAL|WARN|WARNING|INFO|DEBUG|TRACE)\b"#], + comment: [], + link: [#"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"#], + markdownHeading: [] + ) + case "ipynb": + return SyntaxEmphasisPatterns( + keyword: [#"\b(true|false|null)\b"#], + comment: [], + link: [], + markdownHeading: [] + ) + case "csharp": + return SyntaxEmphasisPatterns( + keyword: [#"\b(class|interface|enum|struct|namespace|using|public|private|protected|internal|static|readonly|sealed|abstract|virtual|override|async|await|new|return|if|else|for|foreach|while|do|switch|case|break|continue|try|catch|finally|throw)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "cobol": + return SyntaxEmphasisPatterns( + keyword: [#"(?i)\b(identification|environment|data|procedure|division|section|program-id|author|installati?on|date-written|date-compiled|working-storage|linkage|file-control|input-output|select|assign|fd|01|77|88|level|pic|picture|value|values|move|add|subtract|multiply|divide|compute|if|else|end-if|evaluate|when|perform|until|varying|go|to|goback|stop|run|call|accept|display|open|close|read|write|rewrite|delete|string|unstring|initialize|set|inspect)\b"#], + comment: [#"(?m)^\s*\*.*$|(?m)^\s*\*>.*$"#], + link: [], + markdownHeading: [] + ) + case "dotenv": + return SyntaxEmphasisPatterns(keyword: [], comment: [#"(?m)#.*$"#], link: [], markdownHeading: []) + case "proto": + return SyntaxEmphasisPatterns( + keyword: [#"\b(syntax|package|import|option|message|enum|service|rpc|returns|repeated|map|oneof|reserved|required|optional)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/"#], + link: [], + markdownHeading: [] + ) + case "graphql": + return SyntaxEmphasisPatterns( + keyword: [#"\b(type|interface|enum|union|input|scalar|schema|extend|implements|directive|on|query|mutation|subscription|fragment)\b"#], + comment: [#"(?m)#.*$"#], + link: [], + markdownHeading: [] + ) + case "rst": + return SyntaxEmphasisPatterns( + keyword: [#"(?m)^\s*([=\-`:'\"~^_*+<>#]{3,})\s*$"#], + comment: [#"(?m)#.*$"#], + link: [], + markdownHeading: [] + ) + case "nginx": + return SyntaxEmphasisPatterns( + keyword: [#"\b(http|server|location|upstream|map|if|set|return|rewrite|proxy_pass|listen|server_name|root|index|try_files|include|error_page|access_log|error_log|gzip|ssl|add_header)\b"#], + comment: [#"(?m)#.*$"#], + link: [], + markdownHeading: [] + ) + case "standard": + return SyntaxEmphasisPatterns( + keyword: [#"\b(if|else|for|while|do|switch|case|return|class|struct|enum|func|function|var|let|const|import|from|using|namespace|public|private|protected|static|void|new|try|catch|finally|throw)\b"#], + comment: [#"//.*|/\*([^*]|(\*+[^*/]))*\*+/|#.*"#], + link: [#"https?://[A-Za-z0-9._~:/?#@!$&'()*+,;=%-]+"#], + markdownHeading: [] + ) + case "plain": + return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: []) + default: + return SyntaxEmphasisPatterns(keyword: [], comment: [], link: [], markdownHeading: []) + } +} + // Regex patterns per language mapped to colors. Keep light-weight for performance. func getSyntaxPatterns( for language: String, colors: SyntaxColors, profile: SyntaxPatternProfile = .full ) -> [String: Color] { - let normalized = language - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let canonical: String - switch normalized { - case "py", "python3": - canonical = "python" - case "js", "mjs", "cjs": - canonical = "javascript" - case "ts", "tsx": - canonical = "typescript" - case "ee", "expression-engine", "expression_engine": - canonical = "expressionengine" - case "latex", "bibtex": - canonical = "tex" - default: - canonical = normalized - } + let canonical = canonicalSyntaxLanguage(language) switch canonical { case "swift": return [ diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 2891a49..1622241 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -1,5 +1,9 @@ import SwiftUI import Foundation +import Dispatch +#if canImport(Darwin) +import Darwin +#endif #if os(macOS) import AppKit #elseif canImport(UIKit) @@ -638,7 +642,7 @@ extension ContentView { continue } window.isOpaque = !enabled - window.backgroundColor = enabled ? .clear : NSColor.windowBackgroundColor + window.backgroundColor = editorTranslucentBackgroundColor(enabled: enabled, window: window) // Keep chrome flags constant; toggling these causes visible top-bar jumps. window.titlebarAppearsTransparent = true window.toolbarStyle = .unified @@ -650,6 +654,28 @@ extension ContentView { #endif } +#if os(macOS) + private func editorTranslucentBackgroundColor(enabled: Bool, window: NSWindow) -> NSColor { + guard enabled else { return NSColor.windowBackgroundColor } + let isDark = window.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let modeRaw = UserDefaults.standard.string(forKey: "SettingsMacTranslucencyMode") ?? "balanced" + let whiteLevel: CGFloat + let alpha: CGFloat + switch modeRaw { + case "subtle": + whiteLevel = isDark ? 0.18 : 0.90 + alpha = 0.86 + case "vibrant": + whiteLevel = isDark ? 0.12 : 0.82 + alpha = 0.72 + default: + whiteLevel = isDark ? 0.15 : 0.86 + alpha = 0.79 + } + return NSColor(calibratedWhite: whiteLevel, alpha: alpha) + } +#endif + func openProjectFolder() { #if os(macOS) let panel = NSOpenPanel() @@ -682,6 +708,95 @@ extension ContentView { } } + func refreshProjectBrowserState() { + refreshProjectTree() + refreshProjectFileIndex() + } + + func refreshProjectFileIndex() { + guard let root = projectRootFolderURL else { +#if os(macOS) + stopProjectFolderObservation() +#endif + projectFileIndexTask?.cancel() + projectFileIndexTask = nil + projectFileIndexRefreshGeneration &+= 1 + indexedProjectFileURLs = [] + isProjectFileIndexing = false + return + } + + projectFileIndexTask?.cancel() + projectFileIndexRefreshGeneration &+= 1 + let generation = projectFileIndexRefreshGeneration + let supportedOnly = showSupportedProjectFilesOnly + isProjectFileIndexing = true + + projectFileIndexTask = Task(priority: .utility) { + let urls = await ProjectFileIndex.buildFileURLs( + at: root, + supportedOnly: supportedOnly, + isSupportedFile: { url in + EditorViewModel.isSupportedEditorFileURL(url) + } + ) + guard !Task.isCancelled else { return } + await MainActor.run { + guard generation == projectFileIndexRefreshGeneration else { return } + guard projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return } + indexedProjectFileURLs = urls + isProjectFileIndexing = false + projectFileIndexTask = nil + } + } + } + +#if os(macOS) + func stopProjectFolderObservation() { + pendingProjectFolderRefreshWorkItem?.cancel() + pendingProjectFolderRefreshWorkItem = nil + projectFolderMonitorSource?.cancel() + projectFolderMonitorSource = nil + } + + func startProjectFolderObservation(for root: URL) { + stopProjectFolderObservation() + + let fileDescriptor = open(root.path, O_EVTONLY) + guard fileDescriptor >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke], + queue: DispatchQueue.global(qos: .utility) + ) + + source.setEventHandler { [root] in + DispatchQueue.main.async { + guard self.projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return } + self.pendingProjectFolderRefreshWorkItem?.cancel() + let workItem = DispatchWorkItem { [root] in + guard self.projectRootFolderURL?.standardizedFileURL == root.standardizedFileURL else { return } + self.refreshProjectBrowserState() + } + self.pendingProjectFolderRefreshWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: workItem) + } + } + + source.setCancelHandler { + close(fileDescriptor) + } + + projectFolderMonitorSource = source + source.resume() + } +#else + func stopProjectFolderObservation() {} + + func startProjectFolderObservation(for root: URL) {} +#endif + func openProjectFile(url: URL) { guard EditorViewModel.isSupportedEditorFileURL(url) else { presentUnsupportedFileAlert(for: url) @@ -689,7 +804,9 @@ extension ContentView { } if !viewModel.openFile(url: url) { presentUnsupportedFileAlert(for: url) + return } + persistSessionIfReady() } private nonisolated static func buildProjectTree(at root: URL, supportedOnly: Bool) -> [ProjectTreeNode] { @@ -718,8 +835,13 @@ extension ContentView { projectRootFolderURL = folderURL projectTreeNodes = [] quickSwitcherProjectFileURLs = [] + indexedProjectFileURLs = [] + isProjectFileIndexing = false + safeModeRecoveryPreparedForNextLaunch = false applyProjectEditorOverrides(from: folderURL) - refreshProjectTree() + startProjectFolderObservation(for: folderURL) + refreshProjectBrowserState() + persistSessionIfReady() } func clearProjectEditorOverrides() { @@ -813,14 +935,17 @@ extension ContentView { nonisolated static func findInFiles( root: URL, + candidateFiles: [URL]?, query: String, caseSensitive: Bool, maxResults: Int ) async -> [FindInFilesMatch] { await Task.detached(priority: .userInitiated) { + let searchFiles = searchCandidateFiles(root: root, candidateFiles: candidateFiles) #if os(macOS) if let ripgrepMatches = findInFilesWithRipgrep( root: root, + candidateFiles: searchFiles, query: query, caseSensitive: caseSensitive, maxResults: maxResults @@ -829,11 +954,10 @@ extension ContentView { } #endif - let files = searchableProjectFiles(at: root) var results: [FindInFilesMatch] = [] results.reserveCapacity(min(maxResults, 200)) - for file in files { + for file in searchFiles { if Task.isCancelled || results.count >= maxResults { break } let matches = findMatches( in: file, @@ -852,6 +976,7 @@ extension ContentView { #if os(macOS) private nonisolated static func findInFilesWithRipgrep( root: URL, + candidateFiles: [URL], query: String, caseSensitive: Bool, maxResults: Int @@ -860,7 +985,7 @@ extension ContentView { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.currentDirectoryURL = root - process.arguments = [ + var arguments = [ "rg", "--json", "--line-number", @@ -868,9 +993,14 @@ extension ContentView { "--max-count", String(maxResults), caseSensitive ? "-s" : "-i", - query, - root.path + query ] + if let ripgrepFileArguments = ripgrepPathArguments(root: root, candidateFiles: candidateFiles) { + arguments.append(contentsOf: ripgrepFileArguments) + } else { + arguments.append(root.path) + } + process.arguments = arguments let outputPipe = Pipe() process.standardOutput = outputPipe @@ -953,6 +1083,42 @@ extension ContentView { } #endif + private nonisolated static func searchCandidateFiles(root: URL, candidateFiles: [URL]?) -> [URL] { + if let candidateFiles, !candidateFiles.isEmpty { + return candidateFiles + } + return searchableProjectFiles(at: root) + } + +#if os(macOS) + private nonisolated static func ripgrepPathArguments(root: URL, candidateFiles: [URL]) -> [String]? { + guard !candidateFiles.isEmpty else { return [] } + var arguments: [String] = [] + arguments.reserveCapacity(candidateFiles.count) + var combinedLength = 0 + + for fileURL in candidateFiles { + let candidatePath: String + let standardizedFileURL = fileURL.standardizedFileURL + let standardizedRoot = root.standardizedFileURL + if standardizedFileURL.path.hasPrefix(standardizedRoot.path + "/") { + candidatePath = String(standardizedFileURL.path.dropFirst(standardizedRoot.path.count + 1)) + } else if standardizedFileURL == standardizedRoot { + candidatePath = standardizedFileURL.lastPathComponent + } else { + candidatePath = standardizedFileURL.path + } + combinedLength += candidatePath.utf8.count + 1 + if candidateFiles.count > 2_000 || combinedLength > 120_000 { + return nil + } + arguments.append(candidatePath) + } + + return arguments + } +#endif + private nonisolated static func searchableProjectFiles(at root: URL) -> [URL] { let fm = FileManager.default let keys: Set = [.isRegularFileKey, .isHiddenKey, .fileSizeKey] diff --git a/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift b/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift new file mode 100644 index 0000000..1151087 --- /dev/null +++ b/Neon Vision Editor/UI/ContentView+MarkdownPreviewExport.swift @@ -0,0 +1,683 @@ +import Foundation +import SwiftUI +#if os(macOS) +import AppKit +import UniformTypeIdentifiers +#endif + +extension ContentView { + enum MarkdownPDFExportMode: String { + case paginatedFit = "paginated-fit" + case onePageFit = "one-page-fit" + } + + var markdownPDFExportMode: MarkdownPDFExportMode { + MarkdownPDFExportMode(rawValue: markdownPDFExportModeRaw) ?? .paginatedFit + } + + var markdownPDFRendererMode: MarkdownPreviewPDFRenderer.ExportMode { + switch markdownPDFExportMode { + case .onePageFit: + return .onePageFit + case .paginatedFit: + return .paginatedFit + } + } + + var markdownPreviewTemplate: String { + switch markdownPreviewTemplateRaw { + case "docs", + "article", + "compact", + "github-docs", + "academic-paper", + "terminal-notes", + "magazine", + "minimal-reader", + "presentation", + "night-contrast", + "warm-sepia", + "dense-compact", + "developer-spec": + return markdownPreviewTemplateRaw + default: + return "default" + } + } + + var markdownPreviewPreferDarkMode: Bool { + if let forcedScheme = ReleaseRuntimePolicy.preferredColorScheme(for: appearance) { + return forcedScheme == .dark + } + return colorScheme == .dark + } + + @MainActor + func exportMarkdownPreviewPDF() { + Task { @MainActor in + do { + let exportSource = await markdownExportSourceText() + let html = markdownPreviewExportHTML(from: exportSource, mode: markdownPDFExportMode) + guard markdownExportHasContrastContract(html) else { + throw NSError( + domain: "MarkdownPreviewExport", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "PDF export contrast guard failed."] + ) + } + let pdfData = try await MarkdownPreviewPDFRenderer.render( + html: html, + mode: markdownPDFRendererMode + ) + let filename = suggestedMarkdownPDFFilename() +#if os(macOS) + try saveMarkdownPreviewPDFOnMac(pdfData, suggestedFilename: filename) +#else + markdownPDFExportDocument = PDFExportDocument(data: pdfData) + markdownPDFExportFilename = filename + showMarkdownPDFExporter = true +#endif + } catch { + markdownPDFExportErrorMessage = error.localizedDescription + } + } + } + + @MainActor + func markdownExportSourceText() async -> String { + guard let fileURL = viewModel.selectedTab?.fileURL else { return currentContent } + let fallback = currentContent + return await Task.detached(priority: .userInitiated) { + let didAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + guard let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) else { + return fallback + } + if let utf8 = String(data: data, encoding: .utf8) { return utf8 } + if let utf16LE = String(data: data, encoding: .utf16LittleEndian) { return utf16LE } + if let utf16BE = String(data: data, encoding: .utf16BigEndian) { return utf16BE } + if let utf32LE = String(data: data, encoding: .utf32LittleEndian) { return utf32LE } + if let utf32BE = String(data: data, encoding: .utf32BigEndian) { return utf32BE } + return String(decoding: data, as: UTF8.self) + }.value + } + + func suggestedMarkdownPDFFilename() -> String { + let tabName = viewModel.selectedTab?.name ?? "Markdown-Preview" + let rawName = URL(fileURLWithPath: tabName).deletingPathExtension().lastPathComponent + let safeBase = rawName.isEmpty ? "Markdown-Preview" : rawName + return "\(safeBase)-Preview.pdf" + } + +#if os(macOS) + @MainActor + func saveMarkdownPreviewPDFOnMac(_ data: Data, suggestedFilename: String) throws { + let panel = NSSavePanel() + panel.title = "Export Markdown Preview as PDF" + panel.nameFieldStringValue = suggestedFilename + panel.canCreateDirectories = true + panel.allowedContentTypes = [.pdf] + guard panel.runModal() == .OK else { return } + guard let destinationURL = panel.url else { return } + try data.write(to: destinationURL, options: .atomic) + } +#endif + + var markdownPreviewRenderByteLimit: Int { 180_000 } + var markdownPreviewFallbackCharacterLimit: Int { 120_000 } + + func markdownPreviewHTML(from markdownText: String, preferDarkMode: Bool) -> String { + let bodyHTML = markdownPreviewBodyHTML(from: markdownText, useRenderLimits: true) + return """ + + + + + + + + +
+ \(bodyHTML) +
+ + + """ + } + + func markdownPreviewExportHTML(from markdownText: String, mode: MarkdownPDFExportMode) -> String { + let bodyHTML = markdownPreviewBodyHTML(from: markdownText, useRenderLimits: false) + let modeClass = mode == .onePageFit ? " pdf-one-page" : "" + return """ + + + + + + + + +
+ \(bodyHTML) +
+ + + """ + } + + func markdownExportHasContrastContract(_ html: String) -> Bool { + html.contains("body.pdf-export") && + html.contains("background: #ffffff") && + html.contains("-webkit-text-fill-color: #111827") + } + + func markdownPreviewBodyHTML(from markdownText: String, useRenderLimits: Bool) -> String { + let byteCount = markdownText.lengthOfBytes(using: .utf8) + if useRenderLimits && byteCount > markdownPreviewRenderByteLimit { + return largeMarkdownFallbackHTML(from: markdownText, byteCount: byteCount) + } + if !useRenderLimits && byteCount > markdownPreviewRenderByteLimit { + return "
\(escapedHTML(markdownText))
" + } + return renderedMarkdownBodyHTML(from: markdownText) ?? "
\(escapedHTML(markdownText))
" + } + + func largeMarkdownFallbackHTML(from markdownText: String, byteCount: Int) -> String { + let previewText = String(markdownText.prefix(markdownPreviewFallbackCharacterLimit)) + let truncated = previewText.count < markdownText.count + let statusSuffix = truncated ? " (truncated preview)" : "" + return """ +
+

Large Markdown file

+

Rendering full Markdown is skipped for stability (\(byteCount) bytes)\(statusSuffix).

+
+
\(escapedHTML(previewText))
+ """ + } + + func renderedMarkdownBodyHTML(from markdownText: String) -> String? { + let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines) + return html.isEmpty ? nil : html + } + + func simpleMarkdownToHTML(_ markdown: String) -> String { + let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + var result: [String] = [] + var paragraphLines: [String] = [] + var insideCodeFence = false + var codeFenceLanguage: String? + var insideUnorderedList = false + var insideOrderedList = false + var insideBlockquote = false + + func flushParagraph() { + guard !paragraphLines.isEmpty else { return } + let paragraph = paragraphLines.map { inlineMarkdownToHTML($0) }.joined(separator: "
") + result.append("

\(paragraph)

") + paragraphLines.removeAll(keepingCapacity: true) + } + + func closeLists() { + if insideUnorderedList { + result.append("") + insideUnorderedList = false + } + if insideOrderedList { + result.append("") + insideOrderedList = false + } + } + + func closeBlockquote() { + if insideBlockquote { + flushParagraph() + closeLists() + result.append("") + insideBlockquote = false + } + } + + func closeParagraphAndInlineContainers() { + flushParagraph() + closeLists() + } + + for rawLine in lines { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") { + if insideCodeFence { + result.append("") + insideCodeFence = false + codeFenceLanguage = nil + } else { + closeBlockquote() + closeParagraphAndInlineContainers() + insideCodeFence = true + let lang = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + codeFenceLanguage = lang.isEmpty ? nil : lang + if let codeFenceLanguage { + result.append("
")
+                    } else {
+                        result.append("
")
+                    }
+                }
+                continue
+            }
+
+            if insideCodeFence {
+                result.append("\(escapedHTML(rawLine))\n")
+                continue
+            }
+
+            if trimmed.isEmpty {
+                closeParagraphAndInlineContainers()
+                closeBlockquote()
+                continue
+            }
+
+            if let heading = markdownHeading(from: trimmed) {
+                closeBlockquote()
+                closeParagraphAndInlineContainers()
+                result.append("\(inlineMarkdownToHTML(heading.text))")
+                continue
+            }
+
+            if isMarkdownHorizontalRule(trimmed) {
+                closeBlockquote()
+                closeParagraphAndInlineContainers()
+                result.append("
") + continue + } + + var workingLine = trimmed + let isBlockquoteLine = workingLine.hasPrefix(">") + if isBlockquoteLine { + if !insideBlockquote { + closeParagraphAndInlineContainers() + result.append("
") + insideBlockquote = true + } + workingLine = workingLine.dropFirst().trimmingCharacters(in: .whitespaces) + } else { + closeBlockquote() + } + + if let unordered = markdownUnorderedListItem(from: workingLine) { + flushParagraph() + if insideOrderedList { + result.append("") + insideOrderedList = false + } + if !insideUnorderedList { + result.append("
    ") + insideUnorderedList = true + } + result.append("
  • \(inlineMarkdownToHTML(unordered))
  • ") + continue + } + + if let ordered = markdownOrderedListItem(from: workingLine) { + flushParagraph() + if insideUnorderedList { + result.append("
") + insideUnorderedList = false + } + if !insideOrderedList { + result.append("
    ") + insideOrderedList = true + } + result.append("
  1. \(inlineMarkdownToHTML(ordered))
  2. ") + continue + } + + closeLists() + paragraphLines.append(workingLine) + } + + closeBlockquote() + closeParagraphAndInlineContainers() + if insideCodeFence { + result.append("
") + } + return result.joined(separator: "\n") + } + + func markdownHeading(from line: String) -> (level: Int, text: String)? { + let pattern = "^(#{1,6})\\s+(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, options: [], range: range), + let hashesRange = Range(match.range(at: 1), in: line), + let textRange = Range(match.range(at: 2), in: line) else { + return nil + } + return (line[hashesRange].count, String(line[textRange])) + } + + func isMarkdownHorizontalRule(_ line: String) -> Bool { + let compact = line.replacingOccurrences(of: " ", with: "") + return compact == "***" || compact == "---" || compact == "___" + } + + func markdownUnorderedListItem(from line: String) -> String? { + let pattern = "^[-*+]\\s+(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, options: [], range: range), + let textRange = Range(match.range(at: 1), in: line) else { + return nil + } + return String(line[textRange]) + } + + func markdownOrderedListItem(from line: String) -> String? { + let pattern = "^\\d+[\\.)]\\s+(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, options: [], range: range), + let textRange = Range(match.range(at: 1), in: line) else { + return nil + } + return String(line[textRange]) + } + + func inlineMarkdownToHTML(_ text: String) -> String { + var html = escapedHTML(text) + var codeSpans: [String] = [] + let codeSpanTokenPrefix = "%%CODESPAN" + let codeSpanTokenSuffix = "%%" + + html = replacingRegex(in: html, pattern: "`([^`]+)`") { match in + let content = String(match.dropFirst().dropLast()) + let token = "\(codeSpanTokenPrefix)\(codeSpans.count)\(codeSpanTokenSuffix)" + codeSpans.append("\(content)") + return token + } + + html = replacingRegex(in: html, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") { match in + let parts = captureGroups(in: match, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") + guard parts.count == 2 else { return match } + return "\"\(parts[0])\"/" + } + + html = replacingRegex(in: html, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") { match in + let parts = captureGroups(in: match, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") + guard parts.count == 2 else { return match } + return "\(parts[0])" + } + + html = replacingRegex(in: html, pattern: "\\*\\*([^*]+)\\*\\*") { "\(String($0.dropFirst(2).dropLast(2)))" } + html = replacingRegex(in: html, pattern: "__([^_]+)__") { "\(String($0.dropFirst(2).dropLast(2)))" } + html = replacingRegex(in: html, pattern: "\\*([^*]+)\\*") { "\(String($0.dropFirst().dropLast()))" } + html = replacingRegex(in: html, pattern: "_([^_]+)_") { "\(String($0.dropFirst().dropLast()))" } + + for (index, codeHTML) in codeSpans.enumerated() { + html = html.replacingOccurrences( + of: "\(codeSpanTokenPrefix)\(index)\(codeSpanTokenSuffix)", + with: codeHTML + ) + } + return html + } + + func replacingRegex(in text: String, pattern: String, transform: (String) -> String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + guard !matches.isEmpty else { return text } + + var output = text + for match in matches.reversed() { + guard let range = Range(match.range, in: output) else { continue } + let segment = String(output[range]) + output.replaceSubrange(range, with: transform(segment)) + } + return output + } + + func captureGroups(in text: String, pattern: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)) else { + return [] + } + var groups: [String] = [] + for idx in 1.. String { + let basePadding: String + let fontSize: String + let lineHeight: String + let maxWidth: String + switch template { + case "docs": + basePadding = "22px 30px" + fontSize = "15px" + lineHeight = "1.7" + maxWidth = "900px" + case "article": + basePadding = "32px 48px" + fontSize = "17px" + lineHeight = "1.8" + maxWidth = "760px" + case "compact", "dense-compact": + basePadding = "14px 16px" + fontSize = "13px" + lineHeight = "1.5" + maxWidth = "none" + default: + basePadding = "18px 22px" + fontSize = "14px" + lineHeight = "1.6" + maxWidth = "860px" + } + + let textColor = preferDarkMode ? "#E5E7EB" : "#111827" + let linkColor = preferDarkMode ? "#7FB0FF" : "#2F7CF6" + let previewBackground = preferDarkMode && template == "night-contrast" + ? "linear-gradient(180deg, #020617 0%, #050816 100%)" + : "transparent" + + return """ + :root { + color-scheme: light dark; + --md-text-color: \(textColor); + --md-link-color: \(linkColor); + } + html, body { + margin: 0; + padding: 0; + background: \(previewBackground); + color: var(--md-text-color); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + font-size: \(fontSize); + line-height: \(lineHeight); + } + .content { + max-width: \(maxWidth); + padding: \(basePadding); + margin: 0 auto; + } + .preview-warning { + margin: 0.5em 0 0.8em; + padding: 0.75em 0.9em; + border-radius: 9px; + border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); + } + .preview-warning p { + margin: 0; + } + .preview-warning-meta { + margin-top: 0.4em !important; + font-size: 0.92em; + opacity: 0.9; + } + h1, h2, h3, h4, h5, h6 { + line-height: 1.25; + margin: 1.1em 0 0.55em; + font-weight: 700; + } + h1 { font-size: 1.85em; border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding-bottom: 0.25em; } + h2 { font-size: 1.45em; border-bottom: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding-bottom: 0.2em; } + h3 { font-size: 1.2em; } + p, ul, ol, blockquote, table, pre { margin: 0.65em 0; } + ul, ol { padding-left: 1.3em; } + li { margin: 0.2em 0; } + blockquote { + margin-left: 0; + padding: 0.45em 0.9em; + border-left: 3px solid color-mix(in srgb, currentColor 30%, transparent); + background: color-mix(in srgb, currentColor 6%, transparent); + border-radius: 6px; + } + code { + font-family: "SF Mono", "Menlo", "Monaco", monospace; + font-size: 0.9em; + padding: 0.12em 0.35em; + border-radius: 5px; + background: color-mix(in srgb, currentColor 10%, transparent); + } + pre { + overflow-x: auto; + padding: 0.8em 0.95em; + border-radius: 9px; + background: color-mix(in srgb, currentColor 8%, transparent); + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + line-height: 1.35; + white-space: pre; + } + pre code { + display: block; + padding: 0; + background: transparent; + border-radius: 0; + font-size: 0.88em; + line-height: 1.35; + white-space: pre; + } + table { + border-collapse: collapse; + width: 100%; + border: 1px solid color-mix(in srgb, currentColor 16%, transparent); + border-radius: 8px; + overflow: hidden; + } + th, td { + text-align: left; + padding: 0.45em 0.55em; + border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); + } + th { + background: color-mix(in srgb, currentColor 7%, transparent); + font-weight: 600; + } + a { + color: var(--md-link-color); + text-decoration: none; + border-bottom: 1px solid color-mix(in srgb, var(--md-link-color) 45%, transparent); + } + img { + max-width: 100%; + height: auto; + border-radius: 8px; + } + hr { + border: 0; + border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); + margin: 1.1em 0; + } + body.pdf-export { + background: #ffffff !important; + color: #111827 !important; + } + body.pdf-export .content { + background: #ffffff !important; + border: none !important; + box-shadow: none !important; + } + body.pdf-export.pdf-one-page .content { + max-width: none !important; + width: auto !important; + } + body.pdf-export, body.pdf-export * { + opacity: 1 !important; + text-shadow: none !important; + -webkit-text-fill-color: #111827 !important; + } + body.pdf-export a { + color: #1d4ed8 !important; + border-bottom-color: color-mix(in srgb, #1d4ed8 45%, transparent) !important; + -webkit-text-fill-color: #1d4ed8 !important; + } + body.pdf-export code, + body.pdf-export pre, + body.pdf-export pre code { + color: #111827 !important; + background: #f3f4f6 !important; + border-color: #d1d5db !important; + -webkit-text-fill-color: #111827 !important; + } + @media print { + :root { + color-scheme: light; + --md-text-color: #111827; + --md-link-color: #1d4ed8; + } + @page { + size: A4; + margin: 0; + } + html, body { + height: auto !important; + overflow: visible !important; + background: #ffffff !important; + color: var(--md-text-color) !important; + } + body * { + color: inherit !important; + text-shadow: none !important; + } + a { + color: var(--md-link-color) !important; + } + code, pre { + color: #111827 !important; + } + .content { + max-width: none !important; + margin: 0 !important; + padding: 0 !important; + } + h1, h2, h3 { + break-after: avoid-page; + } + blockquote, figure { + break-inside: avoid; + } + } + """ + } + + func escapedHTML(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +} diff --git a/Neon Vision Editor/UI/ContentView+StartupOverlay.swift b/Neon Vision Editor/UI/ContentView+StartupOverlay.swift new file mode 100644 index 0000000..22ae4c9 --- /dev/null +++ b/Neon Vision Editor/UI/ContentView+StartupOverlay.swift @@ -0,0 +1,159 @@ +import SwiftUI + +extension ContentView { + var startupRecentFiles: [RecentFilesStore.Item] { + _ = recentFilesRefreshToken + return RecentFilesStore.items(limit: 5) + } + + var shouldShowStartupRecentFilesCard: Bool { + guard !brainDumpLayoutEnabled else { return false } + guard viewModel.tabs.count == 1 else { return false } + guard let tab = viewModel.selectedTab else { return false } + guard !tab.isLoadingContent else { return false } + guard tab.fileURL == nil else { return false } + guard tab.content.isEmpty else { return false } + return !startupRecentFiles.isEmpty + } + + var shouldShowSafeModeStartupCard: Bool { + guard startupBehavior == .safeMode else { return false } + guard !brainDumpLayoutEnabled else { return false } + guard viewModel.tabs.count == 1 else { return false } + guard let tab = viewModel.selectedTab else { return false } + guard !tab.isLoadingContent else { return false } + return tab.fileURL == nil + } + + var shouldShowStartupOverlay: Bool { + shouldShowSafeModeStartupCard || shouldShowStartupRecentFilesCard + } + + @ViewBuilder + var startupOverlay: some View { + VStack(alignment: .leading, spacing: 16) { + if shouldShowSafeModeStartupCard { + safeModeStartupCard + } + if shouldShowStartupRecentFilesCard { + startupRecentFilesCard + } + } + .padding(24) + } + + var safeModeStartupCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Safe Mode", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(Color.orange) + + Text(safeModeMessage ?? "Safe Mode is active for this launch.") + .font(.subheadline) + .foregroundStyle(.primary) + + Text("Neon Vision Editor started with a blank document and skipped session restore plus startup diagnostics so you can recover safely.") + .font(.footnote) + .foregroundStyle(.secondary) + + if safeModeRecoveryPreparedForNextLaunch { + Text("Normal startup will be used again on the next launch.") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.green) + } + + HStack(spacing: 12) { + Button("Open File…") { + openFileFromToolbar() + } + .font(.subheadline.weight(.semibold)) + + Button("Normal Next Launch") { + RuntimeReliabilityMonitor.shared.clearSafeModeRecoveryState() + safeModeRecoveryPreparedForNextLaunch = true + } + .font(.subheadline.weight(.semibold)) + +#if os(macOS) + Button("Settings…") { + openSettings(tab: "general") + } + .font(.subheadline.weight(.semibold)) +#endif + } + } + .padding(20) + .frame(maxWidth: 560, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color.orange.opacity(0.35), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6) + .accessibilityElement(children: .contain) + .accessibilityLabel("Safe Mode startup") + } + + var startupRecentFilesCard: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Recent Files") + .font(.headline) + + ForEach(startupRecentFiles) { item in + HStack(spacing: 12) { + Button { + _ = viewModel.openFile(url: item.url) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .lineLimit(1) + Text(item.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + Button { + RecentFilesStore.togglePinned(item.url) + } label: { + Image(systemName: item.isPinned ? "star.fill" : "star") + .foregroundStyle(item.isPinned ? Color.yellow : .secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(item.isPinned ? "Unpin recent file" : "Pin recent file") + .accessibilityHint("Keeps this file near the top of recent files") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.thinMaterial) + ) + } + + Button("Open File…") { + openFileFromToolbar() + } + .font(.subheadline.weight(.semibold)) + } + .padding(20) + .frame(maxWidth: 520) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6) + .accessibilityElement(children: .contain) + .accessibilityLabel("Recent files") + } +} diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 90679e4..1296252 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -1108,6 +1108,17 @@ extension ContentView { Button("Docs") { markdownPreviewTemplateRaw = "docs" } Button("Article") { markdownPreviewTemplateRaw = "article" } Button("Compact") { markdownPreviewTemplateRaw = "compact" } + Divider() + Button("GitHub Docs") { markdownPreviewTemplateRaw = "github-docs" } + Button("Academic Paper") { markdownPreviewTemplateRaw = "academic-paper" } + Button("Terminal Notes") { markdownPreviewTemplateRaw = "terminal-notes" } + Button("Magazine") { markdownPreviewTemplateRaw = "magazine" } + Button("Minimal Reader") { markdownPreviewTemplateRaw = "minimal-reader" } + Button("Presentation") { markdownPreviewTemplateRaw = "presentation" } + Button("Night Contrast") { markdownPreviewTemplateRaw = "night-contrast" } + Button("Warm Sepia") { markdownPreviewTemplateRaw = "warm-sepia" } + Button("Dense Compact") { markdownPreviewTemplateRaw = "dense-compact" } + Button("Developer Spec") { markdownPreviewTemplateRaw = "developer-spec" } } label: { Label("Preview Style", systemImage: "textformat.size") .foregroundStyle(macToolbarSymbolColor) diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 2da76a9..5acb084 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -8,6 +8,7 @@ import Foundation import Observation import UniformTypeIdentifiers import OSLog +import Dispatch #if os(macOS) import AppKit #elseif canImport(UIKit) @@ -102,6 +103,7 @@ struct ContentView: View { enum StartupBehavior { case standard case forceBlankDocument + case safeMode } enum ProjectNavigatorPlacement: String, CaseIterable, Identifiable { @@ -140,9 +142,11 @@ struct ContentView: View { } let startupBehavior: StartupBehavior + let safeModeMessage: String? - init(startupBehavior: StartupBehavior = .standard) { + init(startupBehavior: StartupBehavior = .standard, safeModeMessage: String? = nil) { self.startupBehavior = startupBehavior + self.safeModeMessage = safeModeMessage } private enum EditorPerformanceThresholds { @@ -219,8 +223,13 @@ struct ContentView: View { @AppStorage("SettingsConfirmCloseDirtyTab") var confirmCloseDirtyTab: Bool = true @AppStorage("SettingsConfirmClearEditor") var confirmClearEditor: Bool = true @AppStorage("SettingsActiveTab") var settingsActiveTab: String = "general" + @AppStorage("SettingsAppearance") var appearance: String = "system" @AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift" @AppStorage("SettingsThemeName") private var settingsThemeName: String = "Neon Glow" + @AppStorage("SettingsThemeBoldKeywords") private var settingsThemeBoldKeywords: Bool = false + @AppStorage("SettingsThemeItalicComments") private var settingsThemeItalicComments: Bool = false + @AppStorage("SettingsThemeUnderlineLinks") private var settingsThemeUnderlineLinks: Bool = false + @AppStorage("SettingsThemeBoldMarkdownHeadings") private var settingsThemeBoldMarkdownHeadings: Bool = false @State var lastProviderUsed: String = "Apple" @State private var highlightRefreshToken: Int = 0 @State var editorExternalMutationRevision: Int = 0 @@ -286,11 +295,21 @@ struct ContentView: View { @State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "") @State var iosExportFilename: String = "Untitled.txt" @State var iosExportTabID: UUID? = nil + @State var showMarkdownPDFExporter: Bool = false + @State var markdownPDFExportDocument: PDFExportDocument = PDFExportDocument() + @State var markdownPDFExportFilename: String = "Markdown-Preview.pdf" + @State var markdownPDFExportErrorMessage: String? @State var showQuickSwitcher: Bool = false @State var quickSwitcherQuery: String = "" @State var quickSwitcherProjectFileURLs: [URL] = [] + @State var indexedProjectFileURLs: [URL] = [] + @State var isProjectFileIndexing: Bool = false + @State var projectFileIndexRefreshGeneration: Int = 0 + @State var projectFileIndexTask: Task? = nil + @State var projectFolderMonitorSource: DispatchSourceFileSystemObject? = nil + @State var pendingProjectFolderRefreshWorkItem: DispatchWorkItem? = nil @State private var quickSwitcherRecentItemIDs: [String] = [] - @State private var recentFilesRefreshToken: UUID = UUID() + @State var recentFilesRefreshToken: UUID = UUID() @State private var currentSelectionSnapshotText: String = "" @State private var codeSnapshotPayload: CodeSnapshotPayload? @State var showFindInFiles: Bool = false @@ -304,6 +323,7 @@ struct ContentView: View { @State private var wordCountTask: Task? @State var vimModeEnabled: Bool = UserDefaults.standard.bool(forKey: "EditorVimModeEnabled") @State var vimInsertMode: Bool = true + @State var safeModeRecoveryPreparedForNextLaunch: Bool = false @State var droppedFileLoadInProgress: Bool = false @State var droppedFileProgressDeterminate: Bool = true @State var droppedFileLoadProgress: Double = 0 @@ -345,6 +365,7 @@ struct ContentView: View { #elseif os(iOS) @AppStorage("MarkdownPreviewTemplateIOS") var markdownPreviewTemplateRaw: String = "default" #endif + @AppStorage("MarkdownPreviewPDFExportMode") var markdownPDFExportModeRaw: String = "paginated-fit" @State private var showLanguageSetupPrompt: Bool = false @State private var languagePromptSelection: String = "plain" @State private var languagePromptInsertTemplate: Bool = false @@ -418,9 +439,17 @@ struct ContentView: View { var opacity: Double { switch self { - case .subtle: return 0.98 - case .balanced: return 0.93 - case .vibrant: return 0.90 + case .subtle: return 0.84 + case .balanced: return 0.76 + case .vibrant: return 0.68 + } + } + + var toolbarOpacity: Double { + switch self { + case .subtle: return 0.72 + case .balanced: return 0.64 + case .vibrant: return 0.56 } } } @@ -442,7 +471,7 @@ struct ContentView: View { private var macToolbarBackgroundStyle: AnyShapeStyle { if enableTranslucentWindow { - return AnyShapeStyle(macTranslucencyMode.material.opacity(0.8)) + return AnyShapeStyle(macTranslucencyMode.material.opacity(macTranslucencyMode.toolbarOpacity)) } return AnyShapeStyle(Color(nsColor: .textBackgroundColor)) } @@ -1793,6 +1822,11 @@ struct ContentView: View { UserDefaults.standard.set(vimModeEnabled, forKey: "EditorVimModeEnabled") UserDefaults.standard.set(vimModeEnabled, forKey: "EditorVimInterceptionEnabled") vimInsertMode = !vimModeEnabled + NotificationCenter.default.post( + name: .vimModeStateDidChange, + object: nil, + userInfo: ["insertMode": vimInsertMode] + ) } .onReceive(NotificationCenter.default.publisher(for: .toggleSidebarRequested)) { notif in guard matchesCurrentWindow(notif) else { return } @@ -2024,6 +2058,17 @@ struct ContentView: View { } message: { Text(whitespaceInspectorMessage ?? "") } + .alert( + "PDF Export Failed", + isPresented: Binding( + get: { markdownPDFExportErrorMessage != nil }, + set: { if !$0 { markdownPDFExportErrorMessage = nil } } + ) + ) { + Button("OK", role: .cancel) { } + } message: { + Text(markdownPDFExportErrorMessage ?? "") + } .navigationTitle("Neon Vision Editor") #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -2044,7 +2089,7 @@ struct ContentView: View { #endif } - private var lifecycleConfiguredRootView: some View { + private var rootViewWithStateObservers: some View { basePlatformRootView .onAppear { handleSettingsAndEditorDefaultsOnAppear() @@ -2075,6 +2120,9 @@ struct ContentView: View { .onChange(of: settingsThemeName) { _, _ in scheduleHighlightRefresh() } + .onChange(of: themeFormattingRefreshSignature) { _, _ in + scheduleHighlightRefresh() + } .onChange(of: highlightMatchingBrackets) { _, _ in scheduleHighlightRefresh() } @@ -2098,32 +2146,34 @@ struct ContentView: View { persistSessionIfReady() } .onChange(of: showSupportedProjectFilesOnly) { _, _ in - refreshProjectTree() + refreshProjectBrowserState() } .onChange(of: showMarkdownPreviewPane) { _, _ in persistSessionIfReady() } + } + + private var rootViewWithPlatformLifecycleObservers: some View { + rootViewWithStateObservers #if os(iOS) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - if let selectedID = viewModel.selectedTab?.id { - viewModel.refreshExternalConflictForTab(tabID: selectedID) - } + handleAppDidBecomeActive() } #elseif os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - if let selectedID = viewModel.selectedTab?.id { - viewModel.refreshExternalConflictForTab(tabID: selectedID) - } + handleAppDidBecomeActive() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in - persistSessionIfReady() - persistUnsavedDraftSnapshotIfNeeded() + handleAppWillResignActive() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in - persistSessionIfReady() - persistUnsavedDraftSnapshotIfNeeded() + handleAppWillResignActive() } #endif + } + + private var lifecycleConfiguredRootView: some View { + rootViewWithPlatformLifecycleObservers .onOpenURL { url in viewModel.openFile(url: url) } @@ -2316,7 +2366,7 @@ struct ContentView: View { onOpenFolder: { contentView.openProjectFolder() }, onToggleSupportedFilesOnly: { contentView.showSupportedProjectFilesOnly = $0 }, onOpenProjectFile: { contentView.openProjectFile(url: $0) }, - onRefreshTree: { contentView.refreshProjectTree() } + onRefreshTree: { contentView.refreshProjectBrowserState() } ) .navigationTitle("Project Structure") .toolbar { @@ -2553,6 +2603,16 @@ struct ContentView: View { ) { result in contentView.handleIOSExportResult(result) } + .fileExporter( + isPresented: contentView.$showMarkdownPDFExporter, + document: contentView.markdownPDFExportDocument, + contentType: .pdf, + defaultFilename: contentView.markdownPDFExportFilename + ) { result in + if case .failure(let error) = result { + contentView.markdownPDFExportErrorMessage = error.localizedDescription + } + } #endif } } @@ -2566,6 +2626,15 @@ struct ContentView: View { #endif } + private var themeFormattingRefreshSignature: Int { + var signature = 0 + if settingsThemeBoldKeywords { signature |= 1 << 0 } + if settingsThemeItalicComments { signature |= 1 << 1 } + if settingsThemeUnderlineLinks { signature |= 1 << 2 } + if settingsThemeBoldMarkdownHeadings { signature |= 1 << 3 } + return signature + } + private var effectiveIndentWidth: Int { projectOverrideIndentWidth ?? indentWidth } @@ -2577,15 +2646,22 @@ struct ContentView: View { private func applyStartupBehaviorIfNeeded() { guard !didApplyStartupBehavior else { return } - if startupBehavior == .forceBlankDocument { + if startupBehavior == .forceBlankDocument || startupBehavior == .safeMode { viewModel.resetTabsForSessionRestore() viewModel.addNewTab() projectRootFolderURL = nil clearProjectEditorOverrides() projectTreeNodes = [] quickSwitcherProjectFileURLs = [] + stopProjectFolderObservation() + indexedProjectFileURLs = [] + isProjectFileIndexing = false + projectFileIndexTask?.cancel() + projectFileIndexTask = nil didApplyStartupBehavior = true - persistSessionIfReady() + if startupBehavior != .safeMode { + persistSessionIfReady() + } return } @@ -2595,12 +2671,6 @@ struct ContentView: View { return } - if restoreUnsavedDraftSnapshotIfAvailable() { - didApplyStartupBehavior = true - persistSessionIfReady() - return - } - // If both startup toggles are enabled (legacy/default mismatch), prefer session restore. let shouldOpenBlankOnStartup = openWithBlankDocument && !reopenLastSession if shouldOpenBlankOnStartup { @@ -2610,11 +2680,18 @@ struct ContentView: View { clearProjectEditorOverrides() projectTreeNodes = [] quickSwitcherProjectFileURLs = [] + stopProjectFolderObservation() + indexedProjectFileURLs = [] + isProjectFileIndexing = false + projectFileIndexTask?.cancel() + projectFileIndexTask = nil didApplyStartupBehavior = true persistSessionIfReady() return } + var restoredSessionTabs = false + // Restore last session first when enabled. if reopenLastSession { if projectRootFolderURL == nil, let restoredProjectFolderURL = restoredLastSessionProjectFolderURL() { @@ -2634,12 +2711,20 @@ struct ContentView: View { _ = viewModel.focusTabIfOpen(for: selectedURL) } + restoredSessionTabs = !viewModel.tabs.isEmpty if viewModel.tabs.isEmpty { viewModel.addNewTab() } } } + // Restore unsaved drafts only as fallback when no file session tabs were restored. + if !restoredSessionTabs, restoreUnsavedDraftSnapshotIfAvailable() { + didApplyStartupBehavior = true + persistSessionIfReady() + return + } + #if os(iOS) // Keep mobile layout in a valid tab state so the file tab bar always has content. if viewModel.tabs.isEmpty { @@ -2653,8 +2738,9 @@ struct ContentView: View { persistSessionIfReady() } - private func persistSessionIfReady() { + func persistSessionIfReady() { guard didApplyStartupBehavior else { return } + guard startupBehavior != .safeMode else { return } let fileURLs = viewModel.tabs.compactMap { $0.fileURL } UserDefaults.standard.set(fileURLs.map(\.absoluteString), forKey: "LastSessionFileURLs") UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL") @@ -3173,7 +3259,7 @@ struct ContentView: View { return currentContent } - private var brainDumpLayoutEnabled: Bool { + var brainDumpLayoutEnabled: Bool { #if os(macOS) return viewModel.isBrainDumpMode #else @@ -3722,10 +3808,24 @@ struct ContentView: View { onOpenFolder: { openProjectFolder() }, onToggleSupportedFilesOnly: { showSupportedProjectFilesOnly = $0 }, onOpenProjectFile: { openProjectFile(url: $0) }, - onRefreshTree: { refreshProjectTree() } + onRefreshTree: { refreshProjectBrowserState() } ) } + private func handleAppDidBecomeActive() { + if let selectedID = viewModel.selectedTab?.id { + viewModel.refreshExternalConflictForTab(tabID: selectedID) + } + if projectRootFolderURL != nil { + refreshProjectBrowserState() + } + } + + private func handleAppWillResignActive() { + persistSessionIfReady() + persistUnsavedDraftSnapshotIfNeeded() + } + private var delimitedModeControl: some View { HStack(spacing: 10) { Picker("CSV/TSV View Mode", selection: $delimitedViewMode) { @@ -4046,8 +4146,8 @@ struct ContentView: View { ) .id(currentLanguage) .overlay { - if shouldShowStartupRecentFilesCard { - startupRecentFilesCard + if shouldShowStartupOverlay { + startupOverlay } } } @@ -4208,6 +4308,7 @@ struct ContentView: View { #if os(macOS) .onChange(of: macTranslucencyModeRaw) { _, _ in // Keep all chrome/background surfaces in lockstep when mode changes. + applyWindowTranslucency(enableTranslucentWindow) highlightRefreshToken &+= 1 } #endif @@ -4298,468 +4399,54 @@ struct ContentView: View { Text("Docs").tag("docs") Text("Article").tag("article") Text("Compact").tag("compact") + Text("GitHub Docs").tag("github-docs") + Text("Academic Paper").tag("academic-paper") + Text("Terminal Notes").tag("terminal-notes") + Text("Magazine").tag("magazine") + Text("Minimal Reader").tag("minimal-reader") + Text("Presentation").tag("presentation") + Text("Night Contrast").tag("night-contrast") + Text("Warm Sepia").tag("warm-sepia") + Text("Dense Compact").tag("dense-compact") + Text("Developer Spec").tag("developer-spec") } .labelsHidden() .pickerStyle(.menu) - .frame(width: 120) + .frame(minWidth: 120, idealWidth: 190, maxWidth: 220) + Picker("PDF Mode", selection: $markdownPDFExportModeRaw) { + Text("Paginated Fit").tag(MarkdownPDFExportMode.paginatedFit.rawValue) + Text("One Page Fit").tag(MarkdownPDFExportMode.onePageFit.rawValue) + } + .labelsHidden() + .pickerStyle(.menu) + .frame(minWidth: 128, idealWidth: 160, maxWidth: 180) + Button { + exportMarkdownPreviewPDF() + } label: { + Label("Export PDF", systemImage: "doc.badge.arrow.down") + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .buttonStyle(.borderedProminent) + .tint(NeonUIStyle.accentBlue) + .controlSize(.regular) + .layoutPriority(1) + .accessibilityLabel("Export Markdown preview as PDF") } .padding(.horizontal, 12) .padding(.vertical, 10) .background(editorSurfaceBackgroundStyle) - MarkdownPreviewWebView(html: markdownPreviewHTML(from: currentContent)) + MarkdownPreviewWebView( + html: markdownPreviewHTML( + from: currentContent, + preferDarkMode: markdownPreviewPreferDarkMode + ) + ) .accessibilityLabel("Markdown Preview Content") } .background(editorSurfaceBackgroundStyle) } - - private var markdownPreviewTemplate: String { - switch markdownPreviewTemplateRaw { - case "docs", "article", "compact": - return markdownPreviewTemplateRaw - default: - return "default" - } - } - - private var markdownPreviewRenderByteLimit: Int { 180_000 } - private var markdownPreviewFallbackCharacterLimit: Int { 120_000 } - - private func markdownPreviewHTML(from markdownText: String) -> String { - let bodyHTML = markdownPreviewBodyHTML(from: markdownText) - return """ - - - - - - - - -
- \(bodyHTML) -
- - - """ - } - - private func markdownPreviewBodyHTML(from markdownText: String) -> String { - let byteCount = markdownText.lengthOfBytes(using: .utf8) - if byteCount > markdownPreviewRenderByteLimit { - return largeMarkdownFallbackHTML(from: markdownText, byteCount: byteCount) - } - return renderedMarkdownBodyHTML(from: markdownText) ?? "
\(escapedHTML(markdownText))
" - } - - private func largeMarkdownFallbackHTML(from markdownText: String, byteCount: Int) -> String { - let previewText = String(markdownText.prefix(markdownPreviewFallbackCharacterLimit)) - let truncated = previewText.count < markdownText.count - let statusSuffix = truncated ? " (truncated preview)" : "" - return """ -
-

Large Markdown file

-

Rendering full Markdown is skipped for stability (\(byteCount) bytes)\(statusSuffix).

-
-
\(escapedHTML(previewText))
- """ - } - - private func renderedMarkdownBodyHTML(from markdownText: String) -> String? { - let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines) - return html.isEmpty ? nil : html - } - - private func simpleMarkdownToHTML(_ markdown: String) -> String { - let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") - var result: [String] = [] - var paragraphLines: [String] = [] - var insideCodeFence = false - var codeFenceLanguage: String? - var insideUnorderedList = false - var insideOrderedList = false - var insideBlockquote = false - - func flushParagraph() { - guard !paragraphLines.isEmpty else { return } - let paragraph = paragraphLines.map { inlineMarkdownToHTML($0) }.joined(separator: "
") - result.append("

\(paragraph)

") - paragraphLines.removeAll(keepingCapacity: true) - } - - func closeLists() { - if insideUnorderedList { - result.append("") - insideUnorderedList = false - } - if insideOrderedList { - result.append("") - insideOrderedList = false - } - } - - func closeBlockquote() { - if insideBlockquote { - flushParagraph() - closeLists() - result.append("") - insideBlockquote = false - } - } - - func closeParagraphAndInlineContainers() { - flushParagraph() - closeLists() - } - - for rawLine in lines { - let line = rawLine - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed.hasPrefix("```") { - if insideCodeFence { - result.append("
") - insideCodeFence = false - codeFenceLanguage = nil - } else { - closeBlockquote() - closeParagraphAndInlineContainers() - insideCodeFence = true - let lang = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) - codeFenceLanguage = lang.isEmpty ? nil : lang - if let codeFenceLanguage { - result.append("
")
-                    } else {
-                        result.append("
")
-                    }
-                }
-                continue
-            }
-
-            if insideCodeFence {
-                result.append("\(escapedHTML(line))\n")
-                continue
-            }
-
-            if trimmed.isEmpty {
-                closeParagraphAndInlineContainers()
-                closeBlockquote()
-                continue
-            }
-
-            if let heading = markdownHeading(from: trimmed) {
-                closeBlockquote()
-                closeParagraphAndInlineContainers()
-                result.append("\(inlineMarkdownToHTML(heading.text))")
-                continue
-            }
-
-            if isMarkdownHorizontalRule(trimmed) {
-                closeBlockquote()
-                closeParagraphAndInlineContainers()
-                result.append("
") - continue - } - - var workingLine = trimmed - let isBlockquoteLine = workingLine.hasPrefix(">") - if isBlockquoteLine { - if !insideBlockquote { - closeParagraphAndInlineContainers() - result.append("
") - insideBlockquote = true - } - workingLine = workingLine.dropFirst().trimmingCharacters(in: .whitespaces) - } else { - closeBlockquote() - } - - if let unordered = markdownUnorderedListItem(from: workingLine) { - flushParagraph() - if insideOrderedList { - result.append("") - insideOrderedList = false - } - if !insideUnorderedList { - result.append("
    ") - insideUnorderedList = true - } - result.append("
  • \(inlineMarkdownToHTML(unordered))
  • ") - continue - } - - if let ordered = markdownOrderedListItem(from: workingLine) { - flushParagraph() - if insideUnorderedList { - result.append("
") - insideUnorderedList = false - } - if !insideOrderedList { - result.append("
    ") - insideOrderedList = true - } - result.append("
  1. \(inlineMarkdownToHTML(ordered))
  2. ") - continue - } - - closeLists() - paragraphLines.append(workingLine) - } - - closeBlockquote() - closeParagraphAndInlineContainers() - if insideCodeFence { - result.append("
") - } - return result.joined(separator: "\n") - } - - private func markdownHeading(from line: String) -> (level: Int, text: String)? { - let pattern = "^(#{1,6})\\s+(.+)$" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - let range = NSRange(line.startIndex..., in: line) - guard let match = regex.firstMatch(in: line, options: [], range: range), - let hashesRange = Range(match.range(at: 1), in: line), - let textRange = Range(match.range(at: 2), in: line) else { - return nil - } - return (line[hashesRange].count, String(line[textRange])) - } - - private func isMarkdownHorizontalRule(_ line: String) -> Bool { - let compact = line.replacingOccurrences(of: " ", with: "") - return compact == "***" || compact == "---" || compact == "___" - } - - private func markdownUnorderedListItem(from line: String) -> String? { - let pattern = "^[-*+]\\s+(.+)$" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - let range = NSRange(line.startIndex..., in: line) - guard let match = regex.firstMatch(in: line, options: [], range: range), - let textRange = Range(match.range(at: 1), in: line) else { - return nil - } - return String(line[textRange]) - } - - private func markdownOrderedListItem(from line: String) -> String? { - let pattern = "^\\d+[\\.)]\\s+(.+)$" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } - let range = NSRange(line.startIndex..., in: line) - guard let match = regex.firstMatch(in: line, options: [], range: range), - let textRange = Range(match.range(at: 1), in: line) else { - return nil - } - return String(line[textRange]) - } - - private func inlineMarkdownToHTML(_ text: String) -> String { - var html = escapedHTML(text) - var codeSpans: [String] = [] - - html = replacingRegex(in: html, pattern: "`([^`]+)`") { match in - let content = String(match.dropFirst().dropLast()) - let token = "__CODE_SPAN_\(codeSpans.count)__" - codeSpans.append("\(content)") - return token - } - - html = replacingRegex(in: html, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") { match in - let parts = captureGroups(in: match, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") - guard parts.count == 2 else { return match } - return "\"\(parts[0])\"/" - } - - html = replacingRegex(in: html, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") { match in - let parts = captureGroups(in: match, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") - guard parts.count == 2 else { return match } - return "\(parts[0])" - } - - html = replacingRegex(in: html, pattern: "\\*\\*([^*]+)\\*\\*") { "\(String($0.dropFirst(2).dropLast(2)))" } - html = replacingRegex(in: html, pattern: "__([^_]+)__") { "\(String($0.dropFirst(2).dropLast(2)))" } - html = replacingRegex(in: html, pattern: "\\*([^*]+)\\*") { "\(String($0.dropFirst().dropLast()))" } - html = replacingRegex(in: html, pattern: "_([^_]+)_") { "\(String($0.dropFirst().dropLast()))" } - - for (index, codeHTML) in codeSpans.enumerated() { - html = html.replacingOccurrences(of: "__CODE_SPAN_\(index)__", with: codeHTML) - } - return html - } - - private func replacingRegex(in text: String, pattern: String, transform: (String) -> String) -> String { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } - let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) - guard !matches.isEmpty else { return text } - - var output = text - for match in matches.reversed() { - guard let range = Range(match.range, in: output) else { continue } - let segment = String(output[range]) - output.replaceSubrange(range, with: transform(segment)) - } - return output - } - - private func captureGroups(in text: String, pattern: String) -> [String] { - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)) else { - return [] - } - var groups: [String] = [] - for idx in 1.. String { - let basePadding: String - let fontSize: String - let lineHeight: String - let maxWidth: String - switch template { - case "docs": - basePadding = "22px 30px" - fontSize = "15px" - lineHeight = "1.7" - maxWidth = "900px" - case "article": - basePadding = "32px 48px" - fontSize = "17px" - lineHeight = "1.8" - maxWidth = "760px" - case "compact": - basePadding = "14px 16px" - fontSize = "13px" - lineHeight = "1.5" - maxWidth = "none" - default: - basePadding = "18px 22px" - fontSize = "14px" - lineHeight = "1.6" - maxWidth = "860px" - } - - return """ - :root { color-scheme: light dark; } - html, body { - margin: 0; - padding: 0; - background: transparent; - font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; - font-size: \(fontSize); - line-height: \(lineHeight); - } - .content { - max-width: \(maxWidth); - padding: \(basePadding); - margin: 0 auto; - } - .preview-warning { - margin: 0.5em 0 0.8em; - padding: 0.75em 0.9em; - border-radius: 9px; - border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - } - .preview-warning p { - margin: 0; - } - .preview-warning-meta { - margin-top: 0.4em !important; - font-size: 0.92em; - opacity: 0.9; - } - h1, h2, h3, h4, h5, h6 { - line-height: 1.25; - margin: 1.1em 0 0.55em; - font-weight: 700; - } - h1 { font-size: 1.85em; border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding-bottom: 0.25em; } - h2 { font-size: 1.45em; border-bottom: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding-bottom: 0.2em; } - h3 { font-size: 1.2em; } - p, ul, ol, blockquote, table, pre { margin: 0.65em 0; } - ul, ol { padding-left: 1.3em; } - li { margin: 0.2em 0; } - blockquote { - margin-left: 0; - padding: 0.45em 0.9em; - border-left: 3px solid color-mix(in srgb, currentColor 30%, transparent); - background: color-mix(in srgb, currentColor 6%, transparent); - border-radius: 6px; - } - code { - font-family: "SF Mono", "Menlo", "Monaco", monospace; - font-size: 0.9em; - padding: 0.12em 0.35em; - border-radius: 5px; - background: color-mix(in srgb, currentColor 10%, transparent); - } - pre { - overflow-x: auto; - padding: 0.8em 0.95em; - border-radius: 9px; - background: color-mix(in srgb, currentColor 8%, transparent); - border: 1px solid color-mix(in srgb, currentColor 14%, transparent); - line-height: 1.35; - white-space: pre; - } - pre code { - display: block; - padding: 0; - background: transparent; - border-radius: 0; - font-size: 0.88em; - line-height: 1.35; - white-space: pre; - } - table { - border-collapse: collapse; - width: 100%; - border: 1px solid color-mix(in srgb, currentColor 16%, transparent); - border-radius: 8px; - overflow: hidden; - } - th, td { - text-align: left; - padding: 0.45em 0.55em; - border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); - } - th { - background: color-mix(in srgb, currentColor 7%, transparent); - font-weight: 600; - } - a { - color: #2f7cf6; - text-decoration: none; - border-bottom: 1px solid color-mix(in srgb, #2f7cf6 45%, transparent); - } - img { - max-width: 100%; - height: auto; - border-radius: 8px; - } - hr { - border: 0; - border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); - margin: 1.1em 0; - } - """ - } - - private func escapedHTML(_ text: String) -> String { - text - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "'", with: "'") - } #endif #if os(iOS) @@ -5074,7 +4761,11 @@ struct ContentView: View { ) } - for url in quickSwitcherProjectFileURLs { + let projectQuickSwitcherFileURLs = indexedProjectFileURLs.isEmpty + ? quickSwitcherProjectFileURLs + : indexedProjectFileURLs + + for url in projectQuickSwitcherFileURLs { let standardized = url.standardizedFileURL.path if fileURLSet.contains(standardized) { continue } if items.contains(where: { $0.id == "file:\(standardized)" }) { continue } @@ -5195,82 +4886,6 @@ struct ContentView: View { UserDefaults.standard.set(quickSwitcherRecentItemIDs, forKey: quickSwitcherRecentsDefaultsKey) } - private var startupRecentFiles: [RecentFilesStore.Item] { - _ = recentFilesRefreshToken - return RecentFilesStore.items(limit: 5) - } - - private var shouldShowStartupRecentFilesCard: Bool { - guard !brainDumpLayoutEnabled else { return false } - guard viewModel.tabs.count == 1 else { return false } - guard let tab = viewModel.selectedTab else { return false } - guard !tab.isLoadingContent else { return false } - guard tab.fileURL == nil else { return false } - guard tab.content.isEmpty else { return false } - return !startupRecentFiles.isEmpty - } - - private var startupRecentFilesCard: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Recent Files") - .font(.headline) - - ForEach(startupRecentFiles) { item in - HStack(spacing: 12) { - Button { - _ = viewModel.openFile(url: item.url) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .lineLimit(1) - Text(item.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - - Button { - RecentFilesStore.togglePinned(item.url) - } label: { - Image(systemName: item.isPinned ? "star.fill" : "star") - .foregroundStyle(item.isPinned ? Color.yellow : .secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(item.isPinned ? "Unpin recent file" : "Pin recent file") - .accessibilityHint("Keeps this file near the top of recent files") - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.thinMaterial) - ) - } - - Button("Open File…") { - openFileFromToolbar() - } - .font(.subheadline.weight(.semibold)) - } - .padding(20) - .frame(maxWidth: 520) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(.regularMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 6) - .padding(24) - .accessibilityElement(children: .contain) - .accessibilityLabel("Recent files") - } - private func quickSwitcherRecencyScore(for itemID: String) -> Int { guard let index = quickSwitcherRecentItemIDs.firstIndex(of: itemID) else { return 0 } return max(0, 120 - (index * 5)) @@ -5330,12 +4945,18 @@ struct ContentView: View { } findInFilesTask?.cancel() - findInFilesStatusMessage = "Searching…" + let candidateFiles = indexedProjectFileURLs.isEmpty ? nil : indexedProjectFileURLs + if candidateFiles == nil, isProjectFileIndexing { + findInFilesStatusMessage = "Searching while project index updates…" + } else { + findInFilesStatusMessage = "Searching…" + } let caseSensitive = findInFilesCaseSensitive findInFilesTask = Task { let results = await ContentView.findInFiles( root: root, + candidateFiles: candidateFiles, query: query, caseSensitive: caseSensitive, maxResults: 500 diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index b96097f..20535a8 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -73,6 +73,29 @@ private func syntaxProfile(for language: String, text: NSString) -> SyntaxPatter return .full } +private enum SyntaxFontEmphasis { + case keyword + case comment +} + +#if os(macOS) +private func fontWithSymbolicTrait(_ font: NSFont, trait: NSFontDescriptor.SymbolicTraits) -> NSFont { + let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(trait)) + guard + let adjustedFont = NSFont(descriptor: descriptor, size: font.pointSize) else { + return font + } + return adjustedFont +} +#else +private func fontWithSymbolicTrait(_ font: UIFont, trait: UIFontDescriptor.SymbolicTraits) -> UIFont { + guard let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(trait)) else { + return font + } + return UIFont(descriptor: descriptor, size: font.pointSize) +} +#endif + private func supportsResponsiveLargeFileHighlight(language: String) -> Bool { isJSONLikeLanguage(language) && currentLargeFileSyntaxHighlightMode() == .minimal && @@ -2921,6 +2944,7 @@ struct CustomTextEditor: NSViewRepresentable { explicitRange: targetRange, immediate: immediate ) + let emphasisPatterns = syntaxEmphasisPatterns(for: language, profile: syntaxProfile) // Cancel any in-flight work pendingHighlight?.cancel() @@ -2929,6 +2953,7 @@ struct CustomTextEditor: NSViewRepresentable { let interval = syntaxHighlightSignposter.beginInterval("rehighlight_macos") // Compute matches off the main thread var coloredRanges: [(NSRange, Color)] = [] + var emphasizedRanges: [(NSRange, SyntaxFontEmphasis)] = [] if let fastRanges = fastSyntaxColorRanges( language: language, profile: syntaxProfile, @@ -2947,6 +2972,25 @@ struct CustomTextEditor: NSViewRepresentable { } } + if theme.boldKeywords { + for pattern in emphasisPatterns.keyword { + guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } + let matches = regex.matches(in: textSnapshot, range: applyRange) + for match in matches { + emphasizedRanges.append((match.range, .keyword)) + } + } + } + if theme.italicComments { + for pattern in emphasisPatterns.comment { + guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } + let matches = regex.matches(in: textSnapshot, range: applyRange) + for match in matches { + emphasizedRanges.append((match.range, .comment)) + } + } + } + DispatchQueue.main.async { [weak self] in guard let self = self, let tv = self.textView else { syntaxHighlightSignposter.endInterval("rehighlight_macos", interval) @@ -2979,11 +3023,26 @@ struct CustomTextEditor: NSViewRepresentable { tv.textStorage?.removeAttribute(.foregroundColor, range: applyRange) tv.textStorage?.removeAttribute(.backgroundColor, range: applyRange) tv.textStorage?.removeAttribute(.underlineStyle, range: applyRange) + tv.textStorage?.removeAttribute(.font, range: applyRange) tv.textStorage?.addAttribute(.foregroundColor, value: baseColor, range: applyRange) + let baseFont = self.parent.resolvedFont() + tv.textStorage?.addAttribute(.font, value: baseFont, range: applyRange) + let boldKeywordFont = fontWithSymbolicTrait(baseFont, trait: .bold) + let italicCommentFont = fontWithSymbolicTrait(baseFont, trait: .italic) // Apply colored ranges for (range, color) in coloredRanges { tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range) } + for (range, emphasis) in emphasizedRanges { + let font: NSFont + switch emphasis { + case .keyword: + font = boldKeywordFont + case .comment: + font = italicCommentFont + } + tv.textStorage?.addAttribute(.font, value: font, range: range) + } let nsTextMain = textSnapshot as NSString let selectedLocation = min(max(0, selected.location), max(0, fullRange.length)) @@ -3341,7 +3400,11 @@ struct CustomTextEditor: NSViewRepresentable { import UIKit final class EditorInputTextView: UITextView { + private let vimModeDefaultsKey = "EditorVimModeEnabled" + private let vimInterceptionDefaultsKey = "EditorVimInterceptionEnabled" private let bracketTokens: [String] = ["(", ")", "{", "}", "[", "]", "<", ">", "'", "\"", "`", "()", "{}", "[]", "\"\"", "''"] + private var isVimInsertMode: Bool = true + private var pendingDeleteCurrentLineCommand = false private lazy var bracketAccessoryView: UIView = { let host = UIView() @@ -3408,11 +3471,29 @@ final class EditorInputTextView: UITextView { override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) inputAccessoryView = bracketAccessoryView + syncVimModeFromDefaults() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleVimModeStateDidChange(_:)), + name: .vimModeStateDidChange, + object: nil + ) } required init?(coder: NSCoder) { super.init(coder: coder) inputAccessoryView = bracketAccessoryView + syncVimModeFromDefaults() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleVimModeStateDidChange(_:)), + name: .vimModeStateDidChange, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) } func setBracketAccessoryVisible(_ visible: Bool) { @@ -3431,6 +3512,35 @@ final class EditorInputTextView: UITextView { return super.canPerformAction(action, withSender: sender) } + override var keyCommands: [UIKeyCommand]? { + guard UIDevice.current.userInterfaceIdiom == .pad else { return super.keyCommands } + guard UserDefaults.standard.bool(forKey: vimInterceptionDefaultsKey), + UserDefaults.standard.bool(forKey: vimModeDefaultsKey) else { + return super.keyCommands + } + + var commands = super.keyCommands ?? [] + if isVimInsertMode { + commands.append(vimCommand(input: UIKeyCommand.inputEscape, action: #selector(vimEscapeToNormalMode), title: "Vim: Normal Mode")) + return commands + } + + commands.append(vimCommand(input: "h", action: #selector(vimMoveLeft), title: "Vim: Move Left")) + commands.append(vimCommand(input: "j", action: #selector(vimMoveDown), title: "Vim: Move Down")) + commands.append(vimCommand(input: "k", action: #selector(vimMoveUp), title: "Vim: Move Up")) + commands.append(vimCommand(input: "l", action: #selector(vimMoveRight), title: "Vim: Move Right")) + commands.append(vimCommand(input: "w", action: #selector(vimMoveWordForward), title: "Vim: Next Word")) + commands.append(vimCommand(input: "b", action: #selector(vimMoveWordBackward), title: "Vim: Previous Word")) + commands.append(vimCommand(input: "0", action: #selector(vimMoveToLineStart), title: "Vim: Line Start")) + commands.append(vimCommand(input: UIKeyCommand.inputEscape, action: #selector(vimEscapeToNormalMode), title: "Vim: Stay in Normal Mode")) + commands.append(vimCommand(input: "x", action: #selector(vimDeleteForward), title: "Vim: Delete Character")) + commands.append(vimCommand(input: "i", action: #selector(vimEnterInsertMode), title: "Vim: Insert Mode")) + commands.append(vimCommand(input: "a", action: #selector(vimAppendInsertMode), title: "Vim: Append Mode")) + commands.append(vimCommand(input: "d", action: #selector(vimDeleteLineStep), title: "Vim: Delete Line")) + commands.append(vimCommand(input: "$", modifiers: [.shift], action: #selector(vimMoveToLineEnd), title: "Vim: Line End")) + return commands + } + override func paste(_ sender: Any?) { // Force plain-text fallback so simulator/device paste remains reliable // even when the pasteboard advertises rich content first. @@ -3483,6 +3593,270 @@ final class EditorInputTextView: UITextView { default: return nil } } + + private func vimCommand( + input: String, + modifiers: UIKeyModifierFlags = [], + action: Selector, + title: String + ) -> UIKeyCommand { + let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action) + command.discoverabilityTitle = title + if #available(iOS 15.0, *) { + command.wantsPriorityOverSystemBehavior = true + } + return command + } + + private func syncVimModeFromDefaults() { + let enabled = UserDefaults.standard.bool(forKey: vimModeDefaultsKey) + let interceptionEnabled = UserDefaults.standard.bool(forKey: vimInterceptionDefaultsKey) + if !enabled || !interceptionEnabled { + pendingDeleteCurrentLineCommand = false + if !isVimInsertMode { + setVimInsertMode(true) + } + } + } + + private func setVimInsertMode(_ isInsertMode: Bool) { + isVimInsertMode = isInsertMode + pendingDeleteCurrentLineCommand = false + NotificationCenter.default.post( + name: .vimModeStateDidChange, + object: nil, + userInfo: ["insertMode": isInsertMode] + ) + } + + @objc private func handleVimModeStateDidChange(_ notification: Notification) { + if let insertMode = notification.userInfo?["insertMode"] as? Bool { + isVimInsertMode = insertMode + pendingDeleteCurrentLineCommand = false + } else { + syncVimModeFromDefaults() + } + } + + private func vimText() -> NSString { + (text ?? "") as NSString + } + + private func collapseSelection() -> NSRange { + let current = selectedRange + if current.length == 0 { + return current + } + let collapsed = NSRange(location: current.location, length: 0) + selectedRange = collapsed + delegate?.textViewDidChangeSelection?(self) + return collapsed + } + + private func moveCaret(to location: Int) { + let length = vimText().length + selectedRange = NSRange(location: max(0, min(location, length)), length: 0) + scrollRangeToVisible(selectedRange) + delegate?.textViewDidChangeSelection?(self) + } + + private func currentLineRange(for range: NSRange? = nil) -> NSRange { + let target = range ?? collapseSelection() + return vimText().lineRange(for: NSRange(location: target.location, length: 0)) + } + + private func currentColumn(in lineRange: NSRange, location: Int) -> Int { + max(0, location - lineRange.location) + } + + private func lineStartLocations() -> [Int] { + let nsText = vimText() + var starts: [Int] = [0] + var location = 0 + while location < nsText.length { + let lineRange = nsText.lineRange(for: NSRange(location: location, length: 0)) + let nextLocation = NSMaxRange(lineRange) + if nextLocation >= nsText.length { + break + } + starts.append(nextLocation) + location = nextLocation + } + return starts + } + + private func wordCharacterSetContains(_ scalar: UnicodeScalar) -> Bool { + CharacterSet.alphanumerics.contains(scalar) || scalar == "_" + } + + private func moveWordForwardLocation(from location: Int) -> Int { + let nsText = vimText() + let length = nsText.length + var index = min(max(0, location), length) + while index < length { + let codeUnit = nsText.character(at: index) + guard let scalar = UnicodeScalar(codeUnit) else { + index += 1 + continue + } + if wordCharacterSetContains(scalar) { + break + } + index += 1 + } + while index < length { + let codeUnit = nsText.character(at: index) + guard let scalar = UnicodeScalar(codeUnit) else { + index += 1 + continue + } + if !wordCharacterSetContains(scalar) { + break + } + index += 1 + } + while index < length { + let codeUnit = nsText.character(at: index) + guard let scalar = UnicodeScalar(codeUnit) else { + index += 1 + continue + } + if wordCharacterSetContains(scalar) { + break + } + index += 1 + } + return min(index, length) + } + + private func moveWordBackwardLocation(from location: Int) -> Int { + let nsText = vimText() + var index = max(0, min(location, nsText.length)) + if index > 0 { + index -= 1 + } + while index > 0 { + let codeUnit = nsText.character(at: index) + guard let scalar = UnicodeScalar(codeUnit) else { + index -= 1 + continue + } + if wordCharacterSetContains(scalar) { + break + } + index -= 1 + } + while index > 0 { + let previous = nsText.character(at: index - 1) + guard let scalar = UnicodeScalar(previous) else { + index -= 1 + continue + } + if !wordCharacterSetContains(scalar) { + break + } + index -= 1 + } + return index + } + + private func deleteText(in range: NSRange) { + guard range.length > 0 else { return } + textStorage.replaceCharacters(in: range, with: "") + selectedRange = NSRange(location: min(range.location, vimText().length), length: 0) + delegate?.textViewDidChange?(self) + delegate?.textViewDidChangeSelection?(self) + } + + @objc private func vimEscapeToNormalMode() { + if isVimInsertMode { + setVimInsertMode(false) + } + } + + @objc private func vimEnterInsertMode() { + setVimInsertMode(true) + } + + @objc private func vimAppendInsertMode() { + let current = collapseSelection() + if current.location < vimText().length { + moveCaret(to: current.location + 1) + } + setVimInsertMode(true) + } + + @objc private func vimMoveLeft() { + let current = collapseSelection() + moveCaret(to: current.location - 1) + } + + @objc private func vimMoveRight() { + let current = collapseSelection() + moveCaret(to: current.location + 1) + } + + @objc private func vimMoveUp() { + let current = collapseSelection() + let starts = lineStartLocations() + guard let lineIndex = starts.lastIndex(where: { $0 <= current.location }), lineIndex > 0 else { return } + let currentLine = currentLineRange(for: current) + let column = currentColumn(in: currentLine, location: current.location) + let previousStart = starts[lineIndex - 1] + let previousLine = vimText().lineRange(for: NSRange(location: previousStart, length: 0)) + let previousLineEnd = max(previousLine.location, previousLine.location + max(0, previousLine.length - 1)) + moveCaret(to: min(previousStart + column, previousLineEnd)) + } + + @objc private func vimMoveDown() { + let current = collapseSelection() + let starts = lineStartLocations() + guard let lineIndex = starts.lastIndex(where: { $0 <= current.location }), lineIndex + 1 < starts.count else { return } + let currentLine = currentLineRange(for: current) + let column = currentColumn(in: currentLine, location: current.location) + let nextStart = starts[lineIndex + 1] + let nextLine = vimText().lineRange(for: NSRange(location: nextStart, length: 0)) + let nextLineEnd = max(nextLine.location, nextLine.location + max(0, nextLine.length - 1)) + moveCaret(to: min(nextStart + column, nextLineEnd)) + } + + @objc private func vimMoveWordForward() { + moveCaret(to: moveWordForwardLocation(from: collapseSelection().location)) + } + + @objc private func vimMoveWordBackward() { + moveCaret(to: moveWordBackwardLocation(from: collapseSelection().location)) + } + + @objc private func vimMoveToLineStart() { + let lineRange = currentLineRange() + moveCaret(to: lineRange.location) + } + + @objc private func vimMoveToLineEnd() { + let lineRange = currentLineRange() + let lineEnd = max(lineRange.location, lineRange.location + max(0, lineRange.length - 1)) + moveCaret(to: lineEnd) + } + + @objc private func vimDeleteForward() { + let current = collapseSelection() + guard current.location < vimText().length else { return } + deleteText(in: NSRange(location: current.location, length: 1)) + } + + @objc private func vimDeleteLineStep() { + if pendingDeleteCurrentLineCommand { + pendingDeleteCurrentLineCommand = false + let lineRange = currentLineRange() + deleteText(in: lineRange) + return + } + pendingDeleteCurrentLineCommand = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + self?.pendingDeleteCurrentLineCommand = false + } + } } final class LineNumberGutterView: UIView { @@ -4322,7 +4696,9 @@ struct CustomTextEditor: UIViewRepresentable { ) let syntaxProfile = syntaxProfile(for: language, text: nsText) let patterns = getSyntaxPatterns(for: language, colors: colors, profile: syntaxProfile) + let emphasisPatterns = syntaxEmphasisPatterns(for: language, profile: syntaxProfile) var coloredRanges: [(NSRange, UIColor)] = [] + var emphasizedRanges: [(NSRange, SyntaxFontEmphasis)] = [] if let fastRanges = fastSyntaxColorRanges( language: language, @@ -4347,6 +4723,28 @@ struct CustomTextEditor: UIViewRepresentable { } } + if theme.boldKeywords { + for pattern in emphasisPatterns.keyword { + guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } + let matches = regex.matches(in: text, range: applyRange) + for match in matches { + guard isValidRange(match.range, utf16Length: fullRange.length) else { continue } + emphasizedRanges.append((match.range, .keyword)) + } + } + } + + if theme.italicComments { + for pattern in emphasisPatterns.comment { + guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } + let matches = regex.matches(in: text, range: applyRange) + for match in matches { + guard isValidRange(match.range, utf16Length: fullRange.length) else { continue } + emphasizedRanges.append((match.range, .comment)) + } + } + } + DispatchQueue.main.async { [weak self] in guard let self, let textView = self.textView else { return } guard generation == self.highlightGeneration else { return } @@ -4374,9 +4772,21 @@ struct CustomTextEditor: UIViewRepresentable { textView.textStorage.removeAttribute(.underlineStyle, range: applyRange) textView.textStorage.addAttribute(.foregroundColor, value: baseColor, range: applyRange) textView.textStorage.addAttribute(.font, value: baseFont, range: applyRange) + let boldKeywordFont = fontWithSymbolicTrait(baseFont, trait: .traitBold) + let italicCommentFont = fontWithSymbolicTrait(baseFont, trait: .traitItalic) for (range, color) in coloredRanges { textView.textStorage.addAttribute(.foregroundColor, value: color, range: range) } + for (range, emphasis) in emphasizedRanges { + let font: UIFont + switch emphasis { + case .keyword: + font = boldKeywordFont + case .comment: + font = italicCommentFont + } + textView.textStorage.addAttribute(.font, value: font, range: range) + } let suppressLargeFileExtras = self.parent.isLargeFileMode let wantsBracketTokens = self.parent.highlightMatchingBrackets && !suppressLargeFileExtras let wantsScopeBackground = self.parent.highlightScopeBackground && !suppressLargeFileExtras diff --git a/Neon Vision Editor/UI/MarkdownPreviewPDFRenderer.swift b/Neon Vision Editor/UI/MarkdownPreviewPDFRenderer.swift new file mode 100644 index 0000000..e317cff --- /dev/null +++ b/Neon Vision Editor/UI/MarkdownPreviewPDFRenderer.swift @@ -0,0 +1,601 @@ +import Foundation +#if os(macOS) +import AppKit +import CoreText +#elseif canImport(UIKit) +import UIKit +#endif +#if os(macOS) || os(iOS) +import WebKit +#endif + +final class MarkdownPreviewPDFRenderer: NSObject, WKNavigationDelegate { + enum ExportMode { + case paginatedFit + case onePageFit + } + + private var continuation: CheckedContinuation? + private var webView: WKWebView? + private var retainedSelf: MarkdownPreviewPDFRenderer? + private var sourceHTML: String = "" + private var exportMode: ExportMode = .paginatedFit + private var measuredBlockBottoms: [CGFloat] = [] + private static let exportMeasurementPadding: CGFloat = 28 + private static let exportBottomSafetyMargin: CGFloat = 1024 + private static let singlePagePadding: CGFloat = 28 + private static let a4PaperRect = CGRect(x: 0, y: 0, width: 595, height: 842) + + @MainActor + static func render(html: String, mode: ExportMode) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + let renderer = MarkdownPreviewPDFRenderer() + renderer.retainedSelf = renderer + renderer.continuation = continuation + renderer.exportMode = mode + renderer.sourceHTML = html + renderer.start(html: html) + } + } + + @MainActor + private func start(html: String) { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + let webView = WKWebView(frame: .zero, configuration: configuration) +#if os(macOS) + webView.setValue(false, forKey: "drawsBackground") +#else + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.contentInsetAdjustmentBehavior = .never +#endif + webView.navigationDelegate = self + let initialWidth: CGFloat + switch exportMode { + case .paginatedFit: + initialWidth = Self.a4PaperRect.width + case .onePageFit: + initialWidth = 1280 + } + webView.frame = CGRect(x: 0, y: 0, width: initialWidth, height: 1800) + self.webView = webView + webView.loadHTMLString(html, baseURL: nil) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + resetWebViewScrollPosition(webView) + let script = """ + (async () => { + window.scrollTo(0, 0); + const body = document.body; + const html = document.documentElement; + const root = document.querySelector('.content') || body; + const scrolling = document.scrollingElement || html; + const exportPadding = \(Int(Self.exportMeasurementPadding)); + const bottomSafetyMargin = \(Int(Self.exportBottomSafetyMargin)); + if (document.fonts && document.fonts.ready) { + try { await document.fonts.ready; } catch (_) {} + } + body.style.margin = '0'; + body.style.padding = `${exportPadding}px`; + body.style.overflow = 'visible'; + html.style.overflow = 'visible'; + body.style.height = 'auto'; + html.style.height = 'auto'; + await new Promise(resolve => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ); + const rootRect = root.getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(root); + const rangeRect = range.getBoundingClientRect(); + const blockBottoms = Array.from(root.children) + .map(node => Math.ceil(node.getBoundingClientRect().bottom - bodyRect.top)) + .filter(value => Number.isFinite(value) && value > 0); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let lastElement = root; + while (walker.nextNode()) { + lastElement = walker.currentNode; + } + const lastElementRect = lastElement.getBoundingClientRect(); + const measuredBottom = Math.max( + rootRect.bottom, + rangeRect.bottom, + lastElementRect.bottom + ); + const width = Math.max( + Math.ceil(body.scrollWidth), + Math.ceil(html.scrollWidth), + Math.ceil(scrolling.scrollWidth), + Math.ceil(root.scrollWidth), + Math.ceil(root.getBoundingClientRect().width) + exportPadding * 2, + \(Int(Self.a4PaperRect.width)) + ); + const height = Math.max( + Math.ceil(body.scrollHeight), + Math.ceil(html.scrollHeight), + Math.ceil(scrolling.scrollHeight), + Math.ceil(scrolling.offsetHeight), + Math.ceil(root.scrollHeight), + Math.ceil(root.getBoundingClientRect().height) + exportPadding * 2, + Math.ceil(measuredBottom - Math.min(bodyRect.top, rootRect.top)) + exportPadding * 2 + bottomSafetyMargin, + 900 + ); + return [width, height, blockBottoms]; + })(); + """ + webView.evaluateJavaScript(script) { [weak self] value, error in + guard let self else { return } + self.measuredBlockBottoms = self.blockBottoms(from: value) + let rect = self.bestEffortPDFRect(javaScriptValue: value, webView: webView, error: error) + Task { @MainActor [weak self] in + guard let self else { return } + do { + self.resetWebViewScrollPosition(webView) + let output: Data + switch self.exportMode { + case .onePageFit: + try await self.prepareWebViewForPDFCapture(webView, rect: rect) + let fullData = try await self.createPDFData(from: webView, rect: rect) + output = self.fitPDFDataOnSinglePageIfNeeded(from: fullData) + case .paginatedFit: + output = try await self.paginatedPDFData(from: webView, fullRect: rect) + } + self.finish(with: .success(output)) + } catch { + self.finish(with: .failure(error)) + } + } + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + finish(with: .failure(error)) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + finish(with: .failure(error)) + } + + private func pdfRect(from javaScriptValue: Any?) -> CGRect? { + guard let values = javaScriptValue as? [Any], values.count >= 2, + let widthNumber = values[0] as? NSNumber, + let heightNumber = values[1] as? NSNumber else { return nil } + let width = max(640.0, min(8192.0, widthNumber.doubleValue)) + let height = max(900.0, heightNumber.doubleValue) + return CGRect(x: 0, y: 0, width: width, height: height) + } + + private func blockBottoms(from javaScriptValue: Any?) -> [CGFloat] { + guard let values = javaScriptValue as? [Any], values.count >= 3 else { return [] } + guard let numbers = values[2] as? [NSNumber] else { return [] } + return numbers.map { CGFloat($0.doubleValue) }.filter { $0.isFinite && $0 > 0 }.sorted() + } + + private func bestEffortPDFRect(javaScriptValue: Any?, webView: WKWebView, error: Error?) -> CGRect { + let jsRect = pdfRect(from: javaScriptValue) + let contentSize: CGSize +#if os(macOS) + contentSize = webView.enclosingScrollView?.documentView?.frame.size ?? .zero +#else + contentSize = webView.scrollView.contentSize +#endif + let scrollRect = CGRect( + x: 0, + y: 0, + width: max(640.0, min(8192.0, contentSize.width)), + height: max(900.0, contentSize.height) + ) + let fallbackRect = CGRect(x: 0, y: 0, width: 1024, height: 3000) + if let jsRect { + let mergedRect = CGRect( + x: 0, + y: 0, + width: max(jsRect.width, scrollRect.width), + height: max(jsRect.height, scrollRect.height) + ) + if error == nil { + return mergedRect + } + return mergedRect + } + if scrollRect.height > 1200 { + return scrollRect + } + return fallbackRect + } + + @MainActor + private func resetWebViewScrollPosition(_ webView: WKWebView) { +#if os(macOS) + if let clipView = webView.enclosingScrollView?.contentView { + clipView.scroll(to: .zero) + webView.enclosingScrollView?.reflectScrolledClipView(clipView) + } +#else + webView.scrollView.setContentOffset(.zero, animated: false) +#endif + } + + private func finish(with result: Result) { + guard let continuation else { return } + self.continuation = nil + switch result { + case .success(let data): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + webView?.navigationDelegate = nil + webView = nil + retainedSelf = nil + } + + @MainActor + private func createPDFData(from webView: WKWebView, rect: CGRect) async throws -> Data { +#if os(macOS) + let captureRect = CGRect(origin: .zero, size: rect.size) + let data = webView.dataWithPDF(inside: captureRect) + if isUsablePDFData(data) { + return data + } +#endif + return try await withCheckedThrowingContinuation { continuation in + let config = WKPDFConfiguration() + config.rect = rect + webView.createPDF(configuration: config) { result in + switch result { + case .success(let data): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + @MainActor + private func prepareWebViewForPDFCapture(_ webView: WKWebView, rect: CGRect) async throws { + webView.frame = rect + resetWebViewScrollPosition(webView) +#if os(macOS) + webView.layoutSubtreeIfNeeded() +#else + webView.layoutIfNeeded() +#endif + try? await Task.sleep(nanoseconds: 50_000_000) +#if os(macOS) + webView.layoutSubtreeIfNeeded() +#else + webView.layoutIfNeeded() +#endif + } + + @MainActor + private func paginatedPDFData(from webView: WKWebView, fullRect: CGRect) async throws -> Data { + try await prepareWebViewForPDFCapture(webView, rect: fullRect) +#if os(macOS) + if let attributedPaginated = macPaginatedAttributedPDFData(fromHTML: sourceHTML), + isUsablePDFData(attributedPaginated) { + return attributedPaginated + } + if let nativePaginated = macPaginatedPDFData(from: webView, rect: fullRect), + isUsablePDFData(nativePaginated) { + return nativePaginated + } +#endif + let fullData = try await createPDFData(from: webView, rect: fullRect) + if let paginated = paginatedA4PDFData( + fromSinglePagePDF: fullData, + preferredBlockBottoms: measuredBlockBottoms + ) { + return paginated + } + return fullData + } + +#if os(macOS) + private func macPaginatedAttributedPDFData(fromHTML html: String) -> Data? { + guard let htmlData = html.data(using: .utf8) else { return nil } + let readingOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + guard let attributed = try? NSMutableAttributedString( + data: htmlData, + options: readingOptions, + documentAttributes: nil + ) else { + return nil + } + + let fullRange = NSRange(location: 0, length: attributed.length) + attributed.addAttribute(.foregroundColor, value: NSColor.black, range: fullRange) + + let framesetter = CTFramesetterCreateWithAttributedString(attributed as CFAttributedString) + let outputData = NSMutableData() + guard + let consumer = CGDataConsumer(data: outputData as CFMutableData), + let context = CGContext(consumer: consumer, mediaBox: nil, nil) + else { + return nil + } + + let paperRect = Self.a4PaperRect + let printableRect = paperRect.insetBy(dx: 36, dy: 36) + let textFrameRect = printableRect.insetBy(dx: 0, dy: 14) + let pageInfo: [CFString: Any] = [kCGPDFContextMediaBox: paperRect] + var currentRange = CFRange(location: 0, length: 0) + + repeat { + context.beginPDFPage(pageInfo as CFDictionary) + context.saveGState() + context.setFillColor(NSColor.white.cgColor) + context.fill(paperRect) + context.textMatrix = .identity + + let path = CGMutablePath() + path.addRect(textFrameRect) + let frame = CTFramesetterCreateFrame(framesetter, currentRange, path, nil) + CTFrameDraw(frame, context) + context.restoreGState() + context.endPDFPage() + + let visibleRange = CTFrameGetVisibleStringRange(frame) + guard visibleRange.length > 0 else { + context.closePDF() + return nil + } + currentRange.location += visibleRange.length + } while currentRange.location < attributed.length + + context.closePDF() + let result = outputData as Data + return isUsablePDFData(result) ? result : nil + } + + @MainActor + private func macPaginatedPDFData(from webView: WKWebView, rect: CGRect) -> Data? { + let printInfo = NSPrintInfo.shared.copy() as? NSPrintInfo ?? NSPrintInfo() + printInfo.paperSize = NSSize(width: Self.a4PaperRect.width, height: Self.a4PaperRect.height) + printInfo.leftMargin = 36 + printInfo.rightMargin = 36 + printInfo.topMargin = 36 + printInfo.bottomMargin = 36 + printInfo.horizontalPagination = .automatic + printInfo.verticalPagination = .automatic + printInfo.isHorizontallyCentered = false + printInfo.isVerticallyCentered = false + + let outputData = NSMutableData() + let operation = NSPrintOperation.pdfOperation( + with: webView, + inside: CGRect(origin: .zero, size: rect.size), + to: outputData, + printInfo: printInfo + ) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + guard operation.run() else { return nil } + + let result = outputData as Data + return isUsablePDFData(result) ? result : nil + } +#endif + + private func isUsablePDFData(_ data: Data) -> Bool { + guard data.count > 2_000, + let provider = CGDataProvider(data: data as CFData), + let document = CGPDFDocument(provider), + document.numberOfPages > 0, + let firstPage = document.page(at: 1) + else { + return false + } + let rect = firstPage.getBoxRect(.cropBox).standardized + return rect.width > 0 && rect.height > 0 + } + + private func paginatedA4PDFData(fromSinglePagePDF data: Data, preferredBlockBottoms: [CGFloat]) -> Data? { + let normalizedData = stitchedSinglePagePDFDataIfNeeded(from: data) ?? data + guard + let provider = CGDataProvider(data: normalizedData as CFData), + let sourceDocument = CGPDFDocument(provider), + sourceDocument.numberOfPages >= 1, + let sourcePage = sourceDocument.page(at: 1) + else { + return nil + } + + let sourceRect = sourcePage.getBoxRect(.cropBox).standardized + guard sourceRect.width > 1, sourceRect.height > 1 else { + return nil + } + + let paperRect = Self.a4PaperRect + let printableRect = paperRect.insetBy(dx: 36, dy: 36) + let scale = max(0.001, min(printableRect.width / sourceRect.width, 1.0)) + let sourceSliceHeight = max(printableRect.height / scale, 1.0) + let pageRanges = paginatedSourceRanges( + sourceHeight: sourceRect.height, + preferredBlockBottoms: preferredBlockBottoms, + sliceHeight: sourceSliceHeight + ) + + let outputData = NSMutableData() + guard + let consumer = CGDataConsumer(data: outputData as CFMutableData), + let context = CGContext(consumer: consumer, mediaBox: nil, nil) + else { + return nil + } + + let mediaBoxInfo: [CFString: Any] = [kCGPDFContextMediaBox: paperRect] + for range in pageRanges { + let sliceBottomY = max(sourceRect.minY, min(sourceRect.maxY, sourceRect.maxY - range.bottom)) + let sliceHeight = max((range.bottom - range.top) * scale, 1.0) + let contentRect = CGRect( + x: printableRect.minX, + y: printableRect.maxY - min(sliceHeight, printableRect.height), + width: printableRect.width, + height: min(sliceHeight, printableRect.height) + ) + + context.beginPDFPage(mediaBoxInfo as CFDictionary) + context.saveGState() + context.clip(to: contentRect) + context.translateBy( + x: printableRect.minX - (sourceRect.minX * scale), + y: contentRect.minY - (sliceBottomY * scale) + ) + context.scaleBy(x: scale, y: scale) + context.drawPDFPage(sourcePage) + context.restoreGState() + context.endPDFPage() + } + context.closePDF() + + let result = outputData as Data + return isUsablePDFData(result) ? result : nil + } + + private func paginatedSourceRanges( + sourceHeight: CGFloat, + preferredBlockBottoms: [CGFloat], + sliceHeight: CGFloat + ) -> [(top: CGFloat, bottom: CGFloat)] { + let sortedBottoms = preferredBlockBottoms + .filter { $0 > 0 && $0 < sourceHeight } + .sorted() + + let minimumFill = max(sliceHeight * 0.55, 1.0) + var ranges: [(top: CGFloat, bottom: CGFloat)] = [] + var pageTop: CGFloat = 0 + + while pageTop < sourceHeight - 0.5 { + let tentativeBottom = min(pageTop + sliceHeight, sourceHeight) + let minimumBottom = min(sourceHeight, pageTop + minimumFill) + let preferredBottom = sortedBottoms.last(where: { $0 >= minimumBottom && $0 <= tentativeBottom }) ?? tentativeBottom + let pageBottom = max(preferredBottom, min(tentativeBottom, sourceHeight)) + + ranges.append((top: pageTop, bottom: pageBottom)) + + if pageBottom >= sourceHeight - 0.5 { + break + } + + pageTop = pageBottom + } + + if ranges.isEmpty { + return [(top: 0, bottom: sourceHeight)] + } + return ranges + } + + private func fitPDFDataOnSinglePageIfNeeded(from data: Data) -> Data { + let normalizedData = stitchedSinglePagePDFDataIfNeeded(from: data) ?? data + guard + let provider = CGDataProvider(data: normalizedData as CFData), + let sourceDocument = CGPDFDocument(provider), + sourceDocument.numberOfPages >= 1, + let sourcePage = sourceDocument.page(at: 1) + else { + return data + } + + let sourceRect = sourcePage.getBoxRect(.cropBox).standardized + guard sourceRect.width > 0, sourceRect.height > 0 else { + return data + } + + let outputRect = Self.a4PaperRect + let contentRect = outputRect.insetBy(dx: Self.singlePagePadding, dy: Self.singlePagePadding) + + let outputData = NSMutableData() + guard + let consumer = CGDataConsumer(data: outputData as CFMutableData), + let context = CGContext(consumer: consumer, mediaBox: nil, nil) + else { + return data + } + + let pageInfo: [CFString: Any] = [kCGPDFContextMediaBox: outputRect] + context.beginPDFPage(pageInfo as CFDictionary) + context.saveGState() + let transform = sourcePage.getDrawingTransform( + .cropBox, + rect: contentRect, + rotate: 0, + preserveAspectRatio: true + ) + context.concatenate(transform) + context.drawPDFPage(sourcePage) + context.restoreGState() + context.endPDFPage() + context.closePDF() + return outputData as Data + } + + private func stitchedSinglePagePDFDataIfNeeded(from data: Data) -> Data? { + guard + let provider = CGDataProvider(data: data as CFData), + let sourceDocument = CGPDFDocument(provider), + sourceDocument.numberOfPages > 1 + else { + return nil + } + + var pages: [(page: CGPDFPage, rect: CGRect)] = [] + var maxWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 + + for index in 1...sourceDocument.numberOfPages { + guard let page = sourceDocument.page(at: index) else { continue } + let rect = page.getBoxRect(.cropBox).standardized + guard rect.width > 0, rect.height > 0 else { continue } + pages.append((page, rect)) + maxWidth = max(maxWidth, rect.width) + totalHeight += rect.height + } + + guard !pages.isEmpty, maxWidth > 0, totalHeight > 0 else { + return nil + } + + let outputRect = CGRect(x: 0, y: 0, width: maxWidth, height: totalHeight) + let outputData = NSMutableData() + guard + let consumer = CGDataConsumer(data: outputData as CFMutableData), + let context = CGContext(consumer: consumer, mediaBox: nil, nil) + else { + return nil + } + + let pageInfo: [CFString: Any] = [kCGPDFContextMediaBox: outputRect] + context.beginPDFPage(pageInfo as CFDictionary) + + var currentTop = outputRect.maxY + for entry in pages { + currentTop -= entry.rect.height + context.saveGState() + context.translateBy( + x: -entry.rect.minX, + y: currentTop - entry.rect.minY + ) + context.drawPDFPage(entry.page) + context.restoreGState() + } + + context.endPDFPage() + context.closePDF() + + let result = outputData as Data + return isUsablePDFData(result) ? result : nil + } +} diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 3444a7f..9f9eac5 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -94,6 +94,10 @@ struct NeonSettingsView: View { @AppStorage("SettingsThemeCommentColor") private var themeCommentHex: String = "#7F8C98" @AppStorage("SettingsThemeTypeColor") private var themeTypeHex: String = "#32D269" @AppStorage("SettingsThemeBuiltinColor") private var themeBuiltinHex: String = "#EC7887" + @AppStorage("SettingsThemeBoldKeywords") private var themeBoldKeywords: Bool = false + @AppStorage("SettingsThemeItalicComments") private var themeItalicComments: Bool = false + @AppStorage("SettingsThemeUnderlineLinks") private var themeUnderlineLinks: Bool = false + @AppStorage("SettingsThemeBoldMarkdownHeadings") private var themeBoldMarkdownHeadings: Bool = false private var inputFieldBackground: Color { #if os(macOS) @@ -289,6 +293,10 @@ struct NeonSettingsView: View { ) #endif .preferredColorScheme(preferredColorSchemeOverride) +#if os(iOS) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) +#endif .onAppear { settingsActiveTab = Self.defaultSettingsTab if moreSectionTab.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1377,7 +1385,7 @@ struct NeonSettingsView: View { subtitle: "Pick a preset or customize token colors for your editing environment." ) HStack(alignment: .top, spacing: UI.space16) { - Group { + VStack(alignment: .leading, spacing: UI.space12) { #if os(macOS) let listView = List(themes, id: \.self, selection: $selectedTheme) { theme in HStack { @@ -1430,6 +1438,27 @@ struct NeonSettingsView: View { listView } #endif + VStack(alignment: .leading, spacing: UI.space10) { + Text("Formatting") + .font(Typography.sectionSubheadline) + .foregroundStyle(.secondary) + + LazyVGrid( + columns: [ + GridItem(.flexible(minimum: 140), spacing: UI.space12, alignment: .leading), + GridItem(.flexible(minimum: 140), spacing: UI.space12, alignment: .leading) + ], + alignment: .leading, + spacing: UI.space8 + ) { + Toggle("Bold keywords", isOn: $themeBoldKeywords) + Toggle("Italic comments", isOn: $themeItalicComments) + Toggle("Underline links", isOn: $themeUnderlineLinks) + Toggle("Bold Markdown headings", isOn: $themeBoldMarkdownHeadings) + } + } + .padding(UI.space12) + .background(settingsCardBackground(cornerRadius: UI.cardCorner)) } .padding(UI.space8) .background(settingsCardBackground(cornerRadius: UI.cardCorner)) @@ -1506,7 +1535,7 @@ struct NeonSettingsView: View { .padding(UI.space12) .background(settingsCardBackground(cornerRadius: UI.cardCorner)) - Text(isCustom ? "Custom theme applies immediately." : "Select Custom to edit colors.") + Text(isCustom ? "Custom theme applies immediately. Formatting applies to every active theme." : "Select Custom to edit colors. Formatting applies to every active theme.") .font(Typography.footnote) .foregroundStyle(.secondary) } @@ -1859,10 +1888,10 @@ struct NeonSettingsView: View { .font(Typography.footnote) .foregroundStyle(.orange) } - Text("Staged update: \(appUpdateManager.stagedUpdateVersionSummary)") + Text(localized("Staged update: %@", appUpdateManager.stagedUpdateVersionSummary)) .font(Typography.footnote) .foregroundStyle(.secondary) - Text("Last install attempt: \(appUpdateManager.lastInstallAttemptSummary)") + Text(localized("Last install attempt: %@", appUpdateManager.lastInstallAttemptSummary)) .font(Typography.footnote) .foregroundStyle(.secondary) @@ -2087,26 +2116,31 @@ struct NeonSettingsView: View { .padding(.top, UI.space6) .padding(.bottom, UI.space6) } else { - VStack(alignment: .center, spacing: UI.space8) { + HStack(alignment: .top, spacing: UI.space12) { Image(systemName: icon) - .font(.title2.weight(.semibold)) + .font(.title3.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 36, height: 36, alignment: .center) - Text(title) - .font(Typography.sectionTitle) - .multilineTextAlignment(.center) - Text(subtitle) - .font(Typography.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: UI.space6) { + Text(title) + .font(Typography.sectionTitle) + .multilineTextAlignment(.leading) + Text(subtitle) + .font(Typography.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + Spacer(minLength: 0) } - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, UI.mobileHeaderTopPadding) .padding(.bottom, UI.space6) } } #else - VStack(alignment: .center, spacing: UI.space8) { + HStack(alignment: .top, spacing: UI.space12) { ZStack { RoundedRectangle(cornerRadius: UI.macHeaderBadgeCorner, style: .continuous) .fill(Color.accentColor.opacity(0.10)) @@ -2119,15 +2153,20 @@ struct NeonSettingsView: View { .foregroundStyle(Color.accentColor) } .frame(width: UI.macHeaderIconSize, height: UI.macHeaderIconSize) - Text(title) - .font(Typography.sectionTitle) - .multilineTextAlignment(.center) - Text(subtitle) - .font(Typography.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: UI.space6) { + Text(title) + .font(Typography.sectionTitle) + .multilineTextAlignment(.leading) + Text(subtitle) + .font(Typography.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + Spacer(minLength: 0) } - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) .overlay(alignment: .bottom) { Divider().opacity(0.45) } @@ -2437,16 +2476,35 @@ struct NeonSettingsView: View { .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 3) { - Text("func computeTotal(_ values: [Int]) -> Int {") - .foregroundStyle(previewTheme.syntax.keyword) + themePreviewLine( + "# Release Notes", + color: previewTheme.syntax.meta, + weight: previewTheme.boldMarkdownHeadings ? .bold : .regular + ) + themePreviewLine( + "[docs](https://example.com/theme-guide)", + color: previewTheme.syntax.string, + underline: previewTheme.underlineLinks + ) + themePreviewLine( + "func computeTotal(_ values: [Int]) -> Int {", + color: previewTheme.syntax.keyword, + weight: previewTheme.boldKeywords ? .bold : .regular + ) Text(" let sum = values.reduce(0, +)") .foregroundStyle(previewTheme.text) - Text(" // tax adjustment") - .foregroundStyle(previewTheme.syntax.comment) + themePreviewLine( + " // tax adjustment", + color: previewTheme.syntax.comment, + italic: previewTheme.italicComments + ) Text(" return sum + 42") .foregroundStyle(previewTheme.syntax.number) - Text("}") - .foregroundStyle(previewTheme.syntax.keyword) + themePreviewLine( + "}", + color: previewTheme.syntax.keyword, + weight: previewTheme.boldKeywords ? .bold : .regular + ) } .font(.system(size: 12, weight: .regular, design: .monospaced)) .padding(UI.space10) @@ -2461,6 +2519,25 @@ struct NeonSettingsView: View { ) } } + + @ViewBuilder + private func themePreviewLine( + _ text: String, + color: Color, + weight: Font.Weight = .regular, + italic: Bool = false, + underline: Bool = false + ) -> some View { + let line = Text(text) + .foregroundStyle(color) + .font(.system(size: 12, weight: weight, design: .monospaced)) + let formattedLine = italic ? line.italic() : line + if underline { + formattedLine.underline() + } else { + formattedLine + } + } } #if os(macOS) diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index e8150cc..e060372 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -54,6 +54,24 @@ struct PlainTextDocument: FileDocument { } } +struct PDFExportDocument: FileDocument { + static var readableContentTypes: [UTType] { [.pdf] } + + var data: Data + + init(data: Data = Data()) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + self.data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + struct APISupportSettingsView: View { @Binding var grokAPIToken: String @Binding var openAIAPIToken: String @@ -359,12 +377,12 @@ struct WelcomeTourView: View { private let pages: [TourPage] = [ TourPage( title: "What’s New in This Release", - subtitle: "Major changes since v0.5.4:", + subtitle: "Major changes since v0.5.5:", bullets: [ - "Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches.", - "Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states.", - "Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions.", - "Added Share Shot (`Code Snapshot`) creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer." + "![v0.5.6 hero screenshot](docs/images/iphone-themes-light.png)", + "Safe Mode now recovers from repeated failed launches without getting stuck on every normal restart.", + "Large project folders now get a background file index that feeds `Quick Open` and `Find in Files` instead of relying only on live folder scans.", + "Theme formatting and Settings polish now apply immediately, with better localization and an iPad hardware-keyboard Vim MVP." ], iconName: "sparkles.rectangle.stack", colors: [Color(red: 0.40, green: 0.28, blue: 0.90), Color(red: 0.96, green: 0.46, blue: 0.55)], diff --git a/Neon Vision Editor/UI/ThemeSettings.swift b/Neon Vision Editor/UI/ThemeSettings.swift index e406af3..4aeb959 100644 --- a/Neon Vision Editor/UI/ThemeSettings.swift +++ b/Neon Vision Editor/UI/ThemeSettings.swift @@ -14,6 +14,10 @@ struct EditorTheme { let cursor: Color let selection: Color let syntax: SyntaxColors + let boldKeywords: Bool + let italicComments: Bool + let underlineLinks: Bool + let boldMarkdownHeadings: Bool } private struct ThemePalette { @@ -603,6 +607,10 @@ func currentEditorTheme(colorScheme: ColorScheme) -> EditorTheme { let defaults = UserDefaults.standard // Always respect the user's selected theme across iOS and macOS. let name = canonicalThemeName(defaults.string(forKey: "SettingsThemeName") ?? "Neon Glow") + let boldKeywords = defaults.bool(forKey: "SettingsThemeBoldKeywords") + let italicComments = defaults.bool(forKey: "SettingsThemeItalicComments") + let underlineLinks = defaults.bool(forKey: "SettingsThemeUnderlineLinks") + let boldMarkdownHeadings = defaults.bool(forKey: "SettingsThemeBoldMarkdownHeadings") let palette = paletteForThemeName(name, defaults: defaults) // Keep base editor text legible and consistent across all themes. // Neon Glow gets a slightly brighter dark-mode text tone. @@ -700,7 +708,11 @@ func currentEditorTheme(colorScheme: ColorScheme) -> EditorTheme { background: modeAdjustedEditorBackground(palette.background, colorScheme: colorScheme), cursor: palette.cursor, selection: palette.selection, - syntax: syntax + syntax: syntax, + boldKeywords: boldKeywords, + italicComments: italicComments, + underlineLinks: underlineLinks, + boldMarkdownHeadings: boldMarkdownHeadings ) } diff --git a/Neon Vision Editor/de.lproj/Localizable.strings b/Neon Vision Editor/de.lproj/Localizable.strings index bc9e1a6..3ae850a 100644 --- a/Neon Vision Editor/de.lproj/Localizable.strings +++ b/Neon Vision Editor/de.lproj/Localizable.strings @@ -165,6 +165,51 @@ "Base" = "Basis"; "Syntax" = "Syntax"; "Preview" = "Vorschau"; +"Current Setup" = "Aktuelle Konfiguration"; +"Snapshot updates immediately as settings change." = "Die Übersicht aktualisiert sich sofort bei jeder Einstellungsänderung."; +"Window behavior, startup defaults, and confirmation preferences." = "Fensterverhalten, Startvorgaben und Bestätigungseinstellungen."; +"Toolbar Symbols" = "Toolbar-Symbole"; +"Blue" = "Blau"; +"Dark Gray" = "Dunkelgrau"; +"Black" = "Schwarz"; +"Display, indentation, editing behavior, and completion sources." = "Anzeige, Einrückung, Bearbeitungsverhalten und Vervollständigungsquellen."; +"Section" = "Bereich"; +"Basics" = "Grundlagen"; +"Behavior" = "Verhalten"; +"Layout" = "Layout"; +"Left" = "Links"; +"Right" = "Rechts"; +"Editor Basics" = "Editor-Grundlagen"; +"Editor Behavior" = "Editor-Verhalten"; +"Performance" = "Leistung"; +"Balanced" = "Ausgewogen"; +"Large Files" = "Große Dateien"; +"Battery" = "Batterie"; +"Balanced keeps default behavior. Large Files and Battery enter performance mode earlier." = "Ausgewogen behält das Standardverhalten bei. Große Dateien und Batterie wechseln früher in den Performance-Modus."; +"Off" = "Aus"; +"Minimal" = "Minimal"; +"Standard" = "Standard"; +"Deferred" = "Verzögert"; +"Plain Text" = "Nur Text"; +"Minimal colors only visible JSON lines plus a small buffer using a strict work budget." = "Minimal färbt nur sichtbare JSON-Zeilen plus einen kleinen Puffer innerhalb eines strengen Arbeitsbudgets."; +"Deferred uses a lightweight loading step and chunked editor install. Plain Text keeps large-file sessions unstyled." = "Verzögert nutzt einen leichten Ladeschritt und eine gestückelte Editor-Initialisierung. Nur Text lässt große Dateien ungestylt."; +"Pick a preset or customize token colors for your editing environment." = "Wähle ein Preset oder passe die Token-Farben für deine Bearbeitungsumgebung an."; +"Formatting" = "Formatierung"; +"Bold keywords" = "Schlüsselwörter fett"; +"Italic comments" = "Kommentare kursiv"; +"Underline links" = "Links unterstreichen"; +"Bold Markdown headings" = "Markdown-Überschriften fett"; +"Custom theme applies immediately. Formatting applies to every active theme." = "Das benutzerdefinierte Design wird sofort angewendet. Die Formatierung gilt für jedes aktive Design."; +"Select Custom to edit colors. Formatting applies to every active theme." = "Wähle Benutzerdefiniert, um Farben zu bearbeiten. Die Formatierung gilt für jedes aktive Design."; +"AI setup, provider credentials, and support options." = "KI-Einrichtung, Anbieter-Zugangsdaten und Support-Optionen."; +"AI model, privacy disclosure, and provider credentials." = "KI-Modell, Datenschutzhinweise und Anbieter-Zugangsdaten."; +"Safe local diagnostics for update and file-open troubleshooting." = "Sichere lokale Diagnose für Update- und Dateiöffnungs-Fehlersuche."; +"Updater" = "Updater"; +"Recent updater log" = "Letztes Updater-Protokoll"; +"File Open Timing" = "Dateiöffnungs-Zeitwerte"; +"No recent file-open snapshots yet." = "Noch keine aktuellen Dateiöffnungs-Snapshots."; +"Staged update: %@" = "Bereitgestelltes Update: %@"; +"Last install attempt: %@" = "Letzter Installationsversuch: %@"; "Tip: Enable only one startup mode to keep app launch behavior predictable." = "Tipp: Aktiviere nur einen Startmodus, damit das Startverhalten der App vorhersehbar bleibt."; "When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "Wenn Zeilenumbruch aktiv ist, werden Scope-Guides/Scope-Bereich deaktiviert, um Layoutkonflikte zu vermeiden."; "Scope guides are intended for non-Swift languages. Swift favors matching-token highlight." = "Scope-Guides sind für Nicht-Swift-Sprachen gedacht. Für Swift wird die Hervorhebung passender Tokens bevorzugt."; diff --git a/Neon Vision Editor/en.lproj/Localizable.strings b/Neon Vision Editor/en.lproj/Localizable.strings index 9a691da..dc151b3 100644 --- a/Neon Vision Editor/en.lproj/Localizable.strings +++ b/Neon Vision Editor/en.lproj/Localizable.strings @@ -144,6 +144,51 @@ "Base" = "Base"; "Syntax" = "Syntax"; "Preview" = "Preview"; +"Current Setup" = "Current Setup"; +"Snapshot updates immediately as settings change." = "Snapshot updates immediately as settings change."; +"Window behavior, startup defaults, and confirmation preferences." = "Window behavior, startup defaults, and confirmation preferences."; +"Toolbar Symbols" = "Toolbar Symbols"; +"Blue" = "Blue"; +"Dark Gray" = "Dark Gray"; +"Black" = "Black"; +"Display, indentation, editing behavior, and completion sources." = "Display, indentation, editing behavior, and completion sources."; +"Section" = "Section"; +"Basics" = "Basics"; +"Behavior" = "Behavior"; +"Layout" = "Layout"; +"Left" = "Left"; +"Right" = "Right"; +"Editor Basics" = "Editor Basics"; +"Editor Behavior" = "Editor Behavior"; +"Performance" = "Performance"; +"Balanced" = "Balanced"; +"Large Files" = "Large Files"; +"Battery" = "Battery"; +"Balanced keeps default behavior. Large Files and Battery enter performance mode earlier." = "Balanced keeps default behavior. Large Files and Battery enter performance mode earlier."; +"Off" = "Off"; +"Minimal" = "Minimal"; +"Standard" = "Standard"; +"Deferred" = "Deferred"; +"Plain Text" = "Plain Text"; +"Minimal colors only visible JSON lines plus a small buffer using a strict work budget." = "Minimal colors only visible JSON lines plus a small buffer using a strict work budget."; +"Deferred uses a lightweight loading step and chunked editor install. Plain Text keeps large-file sessions unstyled." = "Deferred uses a lightweight loading step and chunked editor install. Plain Text keeps large-file sessions unstyled."; +"Pick a preset or customize token colors for your editing environment." = "Pick a preset or customize token colors for your editing environment."; +"Formatting" = "Formatting"; +"Bold keywords" = "Bold keywords"; +"Italic comments" = "Italic comments"; +"Underline links" = "Underline links"; +"Bold Markdown headings" = "Bold Markdown headings"; +"Custom theme applies immediately. Formatting applies to every active theme." = "Custom theme applies immediately. Formatting applies to every active theme."; +"Select Custom to edit colors. Formatting applies to every active theme." = "Select Custom to edit colors. Formatting applies to every active theme."; +"AI setup, provider credentials, and support options." = "AI setup, provider credentials, and support options."; +"AI model, privacy disclosure, and provider credentials." = "AI model, privacy disclosure, and provider credentials."; +"Safe local diagnostics for update and file-open troubleshooting." = "Safe local diagnostics for update and file-open troubleshooting."; +"Updater" = "Updater"; +"Recent updater log" = "Recent updater log"; +"File Open Timing" = "File Open Timing"; +"No recent file-open snapshots yet." = "No recent file-open snapshots yet."; +"Staged update: %@" = "Staged update: %@"; +"Last install attempt: %@" = "Last install attempt: %@"; "Tip: Enable only one startup mode to keep app launch behavior predictable." = "Tip: Enable only one startup mode to keep app launch behavior predictable."; "When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts."; "Scope guides are intended for non-Swift languages. Swift favors matching-token highlight." = "Scope guides are intended for non-Swift languages. Swift favors matching-token highlight."; diff --git a/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift b/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift index a4e4d43..88c5257 100644 --- a/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift +++ b/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift @@ -96,4 +96,58 @@ final class ReleaseRuntimePolicyTests: XCTestCase { ) ) } + + func testSafeModeStartupDecision() { + XCTAssertFalse( + ReleaseRuntimePolicy.shouldEnterSafeMode( + consecutiveFailedLaunches: 0, + requestedManually: false + ) + ) + XCTAssertFalse( + ReleaseRuntimePolicy.shouldEnterSafeMode( + consecutiveFailedLaunches: 1, + requestedManually: false + ) + ) + XCTAssertTrue( + ReleaseRuntimePolicy.shouldEnterSafeMode( + consecutiveFailedLaunches: ReleaseRuntimePolicy.safeModeFailureThreshold, + requestedManually: false + ) + ) + XCTAssertTrue( + ReleaseRuntimePolicy.shouldEnterSafeMode( + consecutiveFailedLaunches: 0, + requestedManually: true + ) + ) + } + + func testSafeModeStartupMessageExplainsTrigger() { + XCTAssertNil( + ReleaseRuntimePolicy.safeModeStartupMessage( + consecutiveFailedLaunches: 0, + requestedManually: false + ) + ) + + let automatic = ReleaseRuntimePolicy.safeModeStartupMessage( + consecutiveFailedLaunches: 2, + requestedManually: false + ) + XCTAssertEqual( + automatic, + "Safe Mode is active because the last 2 launch attempts did not finish cleanly. Session restore and startup diagnostics are paused." + ) + + let manual = ReleaseRuntimePolicy.safeModeStartupMessage( + consecutiveFailedLaunches: 0, + requestedManually: true + ) + XCTAssertEqual( + manual, + "Safe Mode is active for this launch. Session restore and startup diagnostics are paused." + ) + } } diff --git a/README.md b/README.md index ba555d5..7b5a7a1 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ > Status: **active release** -> Latest release: **v0.5.5** +> Latest release: **v0.5.6** > Platform target: **macOS 26 (Tahoe)** compatible with **macOS Sequoia** > Apple Silicon: tested / Intel: not tested -> Last updated (README): **2026-03-17** for release line **v0.5.5** +> Last updated (README): **2026-03-17** for release line **v0.5.6** ## Start Here @@ -167,7 +167,7 @@ - Security policy: [`SECURITY.md`](SECURITY.md) - Release checklists: [`release/`](release/) — TestFlight & App Store preflight docs -## What's New Since v0.5.4 +## What's New Since v0.5.5 - Added a dedicated large-file open mode with deferred first paint and chunked text installation, so ultra-large files no longer depend on a single blocking initial render. - Added per-session large-file modes directly in the editor UI: `Standard`, `Deferred`, and `Plain Text`. @@ -298,10 +298,10 @@ Platform-specific availability is tracked in the [Platform Matrix](#platform-mat ## NEW FEATURE Spotlight

- New Feature Release + New Feature Release

-**Featured in v0.5.5:** Code Snapshot creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer. +**Featured in v0.5.6:** Safe Mode startup recovery with repeated-failure detection, blank-document launch fallback, a dedicated startup explanation, and a `Normal Next Launch` recovery action. Create polished share images directly from your selected code. @@ -495,12 +495,12 @@ Most editor features are shared across macOS, iOS, and iPadOS. ## Roadmap (Near Term)

- Now - Next + Now + Next Later

-### Now (v0.5.3 - v0.5.5) +### Now (v0.5.4 - v0.5.6) - ![v0.5.3](https://img.shields.io/badge/v0.5.3-22C55E?style=flat-square) indexed project search and Open Recent favorites. Tracking: [Milestone 0.5.3](https://github.com/h3pdesign/Neon-Vision-Editor/milestone/4) · [#29](https://github.com/h3pdesign/Neon-Vision-Editor/issues/29) · [#31](https://github.com/h3pdesign/Neon-Vision-Editor/issues/31) @@ -509,7 +509,7 @@ Most editor features are shared across macOS, iOS, and iPadOS. - ![v0.5.5](https://img.shields.io/badge/v0.5.5-22C55E?style=flat-square) first-open/sidebar rendering stabilization, session-restore hardening, and Code Snapshot workflow polish. Tracking: [Milestone 0.5.5](https://github.com/h3pdesign/Neon-Vision-Editor/milestone/6) · [Release v0.5.5](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.5) -### Next (v0.5.6 - v0.5.8) +### Next (v0.5.7 - v0.5.9) - ![v0.5.6](https://img.shields.io/badge/v0.5.6-F59E0B?style=flat-square) Safe Mode startup. Tracking: [#27](https://github.com/h3pdesign/Neon-Vision-Editor/issues/27) @@ -615,18 +615,19 @@ All shortcuts use `Cmd` (`⌘`). iPad/iOS require a hardware keyboard. ## Changelog -Latest stable: **v0.5.5** (2026-03-16) +Latest stable: **v0.5.6** (2026-03-17) ### Recent Releases (At a glance) | Version | Date | Highlights | Fixes | Breaking changes | Migration | |---|---|---|---|---|---| -| [`v0.5.5`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.5) | 2026-03-16 | Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches; Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states; Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions; Code Snapshot creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer | a session-restore regression where previously open files could appear empty on first sidebar click until changing tabs; highlight scheduling during document-state transitions (`switch`, `finish load`, external edits) on macOS, iOS, and iPadOS; startup-default conflicts by aligning defaults and runtime startup gating between `Reopen Last Session` and `Open with Blank Document` | None noted | None required | +| [`v0.5.6`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.6) | 2026-03-17 | Safe Mode startup recovery with repeated-failure detection, blank-document launch fallback, a dedicated startup explanation, and a `Normal Next Launch` recovery action; a background project file index for larger folders and wired it into `Quick Open`, `Find in Files`, and project refresh flows; an iPad hardware-keyboard Vim MVP with core normal-mode navigation/editing commands and shared mode-state reporting; theme formatting controls for bold keywords, italic comments, underlined links, and bold Markdown headings across active themes | Safe Mode so a successful launch clears recovery state and normal restarts no longer re-enter Safe Mode unnecessarily; theme-formatting updates so editor styling refreshes immediately without requiring a theme switch; the editor font-size regression introduced by theme-formatting changes by restoring the base font before applying emphasis overrides | None noted | None required | +| [`v0.5.5`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.5) | 2026-03-16 | Stabilized first-open rendering from the project sidebar so file content and syntax highlighting appear on first click without requiring tab switches; Hardened startup/session behavior so `Reopen Last Session` reliably wins over conflicting blank-document startup states; Refined large-file activation and loading placeholders to avoid misclassifying smaller files as large-file sessions; Share Shot (`Code Snapshot`) creation flow with toolbar + selection-context actions (`camera.viewfinder`) and a styled share/export composer | a session-restore regression where previously open files could appear empty on first sidebar click until changing tabs; highlight scheduling during document-state transitions (`switch`, `finish load`, external edits) on macOS, iOS, and iPadOS; startup-default conflicts by aligning defaults and runtime startup gating between `Reopen Last Session` and `Open with Blank Document` | None noted | None required | | [`v0.5.4`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.4) | 2026-03-13 | a dedicated large-file open mode with deferred first paint, chunked text installation, and an optional plain-text session mode for ultra-large documents | large-file responsiveness regressions across project-sidebar reopen, tab switching, line-number visibility, status metrics, and large-file editor rendering stability | None noted | None required | -| [`v0.5.3`](https://github.com/h3pdesign/Neon-Vision-Editor/releases/tag/v0.5.3) | 2026-03-10 | a new high-readability colorful light theme preset: `Prism Daylight` (also selectable while app appearance is set to dark); double-click-to-close behavior for tabs on macOS tab strips; custom theme vibrancy by applying the vivid neon syntax profile to `Custom`, so syntax colors remain bright and saturated | toolbar-symbol contrast edge cases in dark mode where gray/black variants could appear too similar | None noted | None required | - Full release history: [`CHANGELOG.md`](CHANGELOG.md) -- Compare recent changes: [v0.5.4...v0.5.5](https://github.com/h3pdesign/Neon-Vision-Editor/compare/v0.5.4...v0.5.5) +- Latest release: **v0.5.6** +- Compare recent changes: [v0.5.5...v0.5.6](https://github.com/h3pdesign/Neon-Vision-Editor/compare/v0.5.5...v0.5.6) ## Known Limitations @@ -645,12 +646,12 @@ Latest stable: **v0.5.5** (2026-03-16) ## Release Integrity -- Tag: `v0.5.5` +- Tag: `v0.5.6` - Tagged commit: `f23c74a` - Verify local tag target: ```bash -git rev-parse --verify v0.5.5 +git rev-parse --verify v0.5.6 ``` - Verify downloaded artifact checksum locally: