diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e6932..7b734e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,15 @@ The format follows *Keep a Changelog*. Versions use semantic versioning with pre - Settings architecture cleanup: editor options consolidated into Settings dialog/sheet and aligned with toolbar actions. - Language detection and syntax highlighting stability for newly opened tabs and ongoing edits. - Sequoia/Tahoe compatibility guards and cross-platform settings presentation behavior. +- Consolidated macOS app icon source to a single `Resources/AppIcon.icon` catalog (removed duplicate `Assets.xcassets/AppIcon.icon`). ### Fixed - iOS build break caused by missing settings sheet state binding in `ContentView`. - Find panel behavior (`Cmd+F`, initial focus, Enter-to-find-next) and highlight-current-line setting application. - Line number ruler rendering overlap/flicker issues from previous fragment enumeration logic. - Editor text sanitization paths around paste/tab/open flows to reduce injected visible whitespace glyph artifacts. +- Reintroduced whitespace/control glyph artifacts (`U+2581`, `U+2400`–`U+243F`) during typing/paste by hardening sanitizer checks in editor update paths. +- macOS line-number gutter redraw/background mismatch so the ruler keeps the same window/editor tone without white striping. ## [v0.4.4-beta] - 2026-02-09 diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index c0d1db2..cecd6bf 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -101,6 +101,7 @@ struct NeonVisionEditorApp: App { // Force-disable invisible/control character rendering. defaults.set(false, forKey: "NSShowAllInvisibles") defaults.set(false, forKey: "NSShowControlCharacters") + defaults.set(false, forKey: "SettingsShowInvisibleCharacters") // Default editor behavior: // - keep line numbers on // - keep style/space visualization toggles off unless user enables them in Settings diff --git a/Neon Vision Editor/Resources/Assets.xcassets/AppIcon.icon/Assets/foreground.png b/Neon Vision Editor/Resources/Assets.xcassets/AppIcon.icon/Assets/foreground.png deleted file mode 100644 index f3a230e..0000000 Binary files a/Neon Vision Editor/Resources/Assets.xcassets/AppIcon.icon/Assets/foreground.png and /dev/null differ diff --git a/Neon Vision Editor/Resources/Assets.xcassets/AppIcon.icon/icon.json b/Neon Vision Editor/Resources/Assets.xcassets/AppIcon.icon/icon.json deleted file mode 100644 index 902d873..0000000 --- a/Neon Vision Editor/Resources/Assets.xcassets/AppIcon.icon/icon.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "fill-specializations" : [ - { - "value" : { - "linear-gradient" : [ - "srgb:1.00000,0.41748,0.93330,1.00000", - "srgb:0.49593,0.00401,0.64443,1.00000" - ], - "orientation" : { - "start" : { - "x" : 0.5, - "y" : 0 - }, - "stop" : { - "x" : 0.5, - "y" : 0.7 - } - } - } - }, - { - "appearance" : "dark", - "value" : "automatic" - } - ], - "groups" : [ - { - "blur-material" : null, - "layers" : [ - { - "blend-mode" : "darken", - "fill" : "none", - "hidden" : false, - "image-name" : "foreground.png", - "name" : "foreground", - "position" : { - "scale" : 1.38, - "translation-in-points" : [ - 8.279999999999973, - 0.6900000000000546 - ] - } - } - ], - "lighting" : "individual", - "opacity" : 1, - "shadow" : { - "kind" : "layer-color", - "opacity" : 0.5 - }, - "specular" : true, - "translucency" : { - "enabled" : true, - "value" : 0.5 - } - } - ], - "supported-platforms" : { - "circles" : [ - "watchOS" - ], - "squares" : "shared" - } -} \ No newline at end of file diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 62d3542..aec4c9d 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -757,7 +757,7 @@ struct ContentView: View { #endif private func withBaseEditorEvents(_ view: Content) -> some View { - view + let viewWithClipboardEvents = view .onReceive(NotificationCenter.default.publisher(for: .caretPositionDidChange)) { notif in if let line = notif.userInfo?["line"] as? Int, let col = notif.userInfo?["column"] as? Int { if line <= 0 { @@ -767,99 +767,135 @@ struct ContentView: View { } } } - .onReceive(NotificationCenter.default.publisher(for: .pastedText)) { notif in - if let pasted = notif.object as? String { - let result = LanguageDetector.shared.detect(text: pasted, name: nil, fileURL: nil) - currentLanguageBinding.wrappedValue = result.lang + .onReceive(NotificationCenter.default.publisher(for: .pastedText)) { notif in + handlePastedTextNotification(notif) } + .onReceive(NotificationCenter.default.publisher(for: .pastedFileURL)) { notif in + handlePastedFileNotification(notif) + } + .onReceive(NotificationCenter.default.publisher(for: .zoomEditorFontRequested)) { notif in + let delta: Double = { + if let d = notif.object as? Double { return d } + if let n = notif.object as? NSNumber { return n.doubleValue } + return 1 + }() + adjustEditorFontSize(delta) + } + .onReceive(NotificationCenter.default.publisher(for: .droppedFileURL)) { notif in + handleDroppedFileNotification(notif) + } + + return viewWithClipboardEvents + .onReceive(NotificationCenter.default.publisher(for: .droppedFileLoadStarted)) { notif in + droppedFileLoadInProgress = true + droppedFileProgressDeterminate = (notif.userInfo?["isDeterminate"] as? Bool) ?? true + droppedFileLoadProgress = 0 + droppedFileLoadLabel = "Reading file" + largeFileModeEnabled = (notif.userInfo?["largeFileMode"] as? Bool) ?? false + } + .onReceive(NotificationCenter.default.publisher(for: .droppedFileLoadProgress)) { notif in + // Recover even if "started" was missed. + droppedFileLoadInProgress = true + if let determinate = notif.userInfo?["isDeterminate"] as? Bool { + droppedFileProgressDeterminate = determinate + } + let fraction: Double = { + if let v = notif.userInfo?["fraction"] as? Double { return v } + if let v = notif.userInfo?["fraction"] as? NSNumber { return v.doubleValue } + if let v = notif.userInfo?["fraction"] as? Float { return Double(v) } + if let v = notif.userInfo?["fraction"] as? CGFloat { return Double(v) } + return droppedFileLoadProgress + }() + droppedFileLoadProgress = min(max(fraction, 0), 1) + if (notif.userInfo?["largeFileMode"] as? Bool) == true { + largeFileModeEnabled = true + } + if let name = notif.userInfo?["fileName"] as? String, !name.isEmpty { + droppedFileLoadLabel = name + } + } + .onReceive(NotificationCenter.default.publisher(for: .droppedFileLoadFinished)) { notif in + let success = (notif.userInfo?["success"] as? Bool) ?? true + droppedFileLoadProgress = success ? 1 : 0 + droppedFileProgressDeterminate = true + if (notif.userInfo?["largeFileMode"] as? Bool) == true { + largeFileModeEnabled = true + } + if !success, let message = notif.userInfo?["message"] as? String, !message.isEmpty { + findStatusMessage = "Drop failed: \(message)" + droppedFileLoadLabel = "Import failed" + } + DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.35 : 2.5)) { + droppedFileLoadInProgress = false + } + } + .onChange(of: viewModel.selectedTab?.id) { _, _ in + updateLargeFileMode(for: currentContentBinding.wrappedValue) + highlightRefreshToken &+= 1 + } + .onChange(of: currentLanguage) { _, newValue in + settingsTemplateLanguage = newValue + } + } + + private func handlePastedTextNotification(_ notif: Notification) { + guard let pasted = notif.object as? String else { DispatchQueue.main.async { updateLargeFileMode(for: currentContentBinding.wrappedValue) highlightRefreshToken &+= 1 } + return } - .onReceive(NotificationCenter.default.publisher(for: .pastedFileURL)) { notif in - var urls: [URL] = [] - if let url = notif.object as? URL { - urls = [url] - } else if let list = notif.object as? [URL] { - urls = list - } - guard !urls.isEmpty else { return } - for url in urls { - viewModel.openFile(url: url) - } - DispatchQueue.main.async { - updateLargeFileMode(for: currentContentBinding.wrappedValue) - highlightRefreshToken &+= 1 + let result = LanguageDetector.shared.detect(text: pasted, name: nil, fileURL: nil) + if let tab = viewModel.selectedTab { + if let idx = viewModel.tabs.firstIndex(where: { $0.id == tab.id }), + !viewModel.tabs[idx].languageLocked, + viewModel.tabs[idx].language == "plain", + result.lang != "plain" { + viewModel.tabs[idx].language = result.lang } + } else if singleLanguage == "plain", result.lang != "plain" { + singleLanguage = result.lang } - .onReceive(NotificationCenter.default.publisher(for: .zoomEditorFontRequested)) { notif in - let delta: Double = { - if let d = notif.object as? Double { return d } - if let n = notif.object as? NSNumber { return n.doubleValue } - return 1 - }() - adjustEditorFontSize(delta) - } - .onReceive(NotificationCenter.default.publisher(for: .droppedFileURL)) { notif in - guard let fileURL = notif.object as? URL else { return } - if let preferred = LanguageDetector.shared.preferredLanguage(for: fileURL) { - currentLanguageBinding.wrappedValue = preferred - } - DispatchQueue.main.async { - updateLargeFileMode(for: currentContentBinding.wrappedValue) - highlightRefreshToken &+= 1 - } - } - .onReceive(NotificationCenter.default.publisher(for: .droppedFileLoadStarted)) { notif in - droppedFileLoadInProgress = true - droppedFileProgressDeterminate = (notif.userInfo?["isDeterminate"] as? Bool) ?? true - droppedFileLoadProgress = 0 - droppedFileLoadLabel = "Reading file" - largeFileModeEnabled = (notif.userInfo?["largeFileMode"] as? Bool) ?? false - } - .onReceive(NotificationCenter.default.publisher(for: .droppedFileLoadProgress)) { notif in - // Recover even if "started" was missed. - droppedFileLoadInProgress = true - if let determinate = notif.userInfo?["isDeterminate"] as? Bool { - droppedFileProgressDeterminate = determinate - } - let fraction: Double = { - if let v = notif.userInfo?["fraction"] as? Double { return v } - if let v = notif.userInfo?["fraction"] as? NSNumber { return v.doubleValue } - if let v = notif.userInfo?["fraction"] as? Float { return Double(v) } - if let v = notif.userInfo?["fraction"] as? CGFloat { return Double(v) } - return droppedFileLoadProgress - }() - droppedFileLoadProgress = min(max(fraction, 0), 1) - if (notif.userInfo?["largeFileMode"] as? Bool) == true { - largeFileModeEnabled = true - } - if let name = notif.userInfo?["fileName"] as? String, !name.isEmpty { - droppedFileLoadLabel = name - } - } - .onReceive(NotificationCenter.default.publisher(for: .droppedFileLoadFinished)) { notif in - let success = (notif.userInfo?["success"] as? Bool) ?? true - droppedFileLoadProgress = success ? 1 : 0 - droppedFileProgressDeterminate = true - if (notif.userInfo?["largeFileMode"] as? Bool) == true { - largeFileModeEnabled = true - } - if !success, let message = notif.userInfo?["message"] as? String, !message.isEmpty { - findStatusMessage = "Drop failed: \(message)" - droppedFileLoadLabel = "Import failed" - } - DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.35 : 2.5)) { - droppedFileLoadInProgress = false - } - } - .onChange(of: viewModel.selectedTab?.id) { _, _ in + DispatchQueue.main.async { updateLargeFileMode(for: currentContentBinding.wrappedValue) highlightRefreshToken &+= 1 } - .onChange(of: currentLanguage) { _, newValue in - settingsTemplateLanguage = newValue + } + + private func handlePastedFileNotification(_ notif: Notification) { + var urls: [URL] = [] + if let url = notif.object as? URL { + urls = [url] + } else if let list = notif.object as? [URL] { + urls = list + } + guard !urls.isEmpty else { return } + for url in urls { + viewModel.openFile(url: url) + } + DispatchQueue.main.async { + updateLargeFileMode(for: currentContentBinding.wrappedValue) + highlightRefreshToken &+= 1 + } + } + + private func handleDroppedFileNotification(_ notif: Notification) { + guard let fileURL = notif.object as? URL else { return } + if let preferred = LanguageDetector.shared.preferredLanguage(for: fileURL) { + if let tab = viewModel.selectedTab { + if let idx = viewModel.tabs.firstIndex(where: { $0.id == tab.id }), + !viewModel.tabs[idx].languageLocked, + viewModel.tabs[idx].language == "plain" { + viewModel.tabs[idx].language = preferred + } + } else if singleLanguage == "plain" { + singleLanguage = preferred + } + } + DispatchQueue.main.async { + updateLargeFileMode(for: currentContentBinding.wrappedValue) + highlightRefreshToken &+= 1 } } diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index cef4d26..c0fac48 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -363,6 +363,16 @@ final class AcceptingTextView: NSTextView { EditorTextSanitizer.sanitize(input) } + private static func containsGlyphArtifacts(_ input: String) -> Bool { + for scalar in input.unicodeScalars { + let value = scalar.value + if value == 0x2581 || (0x2400...0x243F).contains(value) { + return true + } + } + return false + } + private func sanitizedPlainText(_ input: String) -> String { Self.sanitizePlainText(input) } @@ -566,7 +576,18 @@ final class AcceptingTextView: NSTextView { override func didChangeText() { super.didChangeText() - forceDisableInvisibleGlyphRendering() + forceDisableInvisibleGlyphRendering(deep: true) + if let storage = textStorage { + let raw = storage.string + if Self.containsGlyphArtifacts(raw) { + let sanitized = Self.sanitizePlainText(raw) + let sel = selectedRange() + storage.beginEditing() + storage.replaceCharacters(in: NSRange(location: 0, length: (raw as NSString).length), with: sanitized) + storage.endEditing() + setSelectedRange(NSRange(location: min(sel.location, (sanitized as NSString).length), length: 0)) + } + } if !isApplyingInlineSuggestion { clearInlineSuggestion() } diff --git a/Neon Vision Editor/UI/LineNumberRulerView.swift b/Neon Vision Editor/UI/LineNumberRulerView.swift index f965c70..7c2c435 100644 --- a/Neon Vision Editor/UI/LineNumberRulerView.swift +++ b/Neon Vision Editor/UI/LineNumberRulerView.swift @@ -5,26 +5,44 @@ final class LineNumberRulerView: NSRulerView { weak var textView: NSTextView? private let font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) - private let textColor = NSColor.secondaryLabelColor + private let textColor = NSColor.tertiaryLabelColor.withAlphaComponent(0.92) private let inset: CGFloat = 6 + private var observers: [NSObjectProtocol] = [] init(textView: NSTextView) { self.textView = textView super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler) self.clientView = textView self.ruleThickness = 48 + installObservers(textView: textView) } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + override var isOpaque: Bool { true } override func draw(_ dirtyRect: NSRect) { - let bg: NSColor = textView?.backgroundColor ?? .clear + let bg: NSColor = { + guard let tv = textView else { return .windowBackgroundColor } + let color = tv.backgroundColor + if color.alphaComponent >= 0.99 { + return color + } + if let windowColor = tv.window?.backgroundColor { + return windowColor + } + return .windowBackgroundColor + }() bg.setFill() bounds.fill() - NSColor.separatorColor.withAlphaComponent(0.35).setFill() + NSColor.separatorColor.withAlphaComponent(0.09).setFill() NSRect(x: bounds.maxX - 1, y: bounds.minY, width: 1, height: bounds.height).fill() drawHashMarksAndLabels(in: dirtyRect) @@ -91,5 +109,23 @@ final class LineNumberRulerView: NSRulerView { numberString.draw(at: drawPoint, withAttributes: attributes) } } + + private func installObservers(textView: NSTextView) { + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: NSText.didChangeNotification, + object: textView, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + }) + observers.append(center.addObserver( + forName: NSView.boundsDidChangeNotification, + object: textView.enclosingScrollView?.contentView, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + }) + } } #endif