import SwiftUI import Foundation extension Notification.Name { static let pastedFileURL = Notification.Name("pastedFileURL") } #if os(macOS) import AppKit final class AcceptingTextView: NSTextView { override var acceptsFirstResponder: Bool { true } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override var mouseDownCanMoveWindow: Bool { false } override var isOpaque: Bool { false } private let vimModeDefaultsKey = "EditorVimModeEnabled" private let vimInterceptionDefaultsKey = "EditorVimInterceptionEnabled" private var isVimInsertMode: Bool = true private var vimObservers: [NSObjectProtocol] = [] private var didConfigureVimMode: Bool = false private var didApplyDeepInvisibleDisable: Bool = false private let dropReadChunkSize = 64 * 1024 fileprivate var isApplyingDroppedContent: Bool = false private var inlineSuggestion: String? private var inlineSuggestionLocation: Int? private var inlineSuggestionView: NSTextField? fileprivate var isApplyingInlineSuggestion: Bool = false fileprivate var recentlyAcceptedInlineSuggestion: Bool = false fileprivate var isApplyingPaste: Bool = false var autoIndentEnabled: Bool = true var autoCloseBracketsEnabled: Bool = true var indentStyle: String = "spaces" var indentWidth: Int = 4 var highlightCurrentLine: Bool = true private let editorInsetX: CGFloat = 12 // We want the caret at the *start* of the paste. private var pendingPasteCaretLocation: Int? deinit { for observer in vimObservers { NotificationCenter.default.removeObserver(observer) } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if !didConfigureVimMode { configureVimMode() didConfigureVimMode = true } DispatchQueue.main.async { [weak self] in guard let self else { return } self.window?.makeFirstResponder(self) } textContainerInset = NSSize(width: editorInsetX, height: 12) forceDisableInvisibleGlyphRendering(deep: true) } override func mouseDown(with event: NSEvent) { cancelPendingPasteCaretEnforcement() clearInlineSuggestion() super.mouseDown(with: event) window?.makeFirstResponder(self) } override func scrollWheel(with event: NSEvent) { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if flags.contains([.shift, .option]) { let delta = event.scrollingDeltaY if abs(delta) > 0.1 { let step: Double = delta > 0 ? 1 : -1 NotificationCenter.default.post(name: .zoomEditorFontRequested, object: step) return } } cancelPendingPasteCaretEnforcement() super.scrollWheel(with: event) updateInlineSuggestionPosition() } override func becomeFirstResponder() -> Bool { let didBecome = super.becomeFirstResponder() if didBecome, UserDefaults.standard.bool(forKey: vimModeDefaultsKey) { // Re-enter NORMAL whenever Vim mode is active. isVimInsertMode = false postVimModeState() } return didBecome } override func layout() { super.layout() updateInlineSuggestionPosition() forceDisableInvisibleGlyphRendering() } // MARK: - Drag & Drop: insert file contents instead of file path override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { let canRead = sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: [ .urlReadingFileURLsOnly: true ]) return canRead ? .copy : [] } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { let pb = sender.draggingPasteboard if let nsurls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [NSURL], !nsurls.isEmpty { let urls = nsurls.map { $0 as URL } if urls.count == 1 { NotificationCenter.default.post(name: .pastedFileURL, object: urls[0]) } else { NotificationCenter.default.post(name: .pastedFileURL, object: urls) } // Do not insert content; let higher-level controller open a new tab. return true } return false } private func applyDroppedContentInChunks( _ content: String, at selection: NSRange, fileName: String, largeFileMode: Bool, completion: @escaping (Bool, Int) -> Void ) { let nsContent = content as NSString let safeSelection = clampedRange(selection, forTextLength: (string as NSString).length) let total = nsContent.length if total == 0 { completion(true, 0) return } NotificationCenter.default.post( name: .droppedFileLoadProgress, object: nil, userInfo: [ "fraction": 0.70, "fileName": "Applying file", "largeFileMode": largeFileMode ] ) // Large payloads: prefer one atomic replace after yielding one runloop turn so // progress updates can render before the heavy text-system mutation begins. if total >= 300_000 { isApplyingDroppedContent = true NotificationCenter.default.post( name: .droppedFileLoadProgress, object: nil, userInfo: [ "fraction": 0.90, "fileName": "Applying file", "largeFileMode": largeFileMode, "isDeterminate": true ] ) DispatchQueue.main.async { [weak self] in guard let self else { completion(false, 0) return } let replaceSucceeded: Bool if let storage = self.textStorage { let liveSafeSelection = self.clampedRange(safeSelection, forTextLength: storage.length) self.undoManager?.disableUndoRegistration() storage.beginEditing() if storage.length == 0 && liveSafeSelection.location == 0 && liveSafeSelection.length == 0 { storage.mutableString.setString(content) } else { storage.mutableString.replaceCharacters(in: liveSafeSelection, with: content) } storage.endEditing() self.undoManager?.enableUndoRegistration() replaceSucceeded = true } else { let current = self.string as NSString if safeSelection.location <= current.length && safeSelection.location + safeSelection.length <= current.length { let replaced = current.replacingCharacters(in: safeSelection, with: content) self.string = replaced replaceSucceeded = true } else { replaceSucceeded = false } } self.isApplyingDroppedContent = false NotificationCenter.default.post( name: .droppedFileLoadProgress, object: nil, userInfo: [ "fraction": replaceSucceeded ? 1.0 : 0.0, "fileName": replaceSucceeded ? "Reading file" : "Import failed", "largeFileMode": largeFileMode, "isDeterminate": true ] ) completion(replaceSucceeded, replaceSucceeded ? total : 0) } return } let replaceSucceeded: Bool if let storage = textStorage { undoManager?.disableUndoRegistration() storage.beginEditing() storage.mutableString.replaceCharacters(in: safeSelection, with: content) storage.endEditing() undoManager?.enableUndoRegistration() replaceSucceeded = true } else { // Fallback for environments where textStorage is temporarily unavailable. let current = string as NSString if safeSelection.location <= current.length && safeSelection.location + safeSelection.length <= current.length { let replaced = current.replacingCharacters(in: safeSelection, with: content) string = replaced replaceSucceeded = true } else { replaceSucceeded = false } } NotificationCenter.default.post( name: .droppedFileLoadProgress, object: nil, userInfo: [ "fraction": replaceSucceeded ? 1.0 : 0.0, "fileName": replaceSucceeded ? "Reading file" : "Import failed", "largeFileMode": largeFileMode ] ) completion(replaceSucceeded, replaceSucceeded ? total : 0) } private func clampedSelectionRange() -> NSRange { clampedRange(selectedRange(), forTextLength: (string as NSString).length) } private func clampedRange(_ range: NSRange, forTextLength length: Int) -> NSRange { guard length >= 0 else { return NSRange(location: 0, length: 0) } if range.location == NSNotFound { return NSRange(location: length, length: 0) } let safeLocation = min(max(0, range.location), length) let maxLen = max(0, length - safeLocation) let safeLength = min(max(0, range.length), maxLen) return NSRange(location: safeLocation, length: safeLength) } private func readDroppedFileData( at url: URL, totalBytes: Int64, progress: @escaping (Double) -> Void ) throws -> Data { do { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } var data = Data() if totalBytes > 0, totalBytes <= Int64(Int.max) { data.reserveCapacity(Int(totalBytes)) } var loadedBytes: Int64 = 0 var lastReported: Double = -1 while true { let chunk = try handle.read(upToCount: dropReadChunkSize) ?? Data() if chunk.isEmpty { break } data.append(chunk) loadedBytes += Int64(chunk.count) if totalBytes > 0 { let fraction = min(1.0, Double(loadedBytes) / Double(totalBytes)) if fraction - lastReported >= 0.02 || fraction >= 1.0 { lastReported = fraction DispatchQueue.main.async { progress(fraction) } } } } if totalBytes > 0, lastReported < 1.0 { DispatchQueue.main.async { progress(1.0) } } return data } catch { // Fallback path for URLs/FileHandle edge cases in sandboxed drag-drop. let data = try Data(contentsOf: url, options: [.mappedIfSafe]) DispatchQueue.main.async { progress(1.0) } return data } } private func decodeDroppedFileText(_ data: Data, fileURL: URL) -> String { let encodings: [String.Encoding] = [.utf8, .utf16, .utf16LittleEndian, .utf16BigEndian, .windowsCP1252, .isoLatin1] for encoding in encodings { if let decoded = String(data: data, encoding: encoding) { return Self.sanitizePlainText(decoded) } } if let fallback = try? String(contentsOf: fileURL, encoding: .utf8) { return Self.sanitizePlainText(fallback) } return Self.sanitizePlainText(String(decoding: data, as: UTF8.self)) } // MARK: - Typing helpers (existing behavior) override func insertText(_ insertString: Any, replacementRange: NSRange) { if !isApplyingInlineSuggestion { clearInlineSuggestion() } guard let s = insertString as? String else { super.insertText(insertString, replacementRange: replacementRange) return } let sanitized = sanitizedPlainText(s) // Ensure invisibles off after insertion self.layoutManager?.showsInvisibleCharacters = false self.layoutManager?.showsControlCharacters = false // Auto-indent by copying leading whitespace if sanitized == "\n" && autoIndentEnabled { let ns = (string as NSString) let sel = selectedRange() let lineRange = ns.lineRange(for: NSRange(location: sel.location, length: 0)) let currentLine = ns.substring(with: NSRange( location: lineRange.location, length: max(0, sel.location - lineRange.location) )) let indent = currentLine.prefix { $0 == " " || $0 == "\t" } super.insertText("\n" + indent, replacementRange: replacementRange) return } // Auto-close common bracket/quote pairs let pairs: [String: String] = ["(": ")", "[": "]", "{": "}", "\"": "\"", "'": "'"] if autoCloseBracketsEnabled, let closing = pairs[sanitized] { let sel = selectedRange() super.insertText(sanitized + closing, replacementRange: replacementRange) setSelectedRange(NSRange(location: sel.location + 1, length: 0)) return } super.insertText(sanitized, replacementRange: replacementRange) } /// Remove control/format characters that render as visible placeholders and normalize NBSP/CR to safe whitespace. static func sanitizePlainText(_ input: String) -> String { 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) } override func keyDown(with event: NSEvent) { if event.keyCode == 48 { // Tab if acceptInlineSuggestion() { return } } // Safety default: bypass Vim interception unless explicitly enabled. if !UserDefaults.standard.bool(forKey: vimInterceptionDefaultsKey) { super.keyDown(with: event) return } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if flags.contains(.command) || flags.contains(.option) || flags.contains(.control) { super.keyDown(with: event) return } let vimModeEnabled = UserDefaults.standard.bool(forKey: vimModeDefaultsKey) guard vimModeEnabled else { super.keyDown(with: event) return } if !isVimInsertMode { let key = event.charactersIgnoringModifiers ?? "" switch key { case "h": moveLeft(nil) case "j": moveDown(nil) case "k": moveUp(nil) case "l": moveRight(nil) case "w": moveWordForward(nil) case "b": moveWordBackward(nil) case "0": moveToBeginningOfLine(nil) case "$": moveToEndOfLine(nil) case "x": deleteForward(nil) case "p": paste(nil) case "i": isVimInsertMode = true postVimModeState() case "a": moveRight(nil) isVimInsertMode = true postVimModeState() case "\u{1B}": break default: break } return } // Escape exits insert mode. if event.keyCode == 53 || event.characters == "\u{1B}" { isVimInsertMode = false postVimModeState() return } super.keyDown(with: event) } override func insertTab(_ sender: Any?) { if acceptInlineSuggestion() { return } // Keep Tab insertion deterministic and avoid platform-level invisible glyph rendering. let insertion: String if indentStyle == "tabs" { insertion = "\t" } else { insertion = String(repeating: " ", count: max(1, indentWidth)) } super.insertText(sanitizedPlainText(insertion), replacementRange: selectedRange()) forceDisableInvisibleGlyphRendering() } // Paste: capture insertion point and enforce caret position after paste across async updates. override func paste(_ sender: Any?) { let pasteboard = NSPasteboard.general if let nsurls = pasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [NSURL], !nsurls.isEmpty { let urls = nsurls.map { $0 as URL } if urls.count == 1 { NotificationCenter.default.post(name: .pastedFileURL, object: urls[0]) } else { NotificationCenter.default.post(name: .pastedFileURL, object: urls) } return } if let urls = fileURLsFromPasteboard(pasteboard), !urls.isEmpty { if urls.count == 1 { NotificationCenter.default.post(name: .pastedFileURL, object: urls[0]) } else { NotificationCenter.default.post(name: .pastedFileURL, object: urls) } return } // Capture where paste begins (start of insertion/replacement) pendingPasteCaretLocation = selectedRange().location if let raw = pasteboardPlainString(from: pasteboard), !raw.isEmpty { if let pathURL = fileURLFromString(raw) { NotificationCenter.default.post(name: .pastedFileURL, object: pathURL) return } let sanitized = sanitizedPlainText(raw) isApplyingPaste = true textStorage?.beginEditing() replaceCharacters(in: selectedRange(), with: sanitized) textStorage?.endEditing() isApplyingPaste = false // Ensure invisibles are off after paste self.layoutManager?.showsInvisibleCharacters = false self.layoutManager?.showsControlCharacters = false NotificationCenter.default.post(name: .pastedText, object: sanitized) didChangeText() schedulePasteCaretEnforcement() return } isApplyingPaste = true super.paste(sender) DispatchQueue.main.async { [weak self] in self?.isApplyingPaste = false // Ensure invisibles are off after async paste self?.layoutManager?.showsInvisibleCharacters = false self?.layoutManager?.showsControlCharacters = false } // Enforce caret after paste (multiple ticks beats late selection changes) schedulePasteCaretEnforcement() } private func pasteboardPlainString(from pasteboard: NSPasteboard) -> String? { if let raw = pasteboard.string(forType: .string), !raw.isEmpty { return raw } if let strings = pasteboard.readObjects(forClasses: [NSString.self], options: nil) as? [NSString], let first = strings.first, first.length > 0 { return first as String } if let rtf = pasteboard.data(forType: .rtf), let attributed = try? NSAttributedString( data: rtf, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil ), !attributed.string.isEmpty { return attributed.string } return nil } private func fileURLsFromPasteboard(_ pasteboard: NSPasteboard) -> [URL]? { if let fileURLString = pasteboard.string(forType: .fileURL), let url = URL(string: fileURLString), url.isFileURL, FileManager.default.fileExists(atPath: url.path) { return [url] } let filenamesType = NSPasteboard.PasteboardType("NSFilenamesPboardType") if let list = pasteboard.propertyList(forType: filenamesType) as? [String] { let urls = list.map { URL(fileURLWithPath: $0) }.filter { FileManager.default.fileExists(atPath: $0.path) } if !urls.isEmpty { return urls } } return nil } private func fileURLFromString(_ text: String) -> URL? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if let url = URL(string: trimmed), url.isFileURL, FileManager.default.fileExists(atPath: url.path) { return url } // Plain paths (with spaces or ~) let expanded = (trimmed as NSString).expandingTildeInPath if FileManager.default.fileExists(atPath: expanded) { return URL(fileURLWithPath: expanded) } return nil } override func didChangeText() { super.didChangeText() 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() } // Pasting triggers didChangeText; schedule enforcement again. schedulePasteCaretEnforcement() } private func forceDisableInvisibleGlyphRendering(deep: Bool = false) { layoutManager?.showsInvisibleCharacters = false layoutManager?.showsControlCharacters = false guard deep, !didApplyDeepInvisibleDisable else { return } didApplyDeepInvisibleDisable = true let selectors = [ "setShowsInvisibleCharacters:", "setShowsControlCharacters:", "setDisplaysInvisibleCharacters:", "setDisplaysControlCharacters:" ] for selectorName in selectors { let selector = NSSelectorFromString(selectorName) let value = NSNumber(value: false) if responds(to: selector) { _ = perform(selector, with: value) } if let lm = layoutManager, lm.responds(to: selector) { _ = lm.perform(selector, with: value) } } if #available(macOS 12.0, *) { if let tlm = value(forKey: "textLayoutManager") as? NSObject { for selectorName in selectors { let selector = NSSelectorFromString(selectorName) if tlm.responds(to: selector) { _ = tlm.perform(selector, with: NSNumber(value: false)) } } } } } func showInlineSuggestion(_ suggestion: String, at location: Int) { guard !suggestion.isEmpty else { clearInlineSuggestion() return } inlineSuggestion = suggestion inlineSuggestionLocation = location if inlineSuggestionView == nil { let label = NSTextField(labelWithString: suggestion) label.isBezeled = false label.isEditable = false label.isSelectable = false label.drawsBackground = false label.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.6) label.font = font ?? NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) label.lineBreakMode = .byClipping label.maximumNumberOfLines = 1 inlineSuggestionView = label addSubview(label) } else { inlineSuggestionView?.stringValue = suggestion inlineSuggestionView?.font = font ?? NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) } updateInlineSuggestionPosition() } func clearInlineSuggestion() { inlineSuggestion = nil inlineSuggestionLocation = nil inlineSuggestionView?.removeFromSuperview() inlineSuggestionView = nil } private func acceptInlineSuggestion() -> Bool { guard let suggestion = inlineSuggestion, let loc = inlineSuggestionLocation else { return false } let sanitizedSuggestion = sanitizedPlainText(suggestion) guard !sanitizedSuggestion.isEmpty else { clearInlineSuggestion() return false } let sel = selectedRange() guard sel.length == 0, sel.location == loc else { clearInlineSuggestion() return false } isApplyingInlineSuggestion = true textStorage?.replaceCharacters(in: NSRange(location: loc, length: 0), with: sanitizedSuggestion) setSelectedRange(NSRange(location: loc + (sanitizedSuggestion as NSString).length, length: 0)) isApplyingInlineSuggestion = false recentlyAcceptedInlineSuggestion = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.recentlyAcceptedInlineSuggestion = false } clearInlineSuggestion() return true } private func updateInlineSuggestionPosition() { guard let suggestion = inlineSuggestion, let loc = inlineSuggestionLocation, let label = inlineSuggestionView, let window else { return } let sel = selectedRange() if sel.location != loc || sel.length != 0 { clearInlineSuggestion() return } let rectInScreen = firstRect(forCharacterRange: NSRange(location: loc, length: 0), actualRange: nil) let rectInWindow = window.convertFromScreen(rectInScreen) let rectInView = convert(rectInWindow, from: nil) label.stringValue = suggestion label.sizeToFit() label.frame.origin = NSPoint(x: rectInView.origin.x, y: rectInView.origin.y) } // Re-apply the desired caret position over multiple runloop ticks to beat late layout/async work. private func schedulePasteCaretEnforcement() { guard pendingPasteCaretLocation != nil else { return } // Cancel previously queued enforcement to avoid spamming NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(applyPendingPasteCaret), object: nil) // Run next turn perform(#selector(applyPendingPasteCaret), with: nil, afterDelay: 0) // Run again next runloop tick (beats "snap back" from late async work) DispatchQueue.main.async { [weak self] in self?.applyPendingPasteCaret() } // Run once more with a tiny delay (beats slower async highlight passes) DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in self?.applyPendingPasteCaret() } } private func cancelPendingPasteCaretEnforcement() { pendingPasteCaretLocation = nil NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(applyPendingPasteCaret), object: nil) } @objc private func applyPendingPasteCaret() { guard let desired = pendingPasteCaretLocation else { return } let length = (string as NSString).length let loc = min(max(0, desired), length) let range = NSRange(location: loc, length: 0) // Set caret and keep it visible setSelectedRange(range) if let container = textContainer { layoutManager?.ensureLayout(for: container) } scrollRangeToVisible(range) // Important: clear only after we've enforced at least once. // The delayed calls will no-op once this is nil. pendingPasteCaretLocation = nil } private func postVimModeState() { NotificationCenter.default.post( name: .vimModeStateDidChange, object: nil, userInfo: ["insertMode": isVimInsertMode] ) } private func scalarName(for value: UInt32) -> String { switch value { case 0x20: return "SPACE" case 0x09: return "TAB" case 0x0A: return "LF" case 0x0D: return "CR" case 0x2581: return "LOWER_ONE_EIGHTH_BLOCK" default: return "U+\(String(format: "%04X", value))" } } private func inspectWhitespaceScalarsAtCaret() { let ns = string as NSString let length = ns.length let caret = min(max(selectedRange().location, 0), max(length - 1, 0)) let lineRange = ns.lineRange(for: NSRange(location: caret, length: 0)) let line = ns.substring(with: lineRange) let lineOneBased = ns.substring(to: lineRange.location).reduce(1) { $1 == "\n" ? $0 + 1 : $0 } var counts: [UInt32: Int] = [:] for scalar in line.unicodeScalars { let value = scalar.value let isWhitespace = scalar.properties.generalCategory == .spaceSeparator || value == 0x20 || value == 0x09 let isLineBreak = value == 0x0A || value == 0x0D let isControlPicture = (0x2400...0x243F).contains(value) let isLowBlock = value == 0x2581 if isWhitespace || isLineBreak || isControlPicture || isLowBlock { counts[value, default: 0] += 1 } } let header = "Line \(lineOneBased) at UTF16@\(selectedRange().location), whitespace scalars:" let body: String if counts.isEmpty { body = "none detected" } else { let rows = counts.keys.sorted().map { key in "\(scalarName(for: key)) x\(counts[key] ?? 0)" } body = rows.joined(separator: ", ") } let windowNumber = window?.windowNumber ?? -1 NotificationCenter.default.post( name: .whitespaceScalarInspectionResult, object: nil, userInfo: [ EditorCommandUserInfo.windowNumber: windowNumber, EditorCommandUserInfo.inspectionMessage: "\(header)\n\(body)" ] ) } private func configureVimMode() { // Vim enabled starts in NORMAL; disabled uses regular insert typing. isVimInsertMode = !UserDefaults.standard.bool(forKey: vimModeDefaultsKey) postVimModeState() let observer = NotificationCenter.default.addObserver( forName: .toggleVimModeRequested, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } // Enter NORMAL when Vim mode is enabled; INSERT when disabled. let enabled = UserDefaults.standard.bool(forKey: self.vimModeDefaultsKey) self.isVimInsertMode = !enabled self.postVimModeState() } vimObservers.append(observer) let inspectorObserver = NotificationCenter.default.addObserver( forName: .inspectWhitespaceScalarsRequested, object: nil, queue: .main ) { [weak self] notif in guard let self else { return } if let target = notif.userInfo?[EditorCommandUserInfo.windowNumber] as? Int, let own = self.window?.windowNumber, target != own { return } self.inspectWhitespaceScalarsAtCaret() } vimObservers.append(inspectorObserver) } private func trimTrailingWhitespaceIfEnabled() { let enabled = UserDefaults.standard.bool(forKey: "SettingsTrimTrailingWhitespace") guard enabled else { return } let original = self.string let lines = original.components(separatedBy: .newlines) var changed = false let trimmedLines = lines.map { line -> String in let trimmed = line.replacingOccurrences(of: #"[\t\x20]+$"#, with: "", options: .regularExpression) if trimmed != line { changed = true } return trimmed } guard changed else { return } let newString = trimmedLines.joined(separator: "\n") let oldSelected = self.selectedRange() self.textStorage?.beginEditing() self.string = newString self.textStorage?.endEditing() let newLoc = min(oldSelected.location, (newString as NSString).length) self.setSelectedRange(NSRange(location: newLoc, length: 0)) self.didChangeText() } } // NSViewRepresentable wrapper around NSTextView to integrate with SwiftUI. struct CustomTextEditor: NSViewRepresentable { @Binding var text: String let language: String let colorScheme: ColorScheme let fontSize: CGFloat @Binding var isLineWrapEnabled: Bool let isLargeFileMode: Bool let translucentBackgroundEnabled: Bool let showLineNumbers: Bool let showInvisibleCharacters: Bool let highlightCurrentLine: Bool let indentStyle: String let indentWidth: Int let autoIndentEnabled: Bool let autoCloseBracketsEnabled: Bool let highlightRefreshToken: Int private var fontName: String { UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? "" } private var lineHeightMultiple: CGFloat { let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight") return CGFloat(stored > 0 ? stored : 1.0) } // Toggle soft-wrapping by adjusting text container sizing and scroller visibility. private func applyWrapMode(isWrapped: Bool, textView: NSTextView, scrollView: NSScrollView) { if isWrapped { // Wrap: track the text view width, no horizontal scrolling textView.isHorizontallyResizable = false textView.textContainer?.widthTracksTextView = true textView.textContainer?.heightTracksTextView = false scrollView.hasHorizontalScroller = false // Ensure the container width matches the visible content width right now let contentWidth = scrollView.contentSize.width let width = contentWidth > 0 ? contentWidth : scrollView.frame.size.width textView.textContainer?.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) } else { // No wrap: allow horizontal expansion and horizontal scrolling textView.isHorizontallyResizable = true textView.textContainer?.widthTracksTextView = false textView.textContainer?.heightTracksTextView = false scrollView.hasHorizontalScroller = true textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) } // Force layout update so the change takes effect immediately if let container = textView.textContainer, let lm = textView.layoutManager { lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: (textView.string as NSString).length), actualCharacterRange: nil) lm.ensureLayout(for: container) } scrollView.reflectScrolledClipView(scrollView.contentView) } private func resolvedFont() -> NSFont { if let named = NSFont(name: fontName, size: fontSize) { return named } return NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) } private func paragraphStyle() -> NSParagraphStyle { let style = NSMutableParagraphStyle() style.lineHeightMultiple = max(0.9, lineHeightMultiple) return style } private func applyInvisibleCharacterPreference(_ textView: NSTextView) { // Hard-disable invisible/control glyph rendering in editor text. textView.layoutManager?.showsInvisibleCharacters = false textView.layoutManager?.showsControlCharacters = false let value = NSNumber(value: false) let selectors = [ "setShowsInvisibleCharacters:", "setShowsControlCharacters:", "setDisplaysInvisibleCharacters:", "setDisplaysControlCharacters:" ] for selectorName in selectors { let selector = NSSelectorFromString(selectorName) if textView.responds(to: selector) { let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value textView.perform(selector, with: enabled) } if let layoutManager = textView.layoutManager, layoutManager.responds(to: selector) { let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value _ = layoutManager.perform(selector, with: enabled) } } if #available(macOS 12.0, *) { if let tlm = textView.value(forKey: "textLayoutManager") as? NSObject { for selectorName in selectors { let selector = NSSelectorFromString(selectorName) if tlm.responds(to: selector) { let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value _ = tlm.perform(selector, with: enabled) } } } } } func makeNSView(context: Context) -> NSScrollView { // Build scroll view and text view let scrollView = NSScrollView() scrollView.drawsBackground = false scrollView.autohidesScrollers = true scrollView.hasVerticalScroller = true scrollView.contentView.postsBoundsChangedNotifications = true let textView = AcceptingTextView(frame: .zero) textView.identifier = NSUserInterfaceItemIdentifier("NeonEditorTextView") // Configure editing behavior and visuals textView.isEditable = true textView.isRichText = false textView.usesFindBar = true textView.usesInspectorBar = false textView.usesFontPanel = false textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) // Apply visibility preference from Settings (off by default). applyInvisibleCharacterPreference(textView) textView.textStorage?.beginEditing() if let storage = textView.textStorage { let fullRange = NSRange(location: 0, length: storage.length) storage.removeAttribute(.backgroundColor, range: fullRange) storage.removeAttribute(.underlineStyle, range: fullRange) storage.removeAttribute(.strikethroughStyle, range: fullRange) } textView.textStorage?.endEditing() let theme = currentEditorTheme(colorScheme: colorScheme) if translucentBackgroundEnabled { textView.backgroundColor = .clear textView.drawsBackground = false } else { let bg = (colorScheme == .light) ? NSColor.textBackgroundColor : NSColor(theme.background) textView.backgroundColor = bg textView.drawsBackground = true } // Use NSRulerView line numbering (v0.4.4-beta behavior). textView.minSize = NSSize(width: 0, height: 0) textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isSelectable = true textView.allowsUndo = true let baseTextColor = (colorScheme == .light && !translucentBackgroundEnabled) ? NSColor.textColor : NSColor(theme.text) textView.textColor = baseTextColor textView.insertionPointColor = (colorScheme == .light && !translucentBackgroundEnabled) ? NSColor.labelColor : NSColor(theme.cursor) textView.selectedTextAttributes = [ .backgroundColor: NSColor(theme.selection) ] textView.usesInspectorBar = false textView.usesFontPanel = false textView.layoutManager?.allowsNonContiguousLayout = true // Keep horizontal rulers disabled; vertical ruler is dedicated to line numbers. textView.usesRuler = true textView.isRulerVisible = showLineNumbers scrollView.hasHorizontalRuler = false scrollView.horizontalRulerView = nil scrollView.hasVerticalRuler = showLineNumbers scrollView.rulersVisible = showLineNumbers scrollView.verticalRulerView = showLineNumbers ? LineNumberRulerView(textView: textView) : nil applyInvisibleCharacterPreference(textView) textView.autoIndentEnabled = autoIndentEnabled textView.autoCloseBracketsEnabled = autoCloseBracketsEnabled textView.indentStyle = indentStyle textView.indentWidth = indentWidth textView.highlightCurrentLine = highlightCurrentLine // Disable smart substitutions/detections that can interfere with selection when recoloring textView.isAutomaticTextCompletionEnabled = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticDataDetectionEnabled = false textView.isAutomaticLinkDetectionEnabled = false textView.isGrammarCheckingEnabled = false textView.isContinuousSpellCheckingEnabled = false textView.smartInsertDeleteEnabled = false textView.registerForDraggedTypes([.fileURL, .URL]) // Embed the text view in the scroll view scrollView.documentView = textView // Configure the text view delegate textView.delegate = context.coordinator // Apply wrapping and seed initial content applyWrapMode(isWrapped: isLineWrapEnabled && !isLargeFileMode, textView: textView, scrollView: scrollView) // Seed initial text (strip control pictures when invisibles are hidden) let seeded = AcceptingTextView.sanitizePlainText(text) textView.string = seeded if seeded != text { // Keep binding clean of control-picture glyphs. DispatchQueue.main.async { if self.text != seeded { self.text = seeded } } } DispatchQueue.main.async { [weak scrollView, weak textView] in guard let sv = scrollView, let tv = textView else { return } sv.window?.makeFirstResponder(tv) } context.coordinator.scheduleHighlightIfNeeded(currentText: text) // Keep container width in sync when the scroll view resizes NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main) { [weak textView, weak scrollView] _ in guard let tv = textView, let sv = scrollView else { return } if tv.textContainer?.widthTracksTextView == true { tv.textContainer?.containerSize.width = sv.contentSize.width if let container = tv.textContainer { tv.layoutManager?.ensureLayout(for: container) } } } context.coordinator.textView = textView return scrollView } // Keep NSTextView in sync with SwiftUI state and schedule highlighting when needed. func updateNSView(_ nsView: NSScrollView, context: Context) { if let textView = nsView.documentView as? NSTextView { textView.isEditable = true textView.isSelectable = true let acceptingView = textView as? AcceptingTextView let isDropApplyInFlight = acceptingView?.isApplyingDroppedContent ?? false // Sanitize and avoid publishing binding during update let target = AcceptingTextView.sanitizePlainText(text) if textView.string != target { textView.string = target context.coordinator.invalidateHighlightCache() DispatchQueue.main.async { if self.text != target { self.text = target } } } let targetFont = resolvedFont() if textView.font != targetFont { textView.font = targetFont context.coordinator.invalidateHighlightCache() } let style = paragraphStyle() let currentLineHeight = textView.defaultParagraphStyle?.lineHeightMultiple ?? 1.0 if abs(currentLineHeight - style.lineHeightMultiple) > 0.0001 { textView.defaultParagraphStyle = style textView.typingAttributes[.paragraphStyle] = style let nsLen = (textView.string as NSString).length if nsLen <= 200_000, let storage = textView.textStorage { storage.beginEditing() storage.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: nsLen)) storage.endEditing() } } // Defensive: sanitize and clear style attributes to prevent control-picture glyphs and ruler-driven styles. let sanitized = AcceptingTextView.sanitizePlainText(textView.string) if sanitized != textView.string { textView.string = sanitized context.coordinator.invalidateHighlightCache() DispatchQueue.main.async { if self.text != sanitized { self.text = sanitized } } } if let storage = textView.textStorage { storage.beginEditing() let fullRange = NSRange(location: 0, length: storage.length) storage.removeAttribute(.underlineStyle, range: fullRange) storage.removeAttribute(.strikethroughStyle, range: fullRange) storage.endEditing() } let theme = currentEditorTheme(colorScheme: colorScheme) let effectiveHighlightCurrentLine = highlightCurrentLine let effectiveWrap = (isLineWrapEnabled && !isLargeFileMode) // Background color adjustments for translucency if translucentBackgroundEnabled { nsView.drawsBackground = false textView.backgroundColor = .clear textView.drawsBackground = false } else { nsView.drawsBackground = false let bg = (colorScheme == .light) ? NSColor.textBackgroundColor : NSColor(theme.background) textView.backgroundColor = bg textView.drawsBackground = true } let baseTextColor = (colorScheme == .light && !translucentBackgroundEnabled) ? NSColor.textColor : NSColor(theme.text) if textView.textColor != baseTextColor { textView.textColor = baseTextColor context.coordinator.invalidateHighlightCache() } let caretColor = (colorScheme == .light && !translucentBackgroundEnabled) ? NSColor.labelColor : NSColor(theme.cursor) if textView.insertionPointColor != caretColor { textView.insertionPointColor = caretColor } textView.selectedTextAttributes = [ .backgroundColor: NSColor(theme.selection) ] let showLineNumbersByDefault = showLineNumbers nsView.hasHorizontalRuler = false nsView.horizontalRulerView = nil nsView.hasVerticalRuler = showLineNumbersByDefault nsView.rulersVisible = showLineNumbersByDefault if showLineNumbersByDefault { if !(nsView.verticalRulerView is LineNumberRulerView) { nsView.verticalRulerView = LineNumberRulerView(textView: textView) } } else { nsView.verticalRulerView = nil } // Defensive clear of underline/strikethrough styles (always clear) if let storage = textView.textStorage { storage.beginEditing() let fullRange = NSRange(location: 0, length: storage.length) storage.removeAttribute(.underlineStyle, range: fullRange) storage.removeAttribute(.strikethroughStyle, range: fullRange) storage.endEditing() } // Re-apply invisible-character visibility preference after style updates. applyInvisibleCharacterPreference(textView) nsView.tile() // Keep the text container width in sync & relayout acceptingView?.autoIndentEnabled = autoIndentEnabled acceptingView?.autoCloseBracketsEnabled = autoCloseBracketsEnabled acceptingView?.indentStyle = indentStyle acceptingView?.indentWidth = indentWidth acceptingView?.highlightCurrentLine = effectiveHighlightCurrentLine applyWrapMode(isWrapped: effectiveWrap, textView: textView, scrollView: nsView) // Force immediate reflow after toggling wrap if let container = textView.textContainer, let lm = textView.layoutManager { lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: (textView.string as NSString).length), actualCharacterRange: nil) lm.ensureLayout(for: container) } textView.invalidateIntrinsicContentSize() nsView.reflectScrolledClipView(nsView.contentView) if NSApp.modalWindow == nil, let window = nsView.window, window.attachedSheet == nil, window.firstResponder !== textView { window.makeFirstResponder(textView) } // Only schedule highlight if needed (e.g., language/color scheme changes or external text updates) context.coordinator.parent = self if !isDropApplyInFlight { context.coordinator.scheduleHighlightIfNeeded() } } } func makeCoordinator() -> Coordinator { Coordinator(self) } // Coordinator: NSTextViewDelegate that bridges NSText changes to SwiftUI and manages highlighting. class Coordinator: NSObject, NSTextViewDelegate { var parent: CustomTextEditor weak var textView: NSTextView? // Background queue + debouncer for regex-based highlighting private let highlightQueue = DispatchQueue(label: "NeonVision.SyntaxHighlight", qos: .userInitiated) // Snapshots of last highlighted state to avoid redundant work private var pendingHighlight: DispatchWorkItem? private var lastHighlightedText: String = "" private var lastLanguage: String? private var lastColorScheme: ColorScheme? var lastLineHeight: CGFloat? private var lastHighlightToken: Int = 0 init(_ parent: CustomTextEditor) { self.parent = parent super.init() NotificationCenter.default.addObserver(self, selector: #selector(moveToLine(_:)), name: .moveCursorToLine, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } func invalidateHighlightCache() { lastHighlightedText = "" lastLanguage = nil lastColorScheme = nil lastLineHeight = nil lastHighlightToken = 0 } /// Schedules highlighting if text/language/theme changed. Skips very large documents /// and defers when a modal sheet is presented. func scheduleHighlightIfNeeded(currentText: String? = nil) { guard textView != nil else { return } // Query NSApp.modalWindow on the main thread to avoid thread-check warnings let isModalPresented: Bool = { if Thread.isMainThread { return NSApp.modalWindow != nil } else { var result = false DispatchQueue.main.sync { result = (NSApp.modalWindow != nil) } return result } }() if isModalPresented { pendingHighlight?.cancel() let work = DispatchWorkItem { [weak self] in self?.scheduleHighlightIfNeeded(currentText: currentText) } pendingHighlight = work highlightQueue.asyncAfter(deadline: .now() + 0.3, execute: work) return } let lang = parent.language let scheme = parent.colorScheme let lineHeightValue: CGFloat = parent.lineHeightMultiple let token = parent.highlightRefreshToken let text: String = { if let currentText = currentText { return currentText } if Thread.isMainThread { return textView?.string ?? "" } var result = "" DispatchQueue.main.sync { result = textView?.string ?? "" } return result }() if parent.isLargeFileMode { self.lastHighlightedText = text self.lastLanguage = lang self.lastColorScheme = scheme self.lastLineHeight = lineHeightValue self.lastHighlightToken = token return } // Skip expensive highlighting for very large documents let nsLen = (text as NSString).length if nsLen > 200_000 { // ~200k UTF-16 code units self.lastHighlightedText = text self.lastLanguage = lang self.lastColorScheme = scheme return } if text == lastHighlightedText && lastLanguage == lang && lastColorScheme == scheme && lastLineHeight == lineHeightValue && lastHighlightToken == token { return } rehighlight(token: token) } /// Perform regex-based token coloring off-main, then apply attributes on the main thread. func rehighlight(token: Int) { guard let textView = textView else { return } // Snapshot current state let textSnapshot = textView.string let language = parent.language let scheme = parent.colorScheme let lineHeightValue: CGFloat = parent.lineHeightMultiple let selected = textView.selectedRange() let colors = SyntaxColors.fromVibrantLightTheme(colorScheme: scheme) let patterns = getSyntaxPatterns(for: language, colors: colors) // Cancel any in-flight work pendingHighlight?.cancel() let work = DispatchWorkItem { [weak self] in // Compute matches off the main thread let nsText = textSnapshot as NSString let fullRange = NSRange(location: 0, length: nsText.length) var coloredRanges: [(NSRange, Color)] = [] for (pattern, color) in patterns { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { continue } let matches = regex.matches(in: textSnapshot, range: fullRange) for match in matches { coloredRanges.append((match.range, color)) } } DispatchQueue.main.async { [weak self] in guard let self = self, let tv = self.textView else { return } // Discard if text changed since we started guard tv.string == textSnapshot else { return } tv.textStorage?.beginEditing() // Clear previous coloring and apply base color tv.textStorage?.removeAttribute(.foregroundColor, range: fullRange) tv.textStorage?.addAttribute(.foregroundColor, value: tv.textColor ?? NSColor.labelColor, range: fullRange) // Apply colored ranges for (range, color) in coloredRanges { tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range) } tv.textStorage?.endEditing() self.parent.applyInvisibleCharacterPreference(tv) // Restore selection only if it hasn't changed since we started if NSEqualRanges(tv.selectedRange(), selected) { tv.setSelectedRange(selected) } // Update last highlighted state self.lastHighlightedText = textSnapshot self.lastLanguage = language self.lastColorScheme = scheme self.lastLineHeight = lineHeightValue self.lastHighlightToken = token // Re-apply visibility preference after recoloring. self.parent.applyInvisibleCharacterPreference(tv) } } pendingHighlight = work // Debounce slightly to avoid thrashing while typing highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work) } func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } if let accepting = textView as? AcceptingTextView, accepting.isApplyingDroppedContent { // Drop-import chunking mutates storage many times; defer expensive binding/highlight work // until the final didChangeText emitted after import completion. return } let sanitized = AcceptingTextView.sanitizePlainText(textView.string) if sanitized != textView.string { textView.string = sanitized } let normalizedStyle = NSMutableParagraphStyle() normalizedStyle.lineHeightMultiple = max(0.9, parent.lineHeightMultiple) textView.defaultParagraphStyle = normalizedStyle textView.typingAttributes[.paragraphStyle] = normalizedStyle if let storage = textView.textStorage { let len = storage.length if len <= 200_000 { storage.beginEditing() storage.addAttribute(.paragraphStyle, value: normalizedStyle, range: NSRange(location: 0, length: len)) storage.endEditing() } } if sanitized != parent.text { parent.text = sanitized parent.applyInvisibleCharacterPreference(textView) } if let accepting = textView as? AcceptingTextView, accepting.isApplyingPaste { parent.applyInvisibleCharacterPreference(textView) let snapshot = textView.string highlightQueue.asyncAfter(deadline: .now() + 0.2) { [weak self] in DispatchQueue.main.async { self?.parent.text = snapshot self?.scheduleHighlightIfNeeded(currentText: snapshot) } } return } parent.applyInvisibleCharacterPreference(textView) // Update SwiftUI binding, caret status, and rehighlight. parent.text = textView.string updateCaretStatusAndHighlight() scheduleHighlightIfNeeded(currentText: parent.text) } func textViewDidChangeSelection(_ notification: Notification) { if let tv = notification.object as? AcceptingTextView { tv.clearInlineSuggestion() } updateCaretStatusAndHighlight() } // Compute (line, column), broadcast, and highlight the current line. private func updateCaretStatusAndHighlight() { guard let tv = textView else { return } let ns = tv.string as NSString let sel = tv.selectedRange() let location = sel.location if parent.isLargeFileMode || ns.length > 300_000 { NotificationCenter.default.post( name: .caretPositionDidChange, object: nil, userInfo: ["line": 0, "column": location] ) return } let prefix = ns.substring(to: min(location, ns.length)) let line = prefix.reduce(1) { $1 == "\n" ? $0 + 1 : $0 } let col: Int = { if let lastNL = prefix.lastIndex(of: "\n") { return prefix.distance(from: lastNL, to: prefix.endIndex) - 1 } else { return prefix.count } }() NotificationCenter.default.post(name: .caretPositionDidChange, object: nil, userInfo: ["line": line, "column": col]) // Highlight current line let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) let fullRange = NSRange(location: 0, length: ns.length) tv.textStorage?.beginEditing() tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange) if parent.highlightCurrentLine { tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange) } tv.textStorage?.endEditing() } /// Move caret to a 1-based line number, clamping to bounds, and emphasize the line. @objc func moveToLine(_ notification: Notification) { guard let lineOneBased = notification.object as? Int, let textView = textView else { return } // If there's no text, nothing to do let currentText = textView.string guard !currentText.isEmpty else { return } // Cancel any in-flight highlight to prevent it from restoring an old selection pendingHighlight?.cancel() // Work with NSString/UTF-16 indices to match NSTextView expectations let ns = currentText as NSString let totalLength = ns.length // Clamp target line to available line count (1-based input) let linesArray = currentText.components(separatedBy: .newlines) let clampedLineIndex = max(1, min(lineOneBased, linesArray.count)) - 1 // 0-based index // Compute the UTF-16 location by summing UTF-16 lengths of preceding lines + newline characters var location = 0 if clampedLineIndex > 0 { for i in 0..<(clampedLineIndex) { let lineNSString = linesArray[i] as NSString location += lineNSString.length // Add one for the newline that separates lines, as components(separatedBy:) drops separators location += 1 } } // Safety clamp location = max(0, min(location, totalLength)) // Move caret and scroll into view on the main thread DispatchQueue.main.async { [weak self] in guard let self = self, let tv = self.textView else { return } tv.window?.makeFirstResponder(tv) // Ensure layout is up-to-date before scrolling if let textContainer = tv.textContainer { tv.layoutManager?.ensureLayout(for: textContainer) } tv.setSelectedRange(NSRange(location: location, length: 0)) tv.scrollRangeToVisible(NSRange(location: location, length: 0)) // Stronger highlight for the entire target line let fullRange = NSRange(location: 0, length: totalLength) tv.textStorage?.beginEditing() tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange) if self.parent.highlightCurrentLine { let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.18), range: lineRange) } tv.textStorage?.endEditing() } } } } #else import UIKit final class LineNumberedTextViewContainer: UIView { let lineNumberView = UITextView() let textView = UITextView() override init(frame: CGRect) { super.init(frame: frame) lineNumberView.translatesAutoresizingMaskIntoConstraints = false textView.translatesAutoresizingMaskIntoConstraints = false lineNumberView.isEditable = false lineNumberView.isSelectable = false lineNumberView.isScrollEnabled = true lineNumberView.isUserInteractionEnabled = false lineNumberView.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.65) lineNumberView.textColor = .secondaryLabel lineNumberView.textAlignment = .right lineNumberView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 6) lineNumberView.textContainer.lineFragmentPadding = 0 textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) let divider = UIView() divider.translatesAutoresizingMaskIntoConstraints = false divider.backgroundColor = UIColor.separator.withAlphaComponent(0.6) addSubview(lineNumberView) addSubview(divider) addSubview(textView) NSLayoutConstraint.activate([ lineNumberView.leadingAnchor.constraint(equalTo: leadingAnchor), lineNumberView.topAnchor.constraint(equalTo: topAnchor), lineNumberView.bottomAnchor.constraint(equalTo: bottomAnchor), lineNumberView.widthAnchor.constraint(equalToConstant: 46), divider.leadingAnchor.constraint(equalTo: lineNumberView.trailingAnchor), divider.topAnchor.constraint(equalTo: topAnchor), divider.bottomAnchor.constraint(equalTo: bottomAnchor), divider.widthAnchor.constraint(equalToConstant: 1), textView.leadingAnchor.constraint(equalTo: divider.trailingAnchor), textView.trailingAnchor.constraint(equalTo: trailingAnchor), textView.topAnchor.constraint(equalTo: topAnchor), textView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func updateLineNumbers(for text: String, fontSize: CGFloat) { let lineCount = max(1, text.components(separatedBy: .newlines).count) let numbers = (1...lineCount).map(String.init).joined(separator: "\n") lineNumberView.font = UIFont.monospacedDigitSystemFont(ofSize: max(11, fontSize - 1), weight: .regular) lineNumberView.text = numbers } } struct CustomTextEditor: UIViewRepresentable { @Binding var text: String let language: String let colorScheme: ColorScheme let fontSize: CGFloat @Binding var isLineWrapEnabled: Bool let isLargeFileMode: Bool let translucentBackgroundEnabled: Bool let showLineNumbers: Bool let showInvisibleCharacters: Bool let highlightCurrentLine: Bool let indentStyle: String let indentWidth: Int let autoIndentEnabled: Bool let autoCloseBracketsEnabled: Bool let highlightRefreshToken: Int private var fontName: String { UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? "" } private var lineHeightMultiple: CGFloat { let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight") return CGFloat(stored > 0 ? stored : 1.0) } func makeUIView(context: Context) -> LineNumberedTextViewContainer { let container = LineNumberedTextViewContainer() let textView = container.textView textView.delegate = context.coordinator if let named = UIFont(name: fontName, size: fontSize) { textView.font = named } else { textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) } let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple) textView.typingAttributes[.paragraphStyle] = paragraphStyle textView.text = text if text.count <= 200_000 { textView.textStorage.beginEditing() textView.textStorage.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: textView.textStorage.length)) textView.textStorage.endEditing() } textView.autocorrectionType = .no textView.autocapitalizationType = .none textView.smartDashesType = .no textView.smartQuotesType = .no textView.smartInsertDeleteType = .no textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode if isLargeFileMode { container.lineNumberView.isHidden = true } else { container.lineNumberView.isHidden = false container.updateLineNumbers(for: text, fontSize: fontSize) } context.coordinator.container = container context.coordinator.textView = textView context.coordinator.scheduleHighlightIfNeeded(currentText: text) return container } func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) { let textView = uiView.textView context.coordinator.parent = self if textView.text != text { textView.text = text } if textView.font?.pointSize != fontSize { textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) } let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple) textView.typingAttributes[.paragraphStyle] = paragraphStyle if context.coordinator.lastLineHeight != lineHeightMultiple { let len = textView.textStorage.length if len > 0 && len <= 200_000 { textView.textStorage.beginEditing() textView.textStorage.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: len)) textView.textStorage.endEditing() } context.coordinator.lastLineHeight = lineHeightMultiple } let theme = currentEditorTheme(colorScheme: colorScheme) textView.textColor = UIColor(theme.text) textView.tintColor = UIColor(theme.cursor) textView.backgroundColor = translucentBackgroundEnabled ? .clear : UIColor(theme.background) textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode if isLargeFileMode { uiView.lineNumberView.isHidden = true } else { uiView.lineNumberView.isHidden = false uiView.updateLineNumbers(for: text, fontSize: fontSize) } context.coordinator.syncLineNumberScroll() context.coordinator.scheduleHighlightIfNeeded(currentText: text) } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UITextViewDelegate { var parent: CustomTextEditor weak var container: LineNumberedTextViewContainer? weak var textView: UITextView? private let highlightQueue = DispatchQueue(label: "NeonVision.iOS.SyntaxHighlight", qos: .userInitiated) private var pendingHighlight: DispatchWorkItem? private var lastHighlightedText: String = "" private var lastLanguage: String? private var lastColorScheme: ColorScheme? var lastLineHeight: CGFloat? private var lastHighlightToken: Int = 0 private var isApplyingHighlight = false init(_ parent: CustomTextEditor) { self.parent = parent } func scheduleHighlightIfNeeded(currentText: String? = nil) { guard let textView else { return } let text = currentText ?? textView.text ?? "" let lang = parent.language let scheme = parent.colorScheme let lineHeight = parent.lineHeightMultiple let token = parent.highlightRefreshToken if parent.isLargeFileMode { lastHighlightedText = text lastLanguage = lang lastColorScheme = scheme lastLineHeight = lineHeight lastHighlightToken = token return } if text == lastHighlightedText && lang == lastLanguage && scheme == lastColorScheme && lineHeight == lastLineHeight && lastHighlightToken == token { return } pendingHighlight?.cancel() let work = DispatchWorkItem { [weak self] in self?.rehighlight(text: text, language: lang, colorScheme: scheme, token: token) } pendingHighlight = work highlightQueue.asyncAfter(deadline: .now() + 0.1, execute: work) } private func rehighlight(text: String, language: String, colorScheme: ColorScheme, token: Int) { let nsText = text as NSString let fullRange = NSRange(location: 0, length: nsText.length) let baseColor: UIColor = colorScheme == .dark ? .white : .label let baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular) let attributed = NSMutableAttributedString( string: text, attributes: [ .foregroundColor: baseColor, .font: baseFont ] ) let colors = SyntaxColors.fromVibrantLightTheme(colorScheme: colorScheme) let patterns = getSyntaxPatterns(for: language, colors: colors) for (pattern, color) in patterns { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { continue } let matches = regex.matches(in: text, range: fullRange) let uiColor = UIColor(color) for match in matches { attributed.addAttribute(.foregroundColor, value: uiColor, range: match.range) } } DispatchQueue.main.async { [weak self] in guard let self, let textView = self.textView else { return } guard textView.text == text else { return } let selectedRange = textView.selectedRange self.isApplyingHighlight = true textView.attributedText = attributed if self.parent.highlightCurrentLine { let ns = text as NSString let lineRange = ns.lineRange(for: selectedRange) textView.textStorage.addAttribute(.backgroundColor, value: UIColor.secondarySystemFill, range: lineRange) } textView.selectedRange = selectedRange textView.typingAttributes = [ .foregroundColor: baseColor, .font: baseFont ] self.isApplyingHighlight = false self.lastHighlightedText = text self.lastLanguage = language self.lastColorScheme = colorScheme self.lastLineHeight = self.parent.lineHeightMultiple self.lastHighlightToken = token self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize) self.syncLineNumberScroll() } } func textViewDidChange(_ textView: UITextView) { guard !isApplyingHighlight else { return } parent.text = textView.text container?.updateLineNumbers(for: textView.text, fontSize: parent.fontSize) scheduleHighlightIfNeeded(currentText: textView.text) } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if text == "\n", parent.autoIndentEnabled { let ns = textView.text as NSString let lineRange = ns.lineRange(for: NSRange(location: range.location, length: 0)) let currentLine = ns.substring(with: NSRange( location: lineRange.location, length: max(0, range.location - lineRange.location) )) let indent = currentLine.prefix { $0 == " " || $0 == "\t" } let normalized = normalizedIndentation(String(indent)) let replacement = "\n" + normalized textView.textStorage.replaceCharacters(in: range, with: replacement) textView.selectedRange = NSRange(location: range.location + replacement.count, length: 0) textViewDidChange(textView) return false } if parent.autoCloseBracketsEnabled, text.count == 1 { let pairs: [String: String] = ["(": ")", "[": "]", "{": "}", "\"": "\"", "'": "'"] if let closing = pairs[text] { let insertion = text + closing textView.textStorage.replaceCharacters(in: range, with: insertion) textView.selectedRange = NSRange(location: range.location + 1, length: 0) textViewDidChange(textView) return false } } return true } private func normalizedIndentation(_ indent: String) -> String { let width = max(1, parent.indentWidth) switch parent.indentStyle { case "tabs": let spacesCount = indent.filter { $0 == " " }.count let tabsCount = indent.filter { $0 == "\t" }.count let totalSpaces = spacesCount + (tabsCount * width) let tabs = String(repeating: "\t", count: totalSpaces / width) let leftover = String(repeating: " ", count: totalSpaces % width) return tabs + leftover default: let tabsCount = indent.filter { $0 == "\t" }.count let spacesCount = indent.filter { $0 == " " }.count let totalSpaces = spacesCount + (tabsCount * width) return String(repeating: " ", count: totalSpaces) } } func scrollViewDidScroll(_ scrollView: UIScrollView) { syncLineNumberScroll() } func syncLineNumberScroll() { guard let textView, let lineView = container?.lineNumberView else { return } lineView.contentOffset = CGPoint(x: 0, y: textView.contentOffset.y) } } } #endif