From 5e1b7ad885f37561a023478479be4c4c0e9a56df Mon Sep 17 00:00:00 2001 From: Feror Date: Fri, 29 May 2026 19:02:51 +0200 Subject: [PATCH] fix(editor): prevent recursive selection updates --- .../SaplingEditor/HybridMarkdownEditor.swift | 142 +++++++++++++----- 1 file changed, 107 insertions(+), 35 deletions(-) diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 2f5c7f3..a610690 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -37,10 +37,12 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi } public func updateSource(_ source: String) { + guard state.document.source != source else { return } state.updateSource(source) } public func updateSelection(_ selection: EditorSelection) { + guard state.selection != selection else { return } state.updateSelection(selection) } @@ -172,13 +174,15 @@ private struct NativeMarkdownTextView: NSViewRepresentable { guard let textView = scrollView.documentView as? NSTextView else { return } if textView.string != text { - textView.string = text + context.coordinator.performProgrammaticUpdate { + textView.string = text + } } let selectedRange = selection.range if textView.selectedRange() != selectedRange, selectedRange.location <= textView.string.utf16.count { - textView.setSelectedRange(selectedRange) + context.coordinator.setSelection(selectedRange, in: textView) } context.coordinator.applyHybridAttributes(to: textView) @@ -186,43 +190,79 @@ private struct NativeMarkdownTextView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { var parent: NativeMarkdownTextView - private var isApplyingAttributes = false + private var programmaticUpdateDepth = 0 + private var lastStyledText: String? + private var lastStyledActiveLineIndex: Int? init(_ parent: NativeMarkdownTextView) { self.parent = parent } func textDidChange(_ notification: Notification) { - guard !isApplyingAttributes, - let textView = notification.object as? NSTextView - else { return } + guard !isPerformingProgrammaticUpdate else { return } + guard let textView = notification.object as? NSTextView else { return } parent.text = textView.string parent.selection = EditorSelection(range: textView.selectedRange()) + lastStyledText = nil applyHybridAttributes(to: textView) } func textViewDidChangeSelection(_ notification: Notification) { + guard !isPerformingProgrammaticUpdate else { return } guard let textView = notification.object as? NSTextView else { return } - parent.selection = EditorSelection(range: textView.selectedRange()) + let newSelection = EditorSelection(range: textView.selectedRange()) + guard parent.selection != newSelection else { return } applyHybridAttributes(to: textView) + parent.selection = newSelection } func applyHybridAttributes(to textView: NSTextView) { guard let textStorage = textView.textStorage else { return } - isApplyingAttributes = true + guard shouldRestyle(textView.string) else { return } + let selectedRange = textView.selectedRange() - MarkdownTextStyler.apply( - to: textStorage, - activeLineIndex: parent.activeLineIndex, - backgroundColor: .textBackgroundColor, - activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), - textColor: .labelColor, - secondaryTextColor: .secondaryLabelColor, - accentColor: .controlAccentColor - ) - textView.setSelectedRange(selectedRange) - isApplyingAttributes = false + performProgrammaticUpdate { + MarkdownTextStyler.apply( + to: textStorage, + activeLineIndex: parent.activeLineIndex, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor + ) + if textView.selectedRange() != selectedRange, + selectedRange.location <= textView.string.utf16.count { + textView.setSelectedRange(selectedRange) + } + } + + lastStyledText = textView.string + lastStyledActiveLineIndex = parent.activeLineIndex + } + + func setSelection(_ range: NSRange, in textView: NSTextView) { + guard textView.selectedRange() != range else { return } + performProgrammaticUpdate { + textView.setSelectedRange(range) + } + } + + func performProgrammaticUpdate(_ updates: () -> Void) { + programmaticUpdateDepth += 1 + defer { + programmaticUpdateDepth -= 1 + } + updates() + } + + private var isPerformingProgrammaticUpdate: Bool { + programmaticUpdateDepth > 0 + } + + private func shouldRestyle(_ text: String) -> Bool { + lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex } } } @@ -254,45 +294,77 @@ private struct NativeMarkdownTextView: UIViewRepresentable { func updateUIView(_ textView: UITextView, context: Context) { context.coordinator.parent = self if textView.text != text { - textView.text = text + context.coordinator.performProgrammaticUpdate { + textView.text = text + } } context.coordinator.applyHybridAttributes(to: textView) } final class Coordinator: NSObject, UITextViewDelegate { var parent: NativeMarkdownTextView - private var isApplyingAttributes = false + private var programmaticUpdateDepth = 0 + private var lastStyledText: String? + private var lastStyledActiveLineIndex: Int? init(_ parent: NativeMarkdownTextView) { self.parent = parent } func textViewDidChange(_ textView: UITextView) { - guard !isApplyingAttributes else { return } + guard !isPerformingProgrammaticUpdate else { return } parent.text = textView.text parent.selection = EditorSelection(range: textView.selectedRange) + lastStyledText = nil applyHybridAttributes(to: textView) } func textViewDidChangeSelection(_ textView: UITextView) { - parent.selection = EditorSelection(range: textView.selectedRange) + guard !isPerformingProgrammaticUpdate else { return } + let newSelection = EditorSelection(range: textView.selectedRange) + guard parent.selection != newSelection else { return } applyHybridAttributes(to: textView) + parent.selection = newSelection } func applyHybridAttributes(to textView: UITextView) { - isApplyingAttributes = true + guard shouldRestyle(textView.text) else { return } + let selectedRange = textView.selectedRange - MarkdownTextStyler.apply( - to: textView.textStorage, - activeLineIndex: parent.activeLineIndex, - backgroundColor: .systemBackground, - activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10), - textColor: .label, - secondaryTextColor: .secondaryLabel, - accentColor: .systemBlue - ) - textView.selectedRange = selectedRange - isApplyingAttributes = false + performProgrammaticUpdate { + MarkdownTextStyler.apply( + to: textView.textStorage, + activeLineIndex: parent.activeLineIndex, + backgroundColor: .systemBackground, + activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10), + textColor: .label, + secondaryTextColor: .secondaryLabel, + accentColor: .systemBlue + ) + if textView.selectedRange != selectedRange, + selectedRange.location <= textView.text.utf16.count { + textView.selectedRange = selectedRange + } + } + + lastStyledText = textView.text + lastStyledActiveLineIndex = parent.activeLineIndex + } + + func performProgrammaticUpdate(_ updates: () -> Void) { + programmaticUpdateDepth += 1 + defer { + programmaticUpdateDepth -= 1 + } + updates() + } + + private var isPerformingProgrammaticUpdate: Bool { + programmaticUpdateDepth > 0 + } + + private func shouldRestyle(_ text: String) -> Bool { + lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex } } }