Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(comments): Add Support for Commenting Multiple Lines #261

Merged
merged 11 commits into from
Aug 23, 2024
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?
}
}
Loading