Skip to content

Commit 8fba782

Browse files
Fix Undo/Redo, Bump Dependencies (#204)
### Description Bumps both `STTextView` and `TextFormation` to their latest versions. This version of `TextFormation` changes how whitespaces are handled by filters, so there were some small modifications that needed to be made to accommodate. This also slightly adjusts the `DeleteWhitespaceFilter` to match other editor's functionality. `DeleteWhitespaceFilter` now deletes only the leading whitespace, and only to the nearest column, instead of just a number of spaces. It will also jump to the rightmost side of the whitespace when deleting. The other half of this PR is an implementation for undo/redo. This adds a `CEUndoManager` (with the prefix b/c AppKit declares that type) class that implements an undo/redo stack and operation grouping. It provides methods for performing undo/redo operations, registering text mutations from the editor, grouping operations, and clearing the undo stack. The class also automatically groups certain mutations. See the class documentation for more info there. ### Related Issues * Closes #201 * Closes #203 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/CodeEditApp/CodeEditTextView/assets/35942988/35c64ed6-590d-493a-8f91-d918fef363a5
1 parent cc28ae5 commit 8fba782

15 files changed

+414
-173
lines changed

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ let package = Package(
1515
dependencies: [
1616
.package(
1717
url: "https://github.com/krzyzanowskim/STTextView.git",
18-
exact: "0.6.7"
18+
revision: "897c5ff"
1919
),
2020
.package(
2121
url: "https://github.com/CodeEditApp/CodeEditLanguages.git",
@@ -27,7 +27,7 @@ let package = Package(
2727
),
2828
.package(
2929
url: "https://github.com/ChimeHQ/TextFormation",
30-
from: "0.6.7"
30+
from: "0.7.0"
3131
)
3232
],
3333
targets: [
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// CETextView.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 7/8/23.
6+
//
7+
8+
import AppKit
9+
import UniformTypeIdentifiers
10+
import TextStory
11+
import STTextView
12+
13+
class CETextView: STTextView {
14+
override open func paste(_ sender: Any?) {
15+
guard let undoManager = undoManager as? CEUndoManager.DelegatedUndoManager else { return }
16+
undoManager.parent?.beginGrouping()
17+
18+
let pasteboard = NSPasteboard.general
19+
if pasteboard.canReadItem(withDataConformingToTypes: [UTType.text.identifier]),
20+
let string = NSPasteboard.general.string(forType: .string) {
21+
for textRange in textLayoutManager
22+
.textSelections
23+
.flatMap(\.textRanges)
24+
.sorted(by: { $0.location.compare($1.location) == .orderedDescending }) {
25+
if let nsRange = textRange.nsRange(using: textContentManager) {
26+
undoManager.registerMutation(
27+
TextMutation(insert: string, at: nsRange.location, limit: textContentStorage?.length ?? 0)
28+
)
29+
}
30+
replaceCharacters(in: textRange, with: string)
31+
}
32+
}
33+
34+
undoManager.parent?.endGrouping()
35+
}
36+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
//
2+
// CEUndoManager.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 7/8/23.
6+
//
7+
8+
import STTextView
9+
import AppKit
10+
import TextStory
11+
12+
/// Maintains a history of edits applied to the editor and allows for undo/redo actions using those edits.
13+
///
14+
/// This object also groups edits into sequences that make for a better undo/redo editing experience such as:
15+
/// - Breaking undo groups on newlines
16+
/// - Grouping pasted text
17+
///
18+
/// If needed, the automatic undo grouping can be overridden using the `beginGrouping()` and `endGrouping()` methods.
19+
class CEUndoManager {
20+
/// An `UndoManager` subclass that forwards relevant actions to a `CEUndoManager`.
21+
/// Allows for objects like `STTextView` to use the `UndoManager` API
22+
/// while CETV manages the undo/redo actions.
23+
class DelegatedUndoManager: UndoManager {
24+
weak var parent: CEUndoManager?
25+
26+
override var canUndo: Bool { parent?.canUndo ?? false }
27+
override var canRedo: Bool { parent?.canRedo ?? false }
28+
29+
func registerMutation(_ mutation: TextMutation) {
30+
parent?.registerMutation(mutation)
31+
removeAllActions()
32+
}
33+
34+
override func undo() {
35+
parent?.undo()
36+
}
37+
38+
override func redo() {
39+
parent?.redo()
40+
}
41+
42+
override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) {
43+
// no-op, but just in case to save resources:
44+
removeAllActions()
45+
}
46+
}
47+
48+
/// Represents a group of mutations that should be treated as one mutation when undoing/redoing.
49+
private struct UndoGroup {
50+
struct Mutation {
51+
var mutation: TextMutation
52+
var inverse: TextMutation
53+
}
54+
55+
var mutations: [Mutation]
56+
}
57+
58+
public let manager: DelegatedUndoManager
59+
public var isUndoing: Bool = false
60+
public var isRedoing: Bool = false
61+
62+
public var canUndo: Bool {
63+
!undoStack.isEmpty
64+
}
65+
public var canRedo: Bool {
66+
!redoStack.isEmpty
67+
}
68+
69+
/// A stack of operations that can be undone.
70+
private var undoStack: [UndoGroup] = []
71+
/// A stack of operations that can be redone.
72+
private var redoStack: [UndoGroup] = []
73+
74+
private unowned let textView: STTextView
75+
private(set) var isGrouping: Bool = false
76+
77+
public init(textView: STTextView) {
78+
self.textView = textView
79+
self.manager = DelegatedUndoManager()
80+
manager.parent = self
81+
}
82+
83+
/// Performs an undo operation if there is one available.
84+
public func undo() {
85+
guard let item = undoStack.popLast() else {
86+
return
87+
}
88+
isUndoing = true
89+
for mutation in item.mutations.reversed() {
90+
textView.applyMutationNoUndo(mutation.inverse)
91+
}
92+
redoStack.append(item)
93+
isUndoing = false
94+
}
95+
96+
/// Performs a redo operation if there is one available.
97+
public func redo() {
98+
guard let item = redoStack.popLast() else {
99+
return
100+
}
101+
isRedoing = true
102+
for mutation in item.mutations {
103+
textView.applyMutationNoUndo(mutation.mutation)
104+
}
105+
undoStack.append(item)
106+
isRedoing = false
107+
}
108+
109+
/// Clears the undo/redo stacks.
110+
public func clearStack() {
111+
undoStack.removeAll()
112+
redoStack.removeAll()
113+
}
114+
115+
/// Registers a mutation into the undo stack.
116+
///
117+
/// Calling this method while the manager is in an undo/redo operation will result in a no-op.
118+
/// - Parameter mutation: The mutation to register for undo/redo
119+
public func registerMutation(_ mutation: TextMutation) {
120+
if (mutation.range.length == 0 && mutation.string.isEmpty) || isUndoing || isRedoing { return }
121+
let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textView.inverseMutation(for: mutation))
122+
if !undoStack.isEmpty, let lastMutation = undoStack.last?.mutations.last {
123+
if isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) {
124+
undoStack[undoStack.count - 1].mutations.append(newMutation)
125+
} else {
126+
undoStack.append(UndoGroup(mutations: [newMutation]))
127+
}
128+
} else {
129+
undoStack.append(
130+
UndoGroup(mutations: [newMutation])
131+
)
132+
}
133+
134+
redoStack.removeAll()
135+
}
136+
137+
/// Groups all incoming mutations.
138+
public func beginGrouping() {
139+
isGrouping = true
140+
}
141+
142+
/// Stops grouping all incoming mutations.
143+
public func endGrouping() {
144+
isGrouping = false
145+
}
146+
147+
/// Determines whether or not two mutations should be grouped.
148+
///
149+
/// Will break group if:
150+
/// - Last mutation is delete and new is insert, and vice versa *(insert and delete)*.
151+
/// - Last mutation was not whitespace, new is whitespace *(insert)*.
152+
/// - New mutation is a newline *(insert and delete)*.
153+
/// - New mutation is not sequential with the last one *(insert and delete)*.
154+
///
155+
/// - Parameters:
156+
/// - mutation: The current mutation.
157+
/// - lastMutation: The last mutation applied to the document.
158+
/// - Returns: Whether or not the given mutations can be grouped.
159+
private func shouldContinueGroup(_ mutation: UndoGroup.Mutation, lastMutation: UndoGroup.Mutation) -> Bool {
160+
// If last mutation was delete & new is insert or vice versa, split group
161+
if (mutation.mutation.range.length > 0 && lastMutation.mutation.range.length == 0)
162+
|| (mutation.mutation.range.length == 0 && lastMutation.mutation.range.length > 0) {
163+
return false
164+
}
165+
166+
if mutation.mutation.string.isEmpty {
167+
// Deleting
168+
return (
169+
lastMutation.mutation.range.location == mutation.mutation.range.max
170+
&& mutation.inverse.string != "\n"
171+
)
172+
} else {
173+
// Inserting
174+
175+
// Only attempt this check if the mutations are small enough.
176+
// If the last mutation was not whitespace, and the new one is, break the group.
177+
if lastMutation.mutation.string.count < 1024
178+
&& mutation.mutation.string.count < 1024
179+
&& !lastMutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty
180+
&& mutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty {
181+
return false
182+
}
183+
184+
return (
185+
lastMutation.mutation.range.max + 1 == mutation.mutation.range.location
186+
&& mutation.mutation.string != "\n"
187+
)
188+
}
189+
}
190+
}

Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ extension STTextViewController {
109109
/// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`.
110110
private func highlightRange(_ range: NSTextRange, scrollToRange: Bool = false) {
111111
guard let bracketPairHighlight = bracketPairHighlight,
112-
var rectToHighlight = textView.textLayoutManager.textSelectionSegmentFrame(
112+
var rectToHighlight = textView.textLayoutManager.textSegmentFrame(
113113
in: range, type: .highlight
114114
) else {
115115
return

Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import STTextView
1010

1111
extension STTextViewController {
1212
public override func loadView() {
13-
textView = STTextView()
13+
textView = CETextView()
1414

1515
let scrollView = CEScrollView()
1616
scrollView.translatesAutoresizingMaskIntoConstraints = false
@@ -22,7 +22,7 @@ extension STTextViewController {
2222
rulerView.drawSeparator = false
2323
rulerView.baselineOffset = baselineOffset
2424
rulerView.allowsMarkers = false
25-
rulerView.backgroundColor = .clear
25+
rulerView.backgroundColor = theme.background
2626
rulerView.textColor = .secondaryLabelColor
2727

2828
scrollView.verticalRulerView = rulerView
@@ -57,6 +57,7 @@ extension STTextViewController {
5757
return event
5858
}
5959

60+
textViewUndoManager = CEUndoManager(textView: textView)
6061
reloadUI()
6162
setUpHighlighter()
6263
setHighlightProvider(self.highlightProvider)
@@ -118,6 +119,6 @@ extension STTextViewController {
118119

119120
public override func viewWillAppear() {
120121
super.viewWillAppear()
121-
updateTextContainerWidthIfNeeded()
122+
updateTextContainerWidthIfNeeded(true)
122123
}
123124
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//
2+
// STTextViewController+STTextViewDelegate.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 7/8/23.
6+
//
7+
8+
import AppKit
9+
import STTextView
10+
import TextStory
11+
12+
extension STTextViewController {
13+
public func undoManager(for textView: STTextView) -> UndoManager? {
14+
textViewUndoManager.manager
15+
}
16+
17+
public func textView(
18+
_ textView: STTextView,
19+
shouldChangeTextIn affectedCharRange: NSTextRange,
20+
replacementString: String?
21+
) -> Bool {
22+
guard let textContentStorage = textView.textContentStorage,
23+
let range = affectedCharRange.nsRange(using: textContentStorage),
24+
!textViewUndoManager.isUndoing,
25+
!textViewUndoManager.isRedoing else {
26+
return true
27+
}
28+
29+
let mutation = TextMutation(
30+
string: replacementString ?? "",
31+
range: range,
32+
limit: textView.textContentStorage?.length ?? 0
33+
)
34+
35+
let result = shouldApplyMutation(mutation, to: textView)
36+
37+
if result {
38+
textViewUndoManager.registerMutation(mutation)
39+
}
40+
41+
return result
42+
}
43+
}

Sources/CodeEditTextView/Controller/STTextViewController+TextContainer.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,25 @@ extension STTextViewController {
1313
///
1414
/// Effectively updates the container to reflect the `wrapLines` setting, and to reflect any updates to the ruler,
1515
/// scroll view, or window frames.
16-
internal func updateTextContainerWidthIfNeeded() {
16+
internal func updateTextContainerWidthIfNeeded(_ forceUpdate: Bool = false) {
1717
let previousTrackingSetting = textView.widthTracksTextView
1818
textView.widthTracksTextView = wrapLines
19+
guard let scrollView = view as? NSScrollView else {
20+
return
21+
}
22+
1923
if wrapLines {
20-
var proposedSize = ((view as? NSScrollView)?.contentSize ?? .zero)
24+
var proposedSize = scrollView.contentSize
2125
proposedSize.height = .greatestFiniteMagnitude
2226

23-
if textView.textContainer.size != proposedSize || textView.frame.size != proposedSize {
27+
if textView.textContainer.size != proposedSize || textView.frame.size != proposedSize || forceUpdate {
2428
textView.textContainer.size = proposedSize
2529
textView.setFrameSize(proposedSize)
2630
}
2731
} else {
2832
var proposedSize = textView.frame.size
29-
proposedSize.width = ((view as? NSScrollView)?.contentSize ?? .zero).width
30-
if previousTrackingSetting != wrapLines {
33+
proposedSize.width = scrollView.contentSize.width
34+
if previousTrackingSetting != wrapLines || forceUpdate {
3135
textView.textContainer.size = CGSize(
3236
width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude
3337
)

Sources/CodeEditTextView/Controller/STTextViewController.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Combine
1111
import STTextView
1212
import CodeEditLanguages
1313
import TextFormation
14+
import TextStory
1415

1516
/// A View Controller managing and displaying a `STTextView`
1617
public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAttributesProviding {
@@ -26,6 +27,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
2627
/// for every new selection.
2728
internal var lastTextSelections: [NSTextRange] = []
2829

30+
internal var textViewUndoManager: CEUndoManager!
31+
2932
/// Binding for the `textView`s string
3033
public var text: Binding<String>
3134

@@ -197,7 +200,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
197200
textView.highlightSelectedLine = isEditable
198201
textView.typingAttributes = attributesFor(nil)
199202
paragraphStyle = generateParagraphStyle()
200-
textView.typingAttributes[.paragraphStyle] = paragraphStyle
203+
textView.typingAttributes = attributesFor(nil)
201204

202205
rulerView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua
203206
? NSColor.quaternaryLabelColor
@@ -206,6 +209,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt
206209
rulerView.highlightSelectedLine = isEditable
207210
rulerView.rulerInsets = STRulerInsets(leading: rulerFont.pointSize * 1.6, trailing: 8)
208211
rulerView.font = rulerFont
212+
rulerView.backgroundColor = theme.background
209213
if self.isEditable == false {
210214
rulerView.selectedLineTextColor = nil
211215
rulerView.selectedLineHighlightColor = .clear

0 commit comments

Comments
 (0)