Skip to content

Commit

Permalink
fix(comments): Add Support for Commenting Multiple Lines (#261)
Browse files Browse the repository at this point in the history
<!--- 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 -->
  • Loading branch information
tom-ludwig authored Aug 23, 2024
1 parent 4e014f7 commit 515b025
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
objects = {

/* Begin PBXBuildFile section */
61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; };
6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */; };
6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */; };
6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365312B8A7B94004A1D18 /* ContentView.swift */; };
6C1365342B8A7B95004A1D18 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C1365332B8A7B95004A1D18 /* Assets.xcassets */; };
6C1365372B8A7B95004A1D18 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C1365362B8A7B95004A1D18 /* Preview Assets.xcassets */; };
6C1365412B8A7BC3004A1D18 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */; };
6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365432B8A7EED004A1D18 /* String+Lines.swift */; };
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; };
6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; };
Expand Down Expand Up @@ -40,19 +40,27 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6C1365412B8A7BC3004A1D18 /* CodeEditSourceEditor in Frameworks */,
61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
61621C5F2C74FB2200494A4A /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
6C1365212B8A7B94004A1D18 = {
isa = PBXGroup;
children = (
6C1365422B8A7BFE004A1D18 /* CodeEditSourceEditor */,
6C13652C2B8A7B94004A1D18 /* CodeEditSourceEditorExample */,
6C13652B2B8A7B94004A1D18 /* Products */,
61621C5F2C74FB2200494A4A /* Frameworks */,
);
sourceTree = "<group>";
};
Expand Down Expand Up @@ -131,7 +139,7 @@
);
name = CodeEditSourceEditorExample;
packageProductDependencies = (
6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */,
61621C602C74FB2200494A4A /* CodeEditSourceEditor */,
);
productName = CodeEditSourceEditorExample;
productReference = 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */;
Expand Down Expand Up @@ -400,7 +408,7 @@
/* End XCConfigurationList section */

