Skip to content

Commit 515b025

Browse files
authored
fix(comments): Add Support for Commenting Multiple Lines (#261)
<!--- IMPORTANT: If this PR addresses multiple unrelated issues, it will be closed until separated. --> ### Description This PR lets you highlight multiple lines and comment them all out at once. ### Related Issues * closes #253 <!--- REQUIRED: Tag all related issues (e.g. * #123) --> <!--- If this PR resolves the issue please specify (e.g. * closes #123) --> <!--- If this PR addresses multiple issues, these issues must be related to one other --> ### Checklist <!--- Add things that are not yet implemented above --> - [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/user-attachments/assets/97ae52d5-0fb0-4b25-90e6-2bbc18769856 <!--- REQUIRED: if issue is UI related --> <!--- IMPORTANT: Fill out all required fields. Otherwise we might close this PR temporarily -->
1 parent 4e014f7 commit 515b025

File tree

4 files changed

+244
-57
lines changed

4 files changed

+244
-57
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; };
1011
6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */; };
1112
6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */; };
1213
6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365312B8A7B94004A1D18 /* ContentView.swift */; };
1314
6C1365342B8A7B95004A1D18 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C1365332B8A7B95004A1D18 /* Assets.xcassets */; };
1415
6C1365372B8A7B95004A1D18 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C1365362B8A7B95004A1D18 /* Preview Assets.xcassets */; };
15-
6C1365412B8A7BC3004A1D18 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */; };
1616
6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365432B8A7EED004A1D18 /* String+Lines.swift */; };
1717
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; };
1818
6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; };
@@ -40,19 +40,27 @@
4040
isa = PBXFrameworksBuildPhase;
4141
buildActionMask = 2147483647;
4242
files = (
43-
6C1365412B8A7BC3004A1D18 /* CodeEditSourceEditor in Frameworks */,
43+
61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */,
4444
);
4545
runOnlyForDeploymentPostprocessing = 0;
4646
};
4747
/* End PBXFrameworksBuildPhase section */
4848

4949
/* Begin PBXGroup section */
50+
61621C5F2C74FB2200494A4A /* Frameworks */ = {
51+
isa = PBXGroup;
52+
children = (
53+
);
54+
name = Frameworks;
55+
sourceTree = "<group>";
56+
};
5057
6C1365212B8A7B94004A1D18 = {
5158
isa = PBXGroup;
5259
children = (
5360
6C1365422B8A7BFE004A1D18 /* CodeEditSourceEditor */,
5461
6C13652C2B8A7B94004A1D18 /* CodeEditSourceEditorExample */,
5562
6C13652B2B8A7B94004A1D18 /* Products */,
63+
61621C5F2C74FB2200494A4A /* Frameworks */,
5664
);
5765
sourceTree = "<group>";
5866
};
@@ -131,7 +139,7 @@
131139
);
132140
name = CodeEditSourceEditorExample;
133141
packageProductDependencies = (
134-
6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */,
142+
61621C602C74FB2200494A4A /* CodeEditSourceEditor */,
135143
);
136144
productName = CodeEditSourceEditorExample;
137145
productReference = 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */;
@@ -400,7 +408,7 @@
400408
/* End XCConfigurationList section */
401409

