Skip to content

Commit

Permalink
Change undo behaviour (#53)
Browse files Browse the repository at this point in the history
This pull request changes the behaviour of the undo functionality and
fixes some small issues in the current implementation:
* If you edited at the end of the current text (e.g. by typing at the
end of the text) and used undo, then the undo didn't work properly and
only the cursor moved, but the text was not removed again. This happened
because the undo range was calculated before the text was added,
therefore the undo range was always too small.
* If you pasted text that was longer than the end of the document and
used undo, then the text ended up in a wrong state and parts of the text
were repeated.
* An undo operation could happen at the wrong place in the text.

Unlike before, a newline will now break the typing coalescing which is
the same behaviour as in other text editors (e.g. Xcode or TextEdit).

Also, an undo operation will now restore the text selection that existed
when the operation that is undone was done, again mimicking the
behaviour of other editors such as Xcode.

Additionally, this pull request provides some handy helpers to create
keyboard events that can be used in tests.

---------

Co-authored-by: Lukas Stührk <[email protected]>
  • Loading branch information
Lukas-Stuehrk and Lukas Stührk authored Jun 11, 2024
1 parent 60faeec commit b53f822
Show file tree
Hide file tree
Showing 6 changed files with 386 additions and 168 deletions.
2 changes: 2 additions & 0 deletions Sources/STTextView/STTextView+Insert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ extension STTextView {

open override func insertNewline(_ sender: Any?) {
// insert newline with current typing attributes
breakUndoCoalescing()
insertText("\n")
breakUndoCoalescing()
}

open override func insertNewlineIgnoringFieldEditor(_ sender: Any?) {
Expand Down
108 changes: 31 additions & 77 deletions Sources/STTextView/STTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1239,7 +1239,7 @@ import AVFoundation
internal func replaceCharacters(in textRanges: [NSTextRange], with replacementString: NSAttributedString, allowsTypingCoalescing: Bool) {
// Replace from the end to beginning of the document
for textRange in textRanges.sorted(by: { $0.location > $1.location }) {
replaceCharacters(in: textRange, with: replacementString, allowsTypingCoalescing: true)
replaceCharacters(in: textRange, with: replacementString, allowsTypingCoalescing: allowsTypingCoalescing)
}
}

Expand All @@ -1252,75 +1252,7 @@ import AVFoundation
}

internal func replaceCharacters(in textRange: NSTextRange, with replacementString: NSAttributedString, allowsTypingCoalescing: Bool) {
if allowsUndo, let undoManager = undoManager, undoManager.isUndoRegistrationEnabled {
// typing coalescing
if processingKeyEvent, allowsTypingCoalescing,
let undoManager = undoManager as? CoalescingUndoManager
{
if undoManager.isCoalescing {
// Extend existing coalesce range
if let coalescingValue = undoManager.coalescing?.value,
textRange.location == coalescingValue.textRange.endLocation,
let undoEndLocation = textContentManager.location(textRange.location, offsetBy: replacementString.length),
let undoTextRange = NSTextRange(location: coalescingValue.textRange.location, end: undoEndLocation)
{
undoManager.coalesce(TypingTextUndo(
textRange: undoTextRange,
attributedString: NSAttributedString()
))

} else {
breakUndoCoalescing()
}
}

if !undoManager.isCoalescing {
let undoRange = NSTextRange(
location: textRange.location,
end: textContentManager.location(textRange.location, offsetBy: replacementString.length)
) ?? textRange

let previousStringInRange = (textContentManager as? NSTextContentStorage)!.attributedString!.attributedSubstring(from: NSRange(textRange, in: textContentManager))

let startTypingUndo = TypingTextUndo(
textRange: undoRange,
attributedString: previousStringInRange
)

undoManager.startCoalescing(startTypingUndo, withTarget: self) { textView, typingTextUndo in
// Undo coalesced session action
textView.replaceCharacters(
in: typingTextUndo.textRange,
with: typingTextUndo.attributedString ?? NSAttributedString(),
allowsTypingCoalescing: false
)
}
}
} else if !undoManager.isUndoing, !undoManager.isRedoing, undoManager.isUndoRegistrationEnabled {
breakUndoCoalescing()

// Reach to NSTextStorage because NSTextContentStorage range extraction is cumbersome.
// A range that is as long as replacement string, so when undo it undo
let undoRange = NSTextRange(
location: textRange.location,
end: textContentManager.location(textRange.location, offsetBy: replacementString.length)
) ?? textRange

let previousStringInRange = (textContentManager as! NSTextContentStorage).attributedString!.attributedSubstring(from: NSRange(textRange, in: textContentManager))

// Register undo/redo
// I can't control internal redoStack, and coalescing messes up with the state
// resulting in broken undo/redo availability
undoManager.registerUndo(withTarget: self) { textView in
// Regular undo action
textView.replaceCharacters(
in: undoRange,
with: previousStringInRange,
allowsTypingCoalescing: false
)
}
}
}
let previousStringInRange = (textContentManager as? NSTextContentStorage)!.attributedString!.attributedSubstring(from: NSRange(textRange, in: textContentManager))

textWillChange(self)
delegateProxy.textView(self, willChangeTextIn: textRange, replacementString: replacementString.string)
Expand All @@ -1333,8 +1265,35 @@ import AVFoundation
}

delegateProxy.textView(self, didChangeTextIn: textRange, replacementString: replacementString.string)

didChangeText(in: textRange)

guard allowsUndo, let undoManager = undoManager, undoManager.isUndoRegistrationEnabled else { return }

// Reach to NSTextStorage because NSTextContentStorage range extraction is cumbersome.
// A range that is as long as replacement string, so when undo it undo
let undoRange = NSTextRange(
location: textRange.location,
end: textContentManager.location(textRange.location, offsetBy: replacementString.length)
) ?? textRange

if let coalescingUndoManager = undoManager as? CoalescingUndoManager, !undoManager.isUndoing, !undoManager.isRedoing {
if allowsTypingCoalescing && processingKeyEvent {
coalescingUndoManager.checkCoalescing(range: undoRange)
} else {
coalescingUndoManager.endCoalescing()
}
}
undoManager.beginUndoGrouping()
undoManager.registerUndo(withTarget: self) { textView in
// Regular undo action
textView.replaceCharacters(
in: undoRange,
with: previousStringInRange,
allowsTypingCoalescing: false
)
textView.setSelectedTextRange(textRange)
}
undoManager.endUndoGrouping()
}

/// Whenever text is to be changed due to some user-induced action,
Expand All @@ -1357,12 +1316,7 @@ import AVFoundation

/// Informs the receiver that it should begin coalescing successive typing operations in a new undo grouping
public func breakUndoCoalescing() {
(undoManager as? CoalescingUndoManager)?.breakCoalescing()
}

/// A Boolean value that indicates whether undo coalescing is in progress.
public var isCoalescingUndo: Bool {
(undoManager as? CoalescingUndoManager)?.isCoalescing ?? false
(undoManager as? CoalescingUndoManager)?.endCoalescing()
}

/// Releases the drag information still existing after the dragging session has completed.
Expand Down
115 changes: 33 additions & 82 deletions Sources/STTextView/Utility/CoalescingUndoManager.swift
Original file line number Diff line number Diff line change
@@ -1,108 +1,59 @@
// Created by Marcin Krzyzanowski
// https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md

import Foundation
import AppKit
import STTextKitPlus

final class CoalescingUndoManager: UndoManager {

private(set) var coalescing: (value: TypingTextUndo?, undoAction: ((TypingTextUndo) -> Void)?)?
private var lastRange: NSTextRange?

private var coalescingIsUndoing: Bool = false
private var coalescingIsRedoing: Bool = false

var isCoalescing: Bool {
coalescing != nil
}

func breakCoalescing() {
guard isUndoRegistrationEnabled else {
return
}

// register undo and break coalescing
if !isUndoing, !isRedoing, let undoAction = coalescing?.undoAction, let value = coalescing?.value {
// Disable implicit grouping to avoid group coalescing and non-coalescing undo
groupsByEvent = false
beginUndoGrouping()
registerUndo(withTarget: self) { _ in
undoAction(value)
}
endUndoGrouping()
groupsByEvent = true
}

coalescing = nil
}
private var isCoalescing: Bool = false

override init() {
super.init()
self.runLoopModes = [.default, .common, .eventTracking, .modalPanel]
}

func coalesce(_ value: TypingTextUndo) {
guard isUndoRegistrationEnabled else {
return
}

assert(isCoalescing, "Coalescing not started. Call startCoalescing(withTarget:_) first")

coalescing = (value: value, undoAction: coalescing?.undoAction)
return
}

func startCoalescing<Target>(_ value: TypingTextUndo, withTarget target: Target, _ undoAction: @escaping (Target, TypingTextUndo) -> Void) where Target: AnyObject {
guard isUndoRegistrationEnabled else { return }
coalescing = (value: value, undoAction: { undoAction(target, $0) })
}

override var canRedo: Bool {
super.canRedo
}

override var canUndo: Bool {
super.canUndo || isCoalescing
}

override var isUndoing: Bool {
super.isUndoing || coalescingIsUndoing
}

override var isRedoing: Bool {
super.isRedoing || coalescingIsRedoing
self.groupsByEvent = false
}

override func undo() {
if let undoAction = coalescing?.undoAction, let value = coalescing?.value {
coalescingIsUndoing = true
undoAction(value)
breakCoalescing()
coalescingIsUndoing = false
// FIXME: call undo to register redo
// When the Undo system performs an undo action,
// it expects me to register the redo actions using the same code as for undo.
// That makes the coalescing flow tricky to make right right now
} else {
super.undo()
if isCoalescing {
endCoalescing()
}
super.undo()
}

override func redo() {
if isCoalescing {
endCoalescing()
}
super.redo()
}

override var undoMenuItemTitle: String {
if canUndo {
return super.undoMenuItemTitle
} else {
return NSLocalizedString("Undo", comment: "Undo")
func checkCoalescing(range: NSTextRange) {
defer {
lastRange = range
}
guard isCoalescing, let lastRange else {
startCoalescing()
return
}
if !lastRange.intersects(range) && lastRange.endLocation != range.location {
endCoalescing()
startCoalescing()
}
}

override var redoMenuItemTitle: String {
if canRedo {
return super.redoMenuItemTitle
} else {
return NSLocalizedString("Redo", comment: "Redo")
}
func startCoalescing() {
guard !isCoalescing else { return }
isCoalescing = true
beginUndoGrouping()
}

func endCoalescing() {
guard isCoalescing else { return }
isCoalescing = false
lastRange = nil
endUndoGrouping()
}
}
9 changes: 0 additions & 9 deletions Sources/STTextView/Utility/TypingTextUndo.swift

This file was deleted.

Loading

0 comments on commit b53f822

Please sign in to comment.