From 515b0254cb0f2faf3dc6437532745754a52520a3 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Fri, 23 Aug 2024 23:59:20 +0200 Subject: [PATCH] fix(comments): Add Support for Commenting Multiple Lines (#261) ### Description This PR lets you highlight multiple lines and comment them all out at once. ### Related Issues * closes #253 ### 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/user-attachments/assets/97ae52d5-0fb0-4b25-90e6-2bbc18769856 --- .../project.pbxproj | 16 +- .../TextViewController+LoadView.swift | 2 +- .../TextViewController+ToggleComment.swift | 254 ++++++++++++++---- ...extViewController+ToggleCommentCache.swift | 29 ++ 4 files changed, 244 insertions(+), 57 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleCommentCache.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 27ea9f902..2ce04dadc 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 = ""; + }; 6C1365212B8A7B94004A1D18 = { isa = PBXGroup; children = ( 6C1365422B8A7BFE004A1D18 /* CodeEditSourceEditor */, 6C13652C2B8A7B94004A1D18 /* CodeEditSourceEditorExample */, 6C13652B2B8A7B94004A1D18 /* Products */, + 61621C5F2C74FB2200494A4A /* Frameworks */, ); sourceTree = ""; }; @@ -131,7 +139,7 @@ ); name = CodeEditSourceEditorExample; packageProductDependencies = ( - 6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */, + 61621C602C74FB2200494A4A /* CodeEditSourceEditor */, ); productName = CodeEditSourceEditorExample; productReference = 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */; @@ -400,7 +408,7 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - 6C1365402B8A7BC3004A1D18 /* CodeEditSourceEditor */ = { + 61621C602C74FB2200494A4A /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 8b126b1d3..fab08cf44 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -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 diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift index feef6be34..9504aee29 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift @@ -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 ) - // 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).. 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.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.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.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: "") } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleCommentCache.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleCommentCache.swift new file mode 100644 index 000000000..5a005127d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleCommentCache.swift @@ -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.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? + } +}