Skip to content

Commit

Permalink
UndoManager Fixes (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
thecoolwinter authored Mar 1, 2024
1 parent 6653c21 commit 86b9804
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 69 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// TextLayoutManager+Invalidation.swift
// CodeEditTextView
//
// Created by Khan Winter on 2/24/24.
//

import Foundation

extension TextLayoutManager {
/// Invalidates layout for the given rect.
/// - Parameter rect: The rect to invalidate.
public func invalidateLayoutForRect(_ rect: NSRect) {
for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
linePosition.data.setNeedsLayout()
}
layoutLines()
}

/// Invalidates layout for the given range of text.
/// - Parameter range: The range of text to invalidate.
public func invalidateLayoutForRange(_ range: NSRange) {
for linePosition in lineStorage.linesInRange(range) {
linePosition.data.setNeedsLayout()
}

layoutLines()
}

public func setNeedsLayout() {
needsLayout = true
visibleLineIds.removeAll(keepingCapacity: true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// TextLayoutManager+Transaction.swift
// CodeEditTextView
//
// Created by Khan Winter on 2/24/24.
//

import Foundation

extension TextLayoutManager {
/// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called.
/// Useful for grouping attribute modifications into one layout pass rather than laying out every update.
///
/// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction
/// group is ended.
///
/// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout
/// manager will never lay out text. If there is a end call without matching a start call an assertionFailure
/// will occur.
public func beginTransaction() {
transactionCounter += 1
}

/// Ends a transaction. When called, the layout manager will layout any necessary lines.
public func endTransaction(forceLayout: Bool = false) {
transactionCounter -= 1
if transactionCounter == 0 {
if forceLayout {
setNeedsLayout()
}
layoutLines()
} else if transactionCounter < 0 {
assertionFailure(
"TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call"
)
}
}
}
59 changes: 3 additions & 56 deletions Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ public class TextLayoutManager: NSObject {
var lineStorage: TextLineStorage<TextLine> = TextLineStorage()
var markedTextManager: MarkedTextManager = MarkedTextManager()
private let viewReuseQueue: ViewReuseQueue<LineFragmentView, UUID> = ViewReuseQueue()
private var visibleLineIds: Set<TextLine.ID> = []
package var visibleLineIds: Set<TextLine.ID> = []
/// Used to force a complete re-layout using `setNeedsLayout`
private var needsLayout: Bool = false
package var needsLayout: Bool = false

private var transactionCounter: Int = 0
package var transactionCounter: Int = 0
public var isInTransaction: Bool {
transactionCounter > 0
}
Expand Down Expand Up @@ -186,59 +186,6 @@ public class TextLayoutManager: NSObject {
/// ``TextLayoutManager/estimateLineHeight()`` is called.
private var _estimateLineHeight: CGFloat?

// MARK: - Invalidation

/// Invalidates layout for the given rect.
/// - Parameter rect: The rect to invalidate.
public func invalidateLayoutForRect(_ rect: NSRect) {
for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
linePosition.data.setNeedsLayout()
}
layoutLines()
}

/// Invalidates layout for the given range of text.
/// - Parameter range: The range of text to invalidate.
public func invalidateLayoutForRange(_ range: NSRange) {
for linePosition in lineStorage.linesInRange(range) {
linePosition.data.setNeedsLayout()
}

layoutLines()
}

public func setNeedsLayout() {
needsLayout = true
visibleLineIds.removeAll(keepingCapacity: true)
}

/// Begins a transaction, preventing the layout manager from performing layout until the `endTransaction` is called.
/// Useful for grouping attribute modifications into one layout pass rather than laying out every update.
///
/// You can nest transaction start/end calls, the layout manager will not cause layout until the last transaction
/// group is ended.
///
/// Ensure there is a balanced number of begin/end calls. If there is a missing endTranscaction call, the layout
/// manager will never lay out text. If there is a end call without matching a start call an assertionFailure
/// will occur.
public func beginTransaction() {
transactionCounter += 1
}

/// Ends a transaction. When called, the layout manager will layout any necessary lines.
public func endTransaction(forceLayout: Bool = false) {
transactionCounter -= 1
if transactionCounter == 0 {
if forceLayout {
setNeedsLayout()
}
layoutLines()
} else if transactionCounter < 0 {
// swiftlint:disable:next line_length
assertionFailure("TextLayoutManager.endTransaction called without a matching TextLayoutManager.beginTransaction call")
}
}

// MARK: - Layout

/// Lays out all visible lines
Expand Down
10 changes: 0 additions & 10 deletions Sources/CodeEditTextView/TextView/TextView+ReplaceCharacters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,6 @@ extension TextView {
layoutManager.beginTransaction()
textStorage.beginEditing()

var shouldEndGrouping = false
if !(_undoManager?.isGrouping ?? false) {
_undoManager?.beginGrouping()
shouldEndGrouping = true
}

// Can't insert an empty string into an empty range. One must be not empty
for range in ranges.sorted(by: { $0.location > $1.location }) where
(!range.isEmpty || !string.isEmpty) &&
Expand All @@ -46,10 +40,6 @@ extension TextView {
delegate?.textView(self, didReplaceContentsIn: range, with: string)
}

if shouldEndGrouping {
_undoManager?.endGrouping()
}

layoutManager.endTransaction()
textStorage.endEditing()
selectionManager.notifyAfterEdit()
Expand Down
12 changes: 9 additions & 3 deletions Sources/CodeEditTextView/Utils/CEUndoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public class CEUndoManager {
public class DelegatedUndoManager: UndoManager {
weak var parent: CEUndoManager?

public override var isUndoing: Bool { parent?.isUndoing ?? false }
public override var isRedoing: Bool { parent?.isRedoing ?? false }
public override var canUndo: Bool { parent?.canUndo ?? false }
public override var canRedo: Bool { parent?.canRedo ?? false }

Expand Down Expand Up @@ -97,9 +99,11 @@ public class CEUndoManager {
}
isUndoing = true
NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager)
textView.textStorage.beginEditing()
for mutation in item.mutations.reversed() {
textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string)
}
textView.textStorage.endEditing()
NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager)
redoStack.append(item)
isUndoing = false
Expand All @@ -112,9 +116,11 @@ public class CEUndoManager {
}
isRedoing = true
NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager)
textView.textStorage.beginEditing()
for mutation in item.mutations {
textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string)
}
textView.textStorage.endEditing()
NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager)
undoStack.append(item)
isRedoing = false
Expand Down Expand Up @@ -198,7 +204,7 @@ public class CEUndoManager {
// Deleting
return (
lastMutation.mutation.range.location == mutation.mutation.range.max
&& mutation.inverse.string != "\n"
&& LineEnding(line: lastMutation.inverse.string) == nil
)
} else {
// Inserting
Expand All @@ -207,14 +213,14 @@ public class CEUndoManager {
// If the last mutation was not whitespace, and the new one is, break the group.
if lastMutation.mutation.string.count < 1024
&& mutation.mutation.string.count < 1024
&& !lastMutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty
&& !lastMutation.mutation.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& mutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty {
return false
}

return (
lastMutation.mutation.range.max + 1 == mutation.mutation.range.location
&& mutation.mutation.string != "\n"
&& LineEnding(line: mutation.mutation.string) == nil
)
}
}
Expand Down

0 comments on commit 86b9804

Please sign in to comment.