/* Begin XCSwiftPackageProductDependency section */
6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */ = {
61621C602C74FB2200494A4A /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ extension TextViewController {
let commandKey = NSEvent.ModifierFlags.command.rawValue
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" {
self?.commandSlashCalled()
self?.handleCommandSlash()
return nil
} else {
return event
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,72 +9,222 @@ import CodeEditTextView
import AppKit

extension TextViewController {
/// Method called when CMD + / key sequence recognized, comments cursor's current line of code
public func commandSlashCalled() {
guard let cursorPosition = cursorPositions.first else {
return
}
// Many languages require a character sequence at the beginning of the line to comment the line.
// (ex. python #, C++ //)
// If such a sequence exists, we will insert that sequence at the beginning of the line
if !language.lineCommentString.isEmpty {
toggleCharsAtBeginningOfLine(chars: language.lineCommentString, lineNumber: cursorPosition.line)
}
// In other cases, languages require a character sequence at beginning and end of a line, aka a range comment
// (Ex. HTML <!--line here -->)
// We treat the line as a one-line range to comment it out using rangeCommentStrings on both sides of the line
else {
let (openComment, closeComment) = language.rangeCommentStrings
toggleCharsAtEndOfLine(chars: closeComment, lineNumber: cursorPosition.line)
toggleCharsAtBeginningOfLine(chars: openComment, lineNumber: cursorPosition.line)
/// Method called when CMD + / key sequence is recognized.
/// Comments or uncomments the cursor's current line(s) of code.
public func handleCommandSlash() {
guard let cursorPosition = cursorPositions.first else { return }
// Set up a cache to avoid redundant computations.
// The cache stores line information (e.g., ranges), line contents,
// and other relevant data to improve efficiency.
var cache = CommentCache()
populateCommentCache(for: cursorPosition.range, using: &cache)

// Begin an undo grouping to allow for a single undo operation for the entire comment toggle.
textView.undoManager?.beginUndoGrouping()
for lineInfo in cache.lineInfos {
if let lineInfo {
toggleComment(lineInfo: lineInfo, cache: cache)
}
}

// End the undo grouping to complete the undo operation for the comment toggle.
textView.undoManager?.endUndoGrouping()
}

/// Toggles comment string at the beginning of a specified line (lineNumber is 1-indexed)
private func toggleCharsAtBeginningOfLine(chars: String, lineNumber: Int) {
guard let lineInfo = textView.layoutManager.textLineForIndex(lineNumber - 1),
let lineString = textView.textStorage.substring(from: lineInfo.range) else {
// swiftlint:disable cyclomatic_complexity
/// Populates the comment cache with information about the lines within a specified range,
/// determining whether comment characters should be inserted or removed.
/// - Parameters:
/// - range: The range of text to process.
/// - commentCache: A cache object to store comment-related data, such as line information,
/// shift factors, and content.
func populateCommentCache(for range: NSRange, using commentCache: inout CommentCache) {
// Determine the appropriate comment characters based on the language settings.
if language.lineCommentString.isEmpty {
commentCache.startCommentChars = language.rangeCommentStrings.0
commentCache.endCommentChars = language.rangeCommentStrings.1
} else {
commentCache.startCommentChars = language.lineCommentString
}

// Return early if no comment characters are available.
guard let startCommentChars = commentCache.startCommentChars else { return }

// Fetch the starting line's information and content.
guard let startLineInfo = textView.layoutManager.textLineForOffset(range.location),
let startLineContent = textView.textStorage.substring(from: startLineInfo.range) else {
return
}
let firstNonWhiteSpaceCharIndex = lineString.firstIndex(where: {!$0.isWhitespace}) ?? lineString.startIndex
let numWhitespaceChars = lineString.distance(from: lineString.startIndex, to: firstNonWhiteSpaceCharIndex)
let firstCharsInLine = lineString.suffix(from: firstNonWhiteSpaceCharIndex).prefix(chars.count)
// toggle comment off
if firstCharsInLine == chars {
textView.replaceCharacters(
in: NSRange(location: lineInfo.range.location + numWhitespaceChars, length: chars.count),
with: ""

// Initialize cache with the first line's information.
commentCache.lineInfos = [startLineInfo]
commentCache.lineStrings[startLineInfo.index] = startLineContent
commentCache.shouldInsertCommentChars = !startLineContent
.trimmingCharacters(in: .whitespacesAndNewlines).starts(with: startCommentChars)

// Retrieve information for the ending line. Proceed only if the ending line
// is different from the starting line, indicating that the user has selected more than one line.
guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound),
endLineInfo.index != startLineInfo.index else { return }

// Check if comment characters need to be inserted for the ending line.
if let endLineContent = textView.textStorage.substring(from: endLineInfo.range) {
// If comment characters need to be inserted, they should be added to every line within the range.
if !commentCache.shouldInsertCommentChars {
commentCache.shouldInsertCommentChars = !endLineContent
.trimmingCharacters(in: .whitespacesAndNewlines).starts(with: startCommentChars)
}
commentCache.lineStrings[endLineInfo.index] = endLineContent
}

// Process all lines between the start and end lines.
let intermediateLines = (startLineInfo.index + 1)..<endLineInfo.index
for (offset, lineIndex) in intermediateLines.enumerated() {
guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { break }
// Cache the line content here since we'll need to access it anyway
// to append a comment at the end of the line.
if let lineContent = textView.textStorage.substring(from: lineInfo.range) {
// Line content is accessed only when:
// - A line's comment is toggled off, or
// - Comment characters need to be appended to the end of the line.
if language.lineCommentString.isEmpty || !commentCache.shouldInsertCommentChars {
commentCache.lineStrings[lineIndex] = lineContent
}

if !commentCache.shouldInsertCommentChars {
commentCache.shouldInsertCommentChars = !lineContent
.trimmingCharacters(in: .whitespacesAndNewlines)
.starts(with: startCommentChars)
}
}

// Cache line information and calculate the shift range factor.
commentCache.lineInfos.append(lineInfo)
commentCache.shiftRangeFactors[lineIndex] = calculateShiftRangeFactor(
startCount: startCommentChars.count,
endCount: commentCache.endCommentChars?.count,
lineCount: offset
)
}

// Cache the ending line's information and calculate its shift range factor.
commentCache.lineInfos.append(endLineInfo)
commentCache.shiftRangeFactors[endLineInfo.index] = calculateShiftRangeFactor(
startCount: startCommentChars.count,
endCount: commentCache.endCommentChars?.count,
lineCount: intermediateLines.count
)
}
// swiftlint:enable cyclomatic_complexity

/// Calculates the shift range factor based on the counts of start and
/// end comment characters and the number of intermediate lines.
///
/// - Parameters:
/// - startCount: The number of characters in the start comment.
/// - endCount: An optional number of characters in the end comment. If `nil`, it is treated as 0.
/// - lineCount: The number of intermediate lines between the start and end comments.
///
/// - Returns: The computed shift range factor as an `Int`.
func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int {
let effectiveEndCount = endCount ?? 0
return (startCount + effectiveEndCount) * (lineCount + 1)
}
/// Toggles the presence of comment characters at the beginning and/or end
/// - Parameters:
/// - lineInfo: Contains information about the specific line, including its position and range.
/// - cache: A cache holding comment-related data such as the comment characters and line content.
private func toggleComment(lineInfo: TextLineStorage<TextLine>.TextLinePosition, cache: borrowing CommentCache) {
if cache.endCommentChars != nil {
toggleCommentAtEndOfLine(lineInfo: lineInfo, cache: cache)
toggleCommentAtBeginningOfLine(lineInfo: lineInfo, cache: cache)
} else {
// toggle comment on
textView.replaceCharacters(
in: NSRange(location: lineInfo.range.location + numWhitespaceChars, length: 0),
with: chars
)
toggleCommentAtBeginningOfLine(lineInfo: lineInfo, cache: cache)
}
}

/// Toggles a specific string of characters at the end of a specified line. (lineNumber is 1-indexed)
private func toggleCharsAtEndOfLine(chars: String, lineNumber: Int) {
guard let lineInfo = textView.layoutManager.textLineForIndex(lineNumber - 1), !lineInfo.range.isEmpty else {
/// Toggles the presence of comment characters at the beginning of a line in the text view.
/// - Parameters:
/// - lineInfo: Contains information about the specific line, including its position and range.
/// - cache: A cache holding comment-related data such as the comment characters and line content.
private func toggleCommentAtBeginningOfLine(
lineInfo: TextLineStorage<TextLine>.TextLinePosition,
cache: borrowing CommentCache
) {
// Ensure there are comment characters to toggle.
guard let startCommentChars = cache.startCommentChars else { return }

// Calculate the range shift based on cached factors, defaulting to 0 if unavailable.
let rangeShift = cache.shiftRangeFactors[lineInfo.index] ?? 0

// If we need to insert comment characters at the beginning of the line.
if cache.shouldInsertCommentChars {
guard let adjustedRange = lineInfo.range.shifted(by: rangeShift) else { return }
textView.replaceCharacters(
in: NSRange(location: adjustedRange.location, length: 0),
with: startCommentChars
)
return
}
let lineLastCharIndex = lineInfo.range.location + lineInfo.range.length - 1
let closeCommentLength = chars.count
let closeCommentRange = NSRange(
location: lineLastCharIndex - closeCommentLength,
length: closeCommentLength

// If we need to remove comment characters from the beginning of the line.
guard let adjustedRange = lineInfo.range.shifted(by: -rangeShift) else { return }

// Retrieve the current line's string content from the cache or the text view's storage.
guard let lineContent =
cache.lineStrings[lineInfo.index] ?? textView.textStorage.substring(from: adjustedRange) else { return }

// Find the index of the first non-whitespace character.
let firstNonWhitespaceIndex = lineContent.firstIndex(where: { !$0.isWhitespace }) ?? lineContent.startIndex
let leadingWhitespaceCount = lineContent.distance(from: lineContent.startIndex, to: firstNonWhitespaceIndex)

// Remove the comment characters from the beginning of the line.
textView.replaceCharacters(
in: NSRange(location: adjustedRange.location + leadingWhitespaceCount, length: startCommentChars.count),
with: ""
)
let lastCharsInLine = textView.textStorage.substring(from: closeCommentRange)
// toggle comment off
if lastCharsInLine == chars {
textView.replaceCharacters(
in: NSRange(location: lineLastCharIndex - closeCommentLength, length: closeCommentLength),
with: ""
)
}

/// Toggles the presence of comment characters at the end of a line in the text view.
/// - Parameters:
/// - lineInfo: Contains information about the specific line, including its position and range.
/// - cache: A cache holding comment-related data such as the comment characters and line content.
private func toggleCommentAtEndOfLine(
lineInfo: TextLineStorage<TextLine>.TextLinePosition,
cache: borrowing CommentCache
) {
// Ensure there are comment characters to toggle and the line is not empty.
guard let endingCommentChars = cache.endCommentChars else { return }
guard !lineInfo.range.isEmpty else { return }

// Calculate the range shift based on cached factors, defaulting to 0 if unavailable.
let rangeShift = cache.shiftRangeFactors[lineInfo.index] ?? 0

// Shift the line range by `rangeShift` if inserting comment characters, or by `-rangeShift` if removing them.
guard let adjustedRange = lineInfo.range.shifted(by: cache.shouldInsertCommentChars ? rangeShift : -rangeShift)
else { return }

// Retrieve the current line's string content from the cache or the text view's storage.
guard let lineContent =
cache.lineStrings[lineInfo.index] ?? textView.textStorage.substring(from: adjustedRange) else { return }

var endIndex = adjustedRange.upperBound

// If the last character is a newline, adjust the insertion point to before the newline.
if lineContent.last?.isNewline ?? false {
endIndex -= 1
}

if cache.shouldInsertCommentChars {
// Insert the comment characters at the calculated position.
textView.replaceCharacters(in: NSRange(location: endIndex, length: 0), with: endingCommentChars)
} else {
// toggle comment on
textView.replaceCharacters(in: NSRange(location: lineLastCharIndex, length: 0), with: chars)
// Remove the comment characters if they exist at the end of the line.
let commentRange = NSRange(
location: endIndex - endingCommentChars.count,
length: endingCommentChars.count
)
textView.replaceCharacters(in: commentRange, with: "")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// File.swift
//
//
// Created by Tommy Ludwig on 23.08.24.
//

import CodeEditTextView

extension TextViewController {
/// A cache used to store and manage comment-related information for lines in a text view.
/// This class helps in efficiently inserting or removing comment characters at specific line positions.
struct CommentCache: ~Copyable {
/// Holds necessary information like the lines range
var lineInfos: [TextLineStorage<TextLine>.TextLinePosition?] = []
/// Caches the content of lines by their indices. Populated only if comment characters need to be inserted.
var lineStrings: [Int: String] = [:]
/// Caches the shift range factors for lines based on their indices.
var shiftRangeFactors: [Int: Int] = [:]
/// Insertion is necessary only if at least one of the selected
/// lines does not already start with `startCommentChars`.
var shouldInsertCommentChars: Bool = false
var startCommentChars: String?
/// The characters used to end a comment.
/// This is applicable for languages (e.g., HTML)
/// that require a closing comment sequence at the end of the line.
var endCommentChars: String?
}
}

0 comments on commit 515b025

Please sign in to comment.