diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 74d7d31..cef3ba7 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 309; + CURRENT_PROJECT_VERSION = 310; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -439,7 +439,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 309; + CURRENT_PROJECT_VERSION = 310; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/Core/SyntaxHighlighting.swift b/Neon Vision Editor/Core/SyntaxHighlighting.swift index aa7c7cd..066466f 100644 --- a/Neon Vision Editor/Core/SyntaxHighlighting.swift +++ b/Neon Vision Editor/Core/SyntaxHighlighting.swift @@ -74,6 +74,7 @@ struct SyntaxColors { enum SyntaxPatternProfile { case full case htmlFast + case csvFast } // Regex patterns per language mapped to colors. Keep light-weight for performance. @@ -368,6 +369,14 @@ func getSyntaxPatterns( #"(?m)#.*$"#: colors.comment ] case "csv": + if profile == .csvFast { + return [ + // Fast CSV profile for large datasets: keep only separators/headers/quoted chunks. + #"(?m)^[^\n,]+(,\s*[^\n,]+)*$"#: colors.meta, + #"\"([^\"\n]|\"\")*\""#: colors.string, + #","#: colors.property + ] + } return [ #"\A([^\n,]+)(,\s*[^\n,]+)*"#: colors.meta, #"\"([^\"\n]|\"\")*\""#: colors.string, diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 32fc74e..76852e2 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -11,7 +11,7 @@ import UIKit // Normalizes pasted and loaded text before it reaches editor state. enum EditorTextSanitizer { // Converts control/marker glyphs into safe spaces/newlines and removes unsupported scalars. - static func sanitize(_ input: String) -> String { + nonisolated static func sanitize(_ input: String) -> String { // Normalize line endings first so CRLF does not become double newlines. let normalized = input .replacingOccurrences(of: "\r\n", with: "\n") @@ -53,6 +53,29 @@ enum EditorTextSanitizer { } } +private enum EditorLoadHelper { + nonisolated static let fastLoadSanitizeByteThreshold = 2_000_000 + nonisolated static let largeFileCandidateByteThreshold = 2_000_000 + nonisolated static let skipFingerprintByteThreshold = 4_000_000 + nonisolated static let stagedAttachByteThreshold = 1_500_000 + nonisolated static let stagedFirstChunkUTF16Length = 180_000 + + nonisolated static func sanitizeTextForFileLoad(_ input: String, useFastPath: Bool) -> String { + if useFastPath { + // Fast path for large files: preserve visible content, normalize line endings, + // and only strip NUL which frequently breaks text system behavior. + if !input.contains("\0") && !input.contains("\r") { + return input + } + return input + .replacingOccurrences(of: "\0", with: "") + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + return EditorTextSanitizer.sanitize(input) + } +} + ///MARK: - Tab Model // Represents one editor tab and its mutable editing state. struct TabData: Identifiable { @@ -64,6 +87,8 @@ struct TabData: Identifiable { var languageLocked: Bool = false var isDirty: Bool = false var lastSavedFingerprint: UInt64? + var isLoadingContent: Bool = false + var isLargeFileCandidate: Bool = false } ///MARK: - Editor View Model @@ -171,6 +196,12 @@ class EditorViewModel: ObservableObject { // Updates tab text and applies language detection/locking heuristics. func updateTabContent(tab: TabData, content: String) { if let index = tabs.firstIndex(where: { $0.id == tab.id }) { + if tabs[index].isLoadingContent { + // During staged file load, content updates are system-driven; do not mark dirty + // and do not run language detection on partial content. + tabs[index].content = content + return + } let previous = tabs[index].content tabs[index].content = content if content != previous { @@ -408,22 +439,60 @@ class EditorViewModel: ObservableObject { // Loads a file into a new tab unless the file is already open. func openFile(url: URL) { if focusTabIfOpen(for: url) { return } - do { - let raw = try String(contentsOf: url, encoding: .utf8) - let content = sanitizeTextForEditor(raw) - let extLang = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()] - let detectedLang = extLang ?? LanguageDetector.shared.detect(text: content, name: url.lastPathComponent, fileURL: url).lang - let newTab = TabData(name: url.lastPathComponent, - content: content, - language: detectedLang, - fileURL: url, - languageLocked: extLang != nil, - isDirty: false, - lastSavedFingerprint: contentFingerprint(content)) - tabs.append(newTab) - selectedTabID = newTab.id - } catch { - debugLog("Failed to open file.") + let extLangHint = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()] + let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + let isLargeCandidate = fileSize >= EditorLoadHelper.largeFileCandidateByteThreshold + let placeholderTab = TabData( + name: url.lastPathComponent, + content: "", + language: extLangHint ?? "plain", + fileURL: url, + languageLocked: extLangHint != nil, + isDirty: false, + lastSavedFingerprint: nil, + isLoadingContent: true, + isLargeFileCandidate: isLargeCandidate + ) + tabs.append(placeholderTab) + selectedTabID = placeholderTab.id + + let tabID = placeholderTab.id + Task.detached(priority: .userInitiated) { [url, extLangHint, tabID] in + do { + let data = try Data(contentsOf: url, options: [.mappedIfSafe]) + let raw = String(decoding: data, as: UTF8.self) + let content = EditorLoadHelper.sanitizeTextForFileLoad( + raw, + useFastPath: data.count >= EditorLoadHelper.fastLoadSanitizeByteThreshold + ) + let detectedLang: String + if let extLangHint { + detectedLang = extLangHint + } else { + detectedLang = "plain" + } + let fingerprint: UInt64? = data.count >= EditorLoadHelper.skipFingerprintByteThreshold + ? nil + : Self.contentFingerprintValue(content) + let useStagedAttach = data.count >= EditorLoadHelper.stagedAttachByteThreshold + + await self.applyLoadedContent( + tabID: tabID, + content: content, + language: detectedLang, + languageLocked: extLangHint != nil, + fingerprint: fingerprint, + isLargeCandidate: data.count >= EditorLoadHelper.largeFileCandidateByteThreshold, + useStagedAttach: useStagedAttach + ) + } catch { + await MainActor.run { + if let index = self.tabs.firstIndex(where: { $0.id == tabID }) { + self.tabs[index].isLoadingContent = false + } + self.debugLog("Failed to open file.") + } + } } } @@ -431,13 +500,58 @@ class EditorViewModel: ObservableObject { EditorTextSanitizer.sanitize(input) } - private func contentFingerprint(_ text: String) -> UInt64 { + private nonisolated static func contentFingerprintValue(_ text: String) -> UInt64 { var hasher = Hasher() hasher.combine(text) let value = hasher.finalize() return UInt64(bitPattern: Int64(value)) } + private func applyLoadedContent( + tabID: UUID, + content: String, + language: String, + languageLocked: Bool, + fingerprint: UInt64?, + isLargeCandidate: Bool, + useStagedAttach: Bool + ) async { + guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return } + + tabs[index].language = language + tabs[index].languageLocked = languageLocked + tabs[index].isDirty = false + tabs[index].lastSavedFingerprint = fingerprint + tabs[index].isLargeFileCandidate = isLargeCandidate + + guard useStagedAttach else { + tabs[index].content = content + tabs[index].isLoadingContent = false + return + } + + let nsContent = content as NSString + let firstLength = min(EditorLoadHelper.stagedFirstChunkUTF16Length, nsContent.length) + let firstChunk = nsContent.substring(with: NSRange(location: 0, length: firstLength)) + tabs[index].content = firstChunk + + // Yield one runloop turn to present the tab quickly before attaching full content. + await Task.yield() + try? await Task.sleep(nanoseconds: 45_000_000) + + guard let refreshedIndex = tabs.firstIndex(where: { $0.id == tabID }) else { return } + if tabs[refreshedIndex].isDirty { + tabs[refreshedIndex].isLoadingContent = false + return + } + tabs[refreshedIndex].content = content + tabs[refreshedIndex].isLoadingContent = false + } + + private func contentFingerprint(_ text: String) -> UInt64 { + Self.contentFingerprintValue(text) + } + func hasOpenFile(url: URL) -> Bool { indexOfOpenTab(for: url) != nil diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 2ceb388..1c40084 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -33,7 +33,10 @@ extension String { struct ContentView: View { private enum EditorPerformanceThresholds { static let largeFileBytes = 12_000_000 + static let largeFileBytesHTMLCSV = 4_000_000 static let heavyFeatureUTF16Length = 450_000 + static let largeFileLineBreaks = 40_000 + static let largeFileLineBreaksHTMLCSV = 15_000 } private static let completionSignposter = OSSignposter(subsystem: "h3p.Neon-Vision-Editor", category: "InlineCompletion") @@ -1134,7 +1137,13 @@ struct ContentView: View { } } .onChange(of: viewModel.selectedTab?.id) { _, _ in - updateLargeFileMode(for: currentContentBinding.wrappedValue) + if viewModel.selectedTab?.isLargeFileCandidate == true { + if !largeFileModeEnabled { + largeFileModeEnabled = true + } + } else { + updateLargeFileMode(for: currentContentBinding.wrappedValue) + } scheduleHighlightRefresh() } .onChange(of: currentLanguage) { _, newValue in @@ -1204,10 +1213,45 @@ struct ContentView: View { } func updateLargeFileMode(for text: String) { + if viewModel.selectedTab?.isLargeFileCandidate == true { + if !largeFileModeEnabled { + largeFileModeEnabled = true + scheduleHighlightRefresh() + } + return + } + let lowerLanguage = currentLanguage.lowercased() + let isHTMLLike = ["html", "htm", "xml", "svg", "xhtml"].contains(lowerLanguage) + let isCSVLike = ["csv", "tsv"].contains(lowerLanguage) + let useAggressiveThresholds = isHTMLLike || isCSVLike + let byteThreshold = useAggressiveThresholds + ? EditorPerformanceThresholds.largeFileBytesHTMLCSV + : EditorPerformanceThresholds.largeFileBytes + let lineThreshold = useAggressiveThresholds + ? EditorPerformanceThresholds.largeFileLineBreaksHTMLCSV + : EditorPerformanceThresholds.largeFileLineBreaks + let byteCount = text.utf8.count + let exceedsByteThreshold = byteCount >= byteThreshold + let exceedsLineThreshold: Bool = { + if exceedsByteThreshold { return true } + var lineBreaks = 0 + for codeUnit in text.utf16 { + if codeUnit == 10 { // '\n' + lineBreaks += 1 + if lineBreaks >= lineThreshold { + return true + } + } + } + return false + }() #if os(iOS) - let isLarge = forceLargeFileMode || text.utf8.count >= EditorPerformanceThresholds.largeFileBytes + let isLarge = forceLargeFileMode + || exceedsByteThreshold + || exceedsLineThreshold #else - let isLarge = text.utf8.count >= EditorPerformanceThresholds.largeFileBytes + let isLarge = exceedsByteThreshold + || exceedsLineThreshold #endif if largeFileModeEnabled != isLarge { largeFileModeEnabled = isLarge diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index c184db0..2e1ce27 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -8,6 +8,12 @@ private enum EditorRuntimeLimits { // Above this, keep editing responsive by skipping regex-heavy syntax passes. static let syntaxMinimalUTF16Length = 1_200_000 static let htmlFastProfileUTF16Length = 250_000 + static let csvFastProfileUTF16Length = 180_000 + static let scopeComputationMaxUTF16Length = 300_000 + static let cursorRehighlightMaxUTF16Length = 220_000 + static let nonImmediateHighlightMaxUTF16Length = 220_000 + static let bindingDebounceUTF16Length = 250_000 + static let bindingDebounceDelay: TimeInterval = 0.18 } private enum EmmetExpander { @@ -1931,6 +1937,7 @@ struct CustomTextEditor: NSViewRepresentable { private var isApplyingHighlight = false private var highlightGeneration: Int = 0 private var pendingEditedRange: NSRange? + private var pendingBindingSync: DispatchWorkItem? var lastAppliedWrapMode: Bool? init(_ parent: CustomTextEditor) { @@ -1953,6 +1960,19 @@ struct CustomTextEditor: NSViewRepresentable { lastTranslucencyEnabled = nil } + private func syncBindingText(_ text: String, immediate: Bool = false) { + pendingBindingSync?.cancel() + if immediate || (text as NSString).length < EditorRuntimeLimits.bindingDebounceUTF16Length { + parent.text = text + return + } + let work = DispatchWorkItem { [weak self] in + self?.parent.text = text + } + pendingBindingSync = work + DispatchQueue.main.asyncAfter(deadline: .now() + EditorRuntimeLimits.bindingDebounceDelay, execute: work) + } + func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) { guard textView != nil else { return } @@ -2106,6 +2126,9 @@ struct CustomTextEditor: NSViewRepresentable { if lower == "html" && nsText.length >= EditorRuntimeLimits.htmlFastProfileUTF16Length { return .htmlFast } + if lower == "csv" && nsText.length >= EditorRuntimeLimits.csvFastProfileUTF16Length { + return .csvFast + } return .full }() let colors = SyntaxColors( @@ -2175,7 +2198,8 @@ struct CustomTextEditor: NSViewRepresentable { let wantsBracketTokens = self.parent.highlightMatchingBrackets let wantsScopeBackground = self.parent.highlightScopeBackground let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift" - let needsScopeComputation = wantsBracketTokens || wantsScopeBackground || wantsScopeGuides + let needsScopeComputation = (wantsBracketTokens || wantsScopeBackground || wantsScopeGuides) + && nsText.length < EditorRuntimeLimits.scopeComputationMaxUTF16Length let bracketMatch = needsScopeComputation ? computeBracketScopeMatch(text: textSnapshot, caretLocation: selectedLocation) : nil let indentationMatch: IndentationScopeMatch? = { guard needsScopeComputation, supportsIndentationScopes(language: self.parent.language) else { return nil } @@ -2285,16 +2309,14 @@ struct CustomTextEditor: NSViewRepresentable { storage.endEditing() } } - if sanitized != parent.text { - parent.text = sanitized - parent.applyInvisibleCharacterPreference(textView) - } + syncBindingText(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?.syncBindingText(snapshot, immediate: true) self?.scheduleHighlightIfNeeded(currentText: snapshot) } } @@ -2302,7 +2324,6 @@ struct CustomTextEditor: NSViewRepresentable { } parent.applyInvisibleCharacterPreference(textView) // Update SwiftUI binding, caret status, and rehighlight. - parent.text = textView.string let nsText = textView.string as NSString let caretLocation = min(nsText.length, textView.selectedRange().location) pendingEditedRange = nsText.lineRange(for: NSRange(location: caretLocation, length: 0)) @@ -2782,6 +2803,7 @@ struct CustomTextEditor: UIViewRepresentable { textView.setBracketAccessoryVisible(showKeyboardAccessoryBar) textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode + textView.layoutManager.allowsNonContiguousLayout = true textView.keyboardDismissMode = .onDrag textView.typingAttributes[.foregroundColor] = baseColor if isLargeFileMode || !showLineNumbers { @@ -2804,6 +2826,7 @@ struct CustomTextEditor: UIViewRepresentable { weak var textView: EditorInputTextView? private let highlightQueue = DispatchQueue(label: "NeonVision.iOS.SyntaxHighlight", qos: .userInitiated) private var pendingHighlight: DispatchWorkItem? + private var pendingBindingSync: DispatchWorkItem? private var lastHighlightedText: String = "" private var lastLanguage: String? private var lastColorScheme: ColorScheme? @@ -2823,9 +2846,28 @@ struct CustomTextEditor: UIViewRepresentable { } deinit { + pendingBindingSync?.cancel() NotificationCenter.default.removeObserver(self) } + private func shouldRenderLineNumbers() -> Bool { + guard let lineView = container?.lineNumberView else { return false } + return parent.showLineNumbers && !parent.isLargeFileMode && !lineView.isHidden + } + + private func syncBindingText(_ text: String, immediate: Bool = false) { + pendingBindingSync?.cancel() + if immediate || (text as NSString).length < EditorRuntimeLimits.bindingDebounceUTF16Length { + parent.text = text + return + } + let work = DispatchWorkItem { [weak self] in + self?.parent.text = text + } + pendingBindingSync = work + DispatchQueue.main.asyncAfter(deadline: .now() + EditorRuntimeLimits.bindingDebounceDelay, execute: work) + } + @objc private func updateKeyboardAccessoryVisibility(_ notification: Notification) { guard let textView else { return } let isVisible: Bool @@ -2917,6 +2959,19 @@ struct CustomTextEditor: UIViewRepresentable { return } + let styleStateUnchanged = lang == lastLanguage && + scheme == lastColorScheme && + lineHeight == lastLineHeight && + lastHighlightToken == token && + lastTranslucencyEnabled == translucencyEnabled + let selectionOnlyChange = text == lastHighlightedText && + styleStateUnchanged && + lastSelectionLocation != selectionLocation + if selectionOnlyChange && textLength >= EditorRuntimeLimits.cursorRehighlightMaxUTF16Length { + lastSelectionLocation = selectionLocation + return + } + pendingHighlight?.cancel() highlightGeneration &+= 1 let generation = highlightGeneration @@ -2932,7 +2987,8 @@ struct CustomTextEditor: UIViewRepresentable { ) } pendingHighlight = work - if immediate || lastHighlightedText.isEmpty || lastHighlightToken != token { + let allowImmediate = textLength < EditorRuntimeLimits.nonImmediateHighlightMaxUTF16Length + if (immediate || lastHighlightedText.isEmpty || lastHighlightToken != token) && allowImmediate { highlightQueue.async(execute: work) } else { let delay: TimeInterval @@ -2962,9 +3018,8 @@ struct CustomTextEditor: UIViewRepresentable { immediate: Bool ) -> NSRange { let fullRange = NSRange(location: 0, length: text.length) - // iOS rehighlight builds attributed text for the whole buffer; for very large files - // keep syntax matching focused on visible content while typing. - guard !immediate, text.length >= 100_000 else { return fullRange } + // Keep syntax matching focused on visible content for very large buffers. + guard text.length >= 100_000 else { return fullRange } let visibleRect = CGRect(origin: textView.contentOffset, size: textView.bounds.size).insetBy(dx: 0, dy: -80) let glyphRange = textView.layoutManager.glyphRange(forBoundingRect: visibleRect, in: textView.textContainer) let charRange = textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) @@ -2995,14 +3050,6 @@ struct CustomTextEditor: UIViewRepresentable { baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular) } - let attributed = NSMutableAttributedString( - string: text, - attributes: [ - .foregroundColor: baseColor, - .font: baseFont - ] - ) - let colors = SyntaxColors( keyword: theme.syntax.keyword, string: theme.syntax.string, @@ -3023,16 +3070,21 @@ struct CustomTextEditor: UIViewRepresentable { if lower == "html" && nsText.length >= EditorRuntimeLimits.htmlFastProfileUTF16Length { return .htmlFast } + if lower == "csv" && nsText.length >= EditorRuntimeLimits.csvFastProfileUTF16Length { + return .csvFast + } return .full }() let patterns = getSyntaxPatterns(for: language, colors: colors, profile: syntaxProfile) + var coloredRanges: [(NSRange, UIColor)] = [] for (pattern, color) in patterns { guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } let matches = regex.matches(in: text, range: applyRange) let uiColor = UIColor(color) for match in matches { - attributed.addAttribute(.foregroundColor, value: uiColor, range: match.range) + guard isValidRange(match.range, utf16Length: fullRange.length) else { continue } + coloredRanges.append((match.range, uiColor)) } } @@ -3044,11 +3096,20 @@ struct CustomTextEditor: UIViewRepresentable { let priorOffset = textView.contentOffset let wasFirstResponder = textView.isFirstResponder self.isApplyingHighlight = true - textView.attributedText = attributed + textView.textStorage.beginEditing() + textView.textStorage.removeAttribute(.foregroundColor, range: applyRange) + textView.textStorage.removeAttribute(.backgroundColor, range: applyRange) + textView.textStorage.removeAttribute(.underlineStyle, range: applyRange) + textView.textStorage.addAttribute(.foregroundColor, value: baseColor, range: applyRange) + textView.textStorage.addAttribute(.font, value: baseFont, range: applyRange) + for (range, color) in coloredRanges { + textView.textStorage.addAttribute(.foregroundColor, value: color, range: range) + } let wantsBracketTokens = self.parent.highlightMatchingBrackets let wantsScopeBackground = self.parent.highlightScopeBackground let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift" - let needsScopeComputation = wantsBracketTokens || wantsScopeBackground || wantsScopeGuides + let needsScopeComputation = (wantsBracketTokens || wantsScopeBackground || wantsScopeGuides) + && nsText.length < EditorRuntimeLimits.scopeComputationMaxUTF16Length let bracketMatch = needsScopeComputation ? computeBracketScopeMatch(text: text, caretLocation: selectedRange.location) : nil let indentationMatch: IndentationScopeMatch? = { guard needsScopeComputation, supportsIndentationScopes(language: self.parent.language) else { return nil } @@ -3090,6 +3151,7 @@ struct CustomTextEditor: UIViewRepresentable { let lineRange = ns.lineRange(for: selectedRange) textView.textStorage.addAttribute(.backgroundColor, value: UIColor.secondarySystemFill, range: lineRange) } + textView.textStorage.endEditing() textView.selectedRange = selectedRange if wasFirstResponder { textView.setContentOffset(priorOffset, animated: false) @@ -3106,15 +3168,19 @@ struct CustomTextEditor: UIViewRepresentable { self.lastHighlightToken = token self.lastSelectionLocation = selectedRange.location self.lastTranslucencyEnabled = self.parent.translucentBackgroundEnabled - self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize) - self.syncLineNumberScroll() + if self.shouldRenderLineNumbers() { + 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) + syncBindingText(textView.text) + if shouldRenderLineNumbers() { + container?.updateLineNumbers(for: textView.text, fontSize: parent.fontSize) + } scheduleHighlightIfNeeded(currentText: textView.text) } @@ -3203,7 +3269,7 @@ struct CustomTextEditor: UIViewRepresentable { } func syncLineNumberScroll() { - guard let textView, let lineView = container?.lineNumberView else { return } + guard shouldRenderLineNumbers(), let textView, let lineView = container?.lineNumberView else { return } let targetY = textView.contentOffset.y + textView.adjustedContentInset.top - lineView.adjustedContentInset.top let minY = -lineView.adjustedContentInset.top let maxY = max(minY, lineView.contentSize.height - lineView.bounds.height + lineView.adjustedContentInset.bottom)