402410
/* Begin XCSwiftPackageProductDependency section */
403-
6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */ = {
411+
61621C602C74FB2200494A4A /* CodeEditSourceEditor */ = {
404412
isa = XCSwiftPackageProductDependency;
405413
productName = CodeEditSourceEditor;
406414
};

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ extension TextViewController {
118118
let commandKey = NSEvent.ModifierFlags.command.rawValue
119119
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
120120
if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" {
121-
self?.commandSlashCalled()
121+
self?.handleCommandSlash()
122122
return nil
123123
} else {
124124
return event

Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift

Lines changed: 202 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,72 +9,222 @@ import CodeEditTextView
99
import AppKit
1010

1111
extension TextViewController {
12-
/// Method called when CMD + / key sequence recognized, comments cursor's current line of code
13-
public func commandSlashCalled() {
14-
guard let cursorPosition = cursorPositions.first else {
15-
return
16-
}
17-
// Many languages require a character sequence at the beginning of the line to comment the line.
18-
// (ex. python #, C++ //)
19-
// If such a sequence exists, we will insert that sequence at the beginning of the line
20-
if !language.lineCommentString.isEmpty {
21-
toggleCharsAtBeginningOfLine(chars: language.lineCommentString, lineNumber: cursorPosition.line)
22-
}
23-
// In other cases, languages require a character sequence at beginning and end of a line, aka a range comment
24-
// (Ex. HTML <!--line here -->)
25-
// We treat the line as a one-line range to comment it out using rangeCommentStrings on both sides of the line
26-
else {
27-
let (openComment, closeComment) = language.rangeCommentStrings
28-
toggleCharsAtEndOfLine(chars: closeComment, lineNumber: cursorPosition.line)
29-
toggleCharsAtBeginningOfLine(chars: openComment, lineNumber: cursorPosition.line)
12+
/// Method called when CMD + / key sequence is recognized.
13+
/// Comments or uncomments the cursor's current line(s) of code.
14+
public func handleCommandSlash() {
15+
guard let cursorPosition = cursorPositions.first else { return }
16+
// Set up a cache to avoid redundant computations.
17+
// The cache stores line information (e.g., ranges), line contents,
18+
// and other relevant data to improve efficiency.
19+
var cache = CommentCache()
20+
populateCommentCache(for: cursorPosition.range, using: &cache)
21+
22+
// Begin an undo grouping to allow for a single undo operation for the entire comment toggle.
23+
textView.undoManager?.beginUndoGrouping()
24+
for lineInfo in cache.lineInfos {
25+
if let lineInfo {
26+
toggleComment(lineInfo: lineInfo, cache: cache)
27+
}
3028
}
29+
30+
// End the undo grouping to complete the undo operation for the comment toggle.
31+
textView.undoManager?.endUndoGrouping()
3132
}
3233

33-
/// Toggles comment string at the beginning of a specified line (lineNumber is 1-indexed)
34-
private func toggleCharsAtBeginningOfLine(chars: String, lineNumber: Int) {
35-
guard let lineInfo = textView.layoutManager.textLineForIndex(lineNumber - 1),
36-
let lineString = textView.textStorage.substring(from: lineInfo.range) else {
34+
// swiftlint:disable cyclomatic_complexity
35+
/// Populates the comment cache with information about the lines within a specified range,
36+
/// determining whether comment characters should be inserted or removed.
37+
/// - Parameters:
38+
/// - range: The range of text to process.
39+
/// - commentCache: A cache object to store comment-related data, such as line information,
40+
/// shift factors, and content.
41+
func populateCommentCache(for range: NSRange, using commentCache: inout CommentCache) {
42+
// Determine the appropriate comment characters based on the language settings.
43+
if language.lineCommentString.isEmpty {
44+
commentCache.startCommentChars = language.rangeCommentStrings.0
45+
commentCache.endCommentChars = language.rangeCommentStrings.1
46+
} else {
47+
commentCache.startCommentChars = language.lineCommentString
48+
}
49+
50+
// Return early if no comment characters are available.
51+
guard let startCommentChars = commentCache.startCommentChars else { return }
52+
53+
// Fetch the starting line's information and content.
54+
guard let startLineInfo = textView.layoutManager.textLineForOffset(range.location),
55+
let startLineContent = textView.textStorage.substring(from: startLineInfo.range) else {
3756
return
3857
}
39-
let firstNonWhiteSpaceCharIndex = lineString.firstIndex(where: {!$0.isWhitespace}) ?? lineString.startIndex
40-
let numWhitespaceChars = lineString.distance(from: lineString.startIndex, to: firstNonWhiteSpaceCharIndex)
41-
let firstCharsInLine = lineString.suffix(from: firstNonWhiteSpaceCharIndex).prefix(chars.count)
42-
// toggle comment off
43-
if firstCharsInLine == chars {
44-
textView.replaceCharacters(
45-
in: NSRange(location: lineInfo.range.location + numWhitespaceChars, length: chars.count),
46-
with: ""
58+
59+
// Initialize cache with the first line's information.
60+
commentCache.lineInfos = [startLineInfo]
61+
commentCache.lineStrings[startLineInfo.index] = startLineContent
62+
commentCache.shouldInsertCommentChars = !startLineContent
63+
.trimmingCharacters(in: .whitespacesAndNewlines).starts(with: startCommentChars)
64+
65+
// Retrieve information for the ending line. Proceed only if the ending line
66+
// is different from the starting line, indicating that the user has selected more than one line.
67+
guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound),
68+
endLineInfo.index != startLineInfo.index else { return }
69+
70+
// Check if comment characters need to be inserted for the ending line.
71+
if let endLineContent = textView.textStorage.substring(from: endLineInfo.range) {
72+
// If comment characters need to be inserted, they should be added to every line within the range.
73+
if !commentCache.shouldInsertCommentChars {
74+
commentCache.shouldInsertCommentChars = !endLineContent
75+
.trimmingCharacters(in: .whitespacesAndNewlines).starts(with: startCommentChars)
76+
}
77+
commentCache.lineStrings[endLineInfo.index] = endLineContent
78+
}
79+
80+
// Process all lines between the start and end lines.
81+
let intermediateLines = (startLineInfo.index + 1)..<endLineInfo.index
82+
for (offset, lineIndex) in intermediateLines.enumerated() {
83+
guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { break }
84+
// Cache the line content here since we'll need to access it anyway
85+
// to append a comment at the end of the line.
86+
if let lineContent = textView.textStorage.substring(from: lineInfo.range) {
87+
// Line content is accessed only when:
88+
// - A line's comment is toggled off, or
89+
// - Comment characters need to be appended to the end of the line.
90+
if language.lineCommentString.isEmpty || !commentCache.shouldInsertCommentChars {
91+
commentCache.lineStrings[lineIndex] = lineContent
92+
}
93+
94+
if !commentCache.shouldInsertCommentChars {
95+
commentCache.shouldInsertCommentChars = !lineContent
96+
.trimmingCharacters(in: .whitespacesAndNewlines)
97+
.starts(with: startCommentChars)
98+
}
99+
}
100+
101+
// Cache line information and calculate the shift range factor.
102+
commentCache.lineInfos.append(lineInfo)
103+
commentCache.shiftRangeFactors[lineIndex] = calculateShiftRangeFactor(
104+
startCount: startCommentChars.count,
105+
endCount: commentCache.endCommentChars?.count,
106+
lineCount: offset
47107
)
108+
}
109+
110+
// Cache the ending line's information and calculate its shift range factor.
111+
commentCache.lineInfos.append(endLineInfo)
112+
commentCache.shiftRangeFactors[endLineInfo.index] = calculateShiftRangeFactor(
113+
startCount: startCommentChars.count,
114+
endCount: commentCache.endCommentChars?.count,
115+
lineCount: intermediateLines.count
116+
)
117+
}
118+
// swiftlint:enable cyclomatic_complexity
119+
120+
/// Calculates the shift range factor based on the counts of start and
121+
/// end comment characters and the number of intermediate lines.
122+
///
123+
/// - Parameters:
124+
/// - startCount: The number of characters in the start comment.
125+
/// - endCount: An optional number of characters in the end comment. If `nil`, it is treated as 0.
126+
/// - lineCount: The number of intermediate lines between the start and end comments.
127+
///
128+
/// - Returns: The computed shift range factor as an `Int`.
129+
func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int {
130+
let effectiveEndCount = endCount ?? 0
131+
return (startCount + effectiveEndCount) * (lineCount + 1)
132+
}
133+
/// Toggles the presence of comment characters at the beginning and/or end
134+
/// - Parameters:
135+
/// - lineInfo: Contains information about the specific line, including its position and range.
136+
/// - cache: A cache holding comment-related data such as the comment characters and line content.
137+
private func toggleComment(lineInfo: TextLineStorage<TextLine>.TextLinePosition, cache: borrowing CommentCache) {
138+
if cache.endCommentChars != nil {
139+
toggleCommentAtEndOfLine(lineInfo: lineInfo, cache: cache)
140+
toggleCommentAtBeginningOfLine(lineInfo: lineInfo, cache: cache)
48141
} else {
49-
// toggle comment on
50-
textView.replaceCharacters(
51-
in: NSRange(location: lineInfo.range.location + numWhitespaceChars, length: 0),
52-
with: chars
53-
)
142+
toggleCommentAtBeginningOfLine(lineInfo: lineInfo, cache: cache)
54143
}
55144
}
56145

57-
/// Toggles a specific string of characters at the end of a specified line. (lineNumber is 1-indexed)
58-
private func toggleCharsAtEndOfLine(chars: String, lineNumber: Int) {
59-
guard let lineInfo = textView.layoutManager.textLineForIndex(lineNumber - 1), !lineInfo.range.isEmpty else {
146+
/// Toggles the presence of comment characters at the beginning of a line in the text view.
147+
/// - Parameters:
148+
/// - lineInfo: Contains information about the specific line, including its position and range.
149+
/// - cache: A cache holding comment-related data such as the comment characters and line content.
150+
private func toggleCommentAtBeginningOfLine(
151+
lineInfo: TextLineStorage<TextLine>.TextLinePosition,
152+
cache: borrowing CommentCache
153+
) {
154+
// Ensure there are comment characters to toggle.
155+
guard let startCommentChars = cache.startCommentChars else { return }
156+
157+
// Calculate the range shift based on cached factors, defaulting to 0 if unavailable.
158+
let rangeShift = cache.shiftRangeFactors[lineInfo.index] ?? 0
159+
160+
// If we need to insert comment characters at the beginning of the line.
161+
if cache.shouldInsertCommentChars {
162+
guard let adjustedRange = lineInfo.range.shifted(by: rangeShift) else { return }
163+
textView.replaceCharacters(
164+
in: NSRange(location: adjustedRange.location, length: 0),
165+
with: startCommentChars
166+
)
60167
return
61168
}
62-
let lineLastCharIndex = lineInfo.range.location + lineInfo.range.length - 1
63-
let closeCommentLength = chars.count
64-
let closeCommentRange = NSRange(
65-
location: lineLastCharIndex - closeCommentLength,
66-
length: closeCommentLength
169+
170+
// If we need to remove comment characters from the beginning of the line.
171+
guard let adjustedRange = lineInfo.range.shifted(by: -rangeShift) else { return }
172+
173+
// Retrieve the current line's string content from the cache or the text view's storage.
174+
guard let lineContent =
175+
cache.lineStrings[lineInfo.index] ?? textView.textStorage.substring(from: adjustedRange) else { return }
176+
177+
// Find the index of the first non-whitespace character.
178+
let firstNonWhitespaceIndex = lineContent.firstIndex(where: { !$0.isWhitespace }) ?? lineContent.startIndex
179+
let leadingWhitespaceCount = lineContent.distance(from: lineContent.startIndex, to: firstNonWhitespaceIndex)
180+
181+
// Remove the comment characters from the beginning of the line.
182+
textView.replaceCharacters(
183+
in: NSRange(location: adjustedRange.location + leadingWhitespaceCount, length: startCommentChars.count),
184+
with: ""
67185
)
68-
let lastCharsInLine = textView.textStorage.substring(from: closeCommentRange)
69-
// toggle comment off
70-
if lastCharsInLine == chars {
71-
textView.replaceCharacters(
72-
in: NSRange(location: lineLastCharIndex - closeCommentLength, length: closeCommentLength),
73-
with: ""
74-
)
186+
}
187+
188+
/// Toggles the presence of comment characters at the end of a line in the text view.
189+
/// - Parameters:
190+
/// - lineInfo: Contains information about the specific line, including its position and range.
191+
/// - cache: A cache holding comment-related data such as the comment characters and line content.
192+
private func toggleCommentAtEndOfLine(
193+
lineInfo: TextLineStorage<TextLine>.TextLinePosition,
194+
cache: borrowing CommentCache
195+
) {
196+
// Ensure there are comment characters to toggle and the line is not empty.
197+
guard let endingCommentChars = cache.endCommentChars else { return }
198+
guard !lineInfo.range.isEmpty else { return }
199+
200+
// Calculate the range shift based on cached factors, defaulting to 0 if unavailable.
201+
let rangeShift = cache.shiftRangeFactors[lineInfo.index] ?? 0
202+
203+
// Shift the line range by `rangeShift` if inserting comment characters, or by `-rangeShift` if removing them.
204+
guard let adjustedRange = lineInfo.range.shifted(by: cache.shouldInsertCommentChars ? rangeShift : -rangeShift)
205+
else { return }
206+
207+
// Retrieve the current line's string content from the cache or the text view's storage.
208+
guard let lineContent =
209+
cache.lineStrings[lineInfo.index] ?? textView.textStorage.substring(from: adjustedRange) else { return }
210+
211+
var endIndex = adjustedRange.upperBound
212+
213+
// If the last character is a newline, adjust the insertion point to before the newline.
214+
if lineContent.last?.isNewline ?? false {
215+
endIndex -= 1
216+
}
217+
218+
if cache.shouldInsertCommentChars {
219+
// Insert the comment characters at the calculated position.
220+
textView.replaceCharacters(in: NSRange(location: endIndex, length: 0), with: endingCommentChars)
75221
} else {
76-
// toggle comment on
77-
textView.replaceCharacters(in: NSRange(location: lineLastCharIndex, length: 0), with: chars)
222+
// Remove the comment characters if they exist at the end of the line.
223+
let commentRange = NSRange(
224+
location: endIndex - endingCommentChars.count,
225+
length: endingCommentChars.count
226+
)
227+
textView.replaceCharacters(in: commentRange, with: "")
78228
}
79229
}
80230
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// File.swift
3+
//
4+
//
5+
// Created by Tommy Ludwig on 23.08.24.
6+
//
7+
8+
import CodeEditTextView
9+
10+
extension TextViewController {
11+
/// A cache used to store and manage comment-related information for lines in a text view.
12+
/// This class helps in efficiently inserting or removing comment characters at specific line positions.
13+
struct CommentCache: ~Copyable {
14+
/// Holds necessary information like the lines range
15+
var lineInfos: [TextLineStorage<TextLine>.TextLinePosition?] = []
16+
/// Caches the content of lines by their indices. Populated only if comment characters need to be inserted.
17+
var lineStrings: [Int: String] = [:]
18+
/// Caches the shift range factors for lines based on their indices.
19+
var shiftRangeFactors: [Int: Int] = [:]
20+
/// Insertion is necessary only if at least one of the selected
21+
/// lines does not already start with `startCommentChars`.
22+
var shouldInsertCommentChars: Bool = false
23+
var startCommentChars: String?
24+
/// The characters used to end a comment.
25+
/// This is applicable for languages (e.g., HTML)
26+
/// that require a closing comment sequence at the end of the line.
27+
var endCommentChars: String?
28+
}
29+
}

0 commit comments

Comments
 (0)