From 9281dcf5e214f6c3697d49a267887e3457c4f643 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Fri, 27 Sep 2024 21:41:49 +0200 Subject: [PATCH 01/10] feat: add indent lines --- .../TextViewController+IndentLines.swift | 100 ++++++++++++++++++ .../TextViewController+LoadView.swift | 12 ++- .../TextViewController+ToggleComment.swift | 4 +- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift new file mode 100644 index 000000000..7b6a6003d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -0,0 +1,100 @@ +// +// TextViewController+IndentLines.swift +// +// +// Created by Ludwig, Tom on 11.09.24. +// + +import CodeEditTextView +import AppKit + +extension TextViewController { + public func handleIndent(inwards: Bool = false) { + // Loop over cursor positions; if more than 1 don't check if multiple lines are selected + guard let cursorPosition = cursorPositions.first else { return } + + guard let lineIndexes = getHeighlightedLines(for: cursorPosition.range) else { + return + } + + // TODO: Get indentation chars form settings + + textView.undoManager?.beginUndoGrouping() + for lineIndex in lineIndexes { + if inwards { + indentInward(lineIndex: lineIndex) + } else { + indent(lineIndex: lineIndex) + } + } + textView.undoManager?.endUndoGrouping() + } + + private func getHeighlightedLines(for range: NSRange) -> [Int]? { + guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else { + return nil + } + var lines: [Int] = [startLineInfo.index] + + guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound), + endLineInfo.index != startLineInfo.index else { + return lines + } + if endLineInfo.index == startLineInfo.index + 1 { + lines.append(endLineInfo.index) + return lines + } + + return Array(startLineInfo.index...endLineInfo.index) + } + + private func indent(lineIndex: Int) { + guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { + return + } + + textView.replaceCharacters( + in: NSRange(location: lineInfo.range.lowerBound, length: 0), + with: " " + ) + } + + private func indentInward(lineIndex: Int) { + guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { + return + } + + guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { return } + + // get first chars when spaces are enabled just the amount of spaces + // if there is text in front count til the text + + // TODO: Remove hardcoded 4 + let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: 4) + guard removeSpacesCount != 0 else { return } + + textView.replaceCharacters( + in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount), + with: "" + ) + } + + func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { + var count = 0 + + for char in line { + if char == " " { + count += 1 + } else { + break // Stop as soon as a non-space character is encountered + } + + // Stop early if we've counted the max number of spaces + if count == maxCount { + break + } + } + + return count + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index fab08cf44..3358b11ba 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -117,10 +117,18 @@ extension TextViewController { guard self?.view.window?.firstResponder == self?.textView else { return event } let commandKey = NSEvent.ModifierFlags.command.rawValue let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue - if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" { + + switch (modifierFlags, event.charactersIgnoringModifiers) { + case (commandKey, "/"): self?.handleCommandSlash() return nil - } else { + case (commandKey, "["): + self?.handleIndent(inwards: true) + return nil + case (commandKey, "]"): + self?.handleIndent() + return nil + default: return event } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift index 9504aee29..898dbf9e4 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift @@ -38,7 +38,7 @@ extension TextViewController { /// - 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) { + private 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 @@ -126,7 +126,7 @@ extension TextViewController { /// - 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 { + private func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int { let effectiveEndCount = endCount ?? 0 return (startCount + effectiveEndCount) * (lineCount + 1) } From 0277db4f11a934da526d46a85b3abd266a32b466 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Sat, 5 Oct 2024 12:58:04 +0200 Subject: [PATCH 02/10] Iterate over cursor positions for multiple cursors. --- .../TextViewController+IndentLines.swift | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 7b6a6003d..bbdc7754a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -10,24 +10,24 @@ import AppKit extension TextViewController { public func handleIndent(inwards: Bool = false) { - // Loop over cursor positions; if more than 1 don't check if multiple lines are selected - guard let cursorPosition = cursorPositions.first else { return } - - guard let lineIndexes = getHeighlightedLines(for: cursorPosition.range) else { - return - } - - // TODO: Get indentation chars form settings - - textView.undoManager?.beginUndoGrouping() - for lineIndex in lineIndexes { - if inwards { - indentInward(lineIndex: lineIndex) - } else { - indent(lineIndex: lineIndex) + for cursorPosition in self.cursorPositions { + // get lineindex, i.e line-numbers+1 + guard let lineIndexes = getHeighlightedLines(for: cursorPosition.range) else { return } + + // TODO: Get indentation chars and count form settings + let spaceCount = 2 + let indentationChars = String(repeating: " ", count: spaceCount) + + textView.undoManager?.beginUndoGrouping() + for lineIndex in lineIndexes { + if inwards { + indentInward(lineIndex: lineIndex, spacesCount: indentationChars.count) + } else { + indent(lineIndex: lineIndex, indentationCharacters: indentationChars) + } } + textView.undoManager?.endUndoGrouping() } - textView.undoManager?.endUndoGrouping() } private func getHeighlightedLines(for range: NSRange) -> [Int]? { @@ -48,29 +48,27 @@ extension TextViewController { return Array(startLineInfo.index...endLineInfo.index) } - private func indent(lineIndex: Int) { + private func indent(lineIndex: Int, indentationCharacters: String) { guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } textView.replaceCharacters( in: NSRange(location: lineInfo.range.lowerBound, length: 0), - with: " " + with: indentationCharacters ) } - private func indentInward(lineIndex: Int) { + private func indentInward(lineIndex: Int, spacesCount: Int) { guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { return } - // get first chars when spaces are enabled just the amount of spaces - // if there is text in front count til the text - - // TODO: Remove hardcoded 4 - let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: 4) + // Count spaces until the required amount. + // E.g. if 4 are needed but only 3 are present, remove only those 3. + let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: spacesCount) guard removeSpacesCount != 0 else { return } textView.replaceCharacters( From c24c5338d2b64c7cd08fc26b600da407ba51e2d9 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Sat, 5 Oct 2024 13:20:06 +0200 Subject: [PATCH 03/10] Clean up code --- .../TextViewController+IndentLines.swift | 78 ++++++++----------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index bbdc7754a..c9a50e070 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -10,24 +10,22 @@ import AppKit extension TextViewController { public func handleIndent(inwards: Bool = false) { + // TODO: Get indentation chars and count form settings + let spaceCount = 2 + let indentationChars = String(repeating: " ", count: spaceCount) + + guard !cursorPositions.isEmpty else { return } + + textView.undoManager?.beginUndoGrouping() for cursorPosition in self.cursorPositions { // get lineindex, i.e line-numbers+1 - guard let lineIndexes = getHeighlightedLines(for: cursorPosition.range) else { return } + guard let lineIndexes = getHeighlightedLines(for: cursorPosition.range) else { continue } - // TODO: Get indentation chars and count form settings - let spaceCount = 2 - let indentationChars = String(repeating: " ", count: spaceCount) - - textView.undoManager?.beginUndoGrouping() - for lineIndex in lineIndexes { - if inwards { - indentInward(lineIndex: lineIndex, spacesCount: indentationChars.count) - } else { - indent(lineIndex: lineIndex, indentationCharacters: indentationChars) - } + for lineIndex in lineIndexes { + adjustIndentation(lineIndex: lineIndex, indentationChars: indentationChars, inwards: inwards) } - textView.undoManager?.endUndoGrouping() } + textView.undoManager?.endUndoGrouping() } private func getHeighlightedLines(for range: NSRange) -> [Int]? { @@ -47,29 +45,35 @@ extension TextViewController { return Array(startLineInfo.index...endLineInfo.index) } + private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) { + guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } - private func indent(lineIndex: Int, indentationCharacters: String) { - guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { - return + if inwards { + removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) + } else { + addIndentation(lineInfo: lineInfo, indentationChars: indentationChars) } + } + private func addIndentation( + lineInfo: TextLineStorage.TextLinePosition, + indentationChars: String + ) { textView.replaceCharacters( - in: NSRange(location: lineInfo.range.lowerBound, length: 0), - with: indentationCharacters - ) + in: NSRange(location: lineInfo.range.lowerBound, length: 0), + with: indentationChars + ) } - private func indentInward(lineIndex: Int, spacesCount: Int) { - guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { - return - } - + private func removeLeadingSpaces( + lineInfo: TextLineStorage.TextLinePosition, + spaceCount: Int + ) { guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { return } - // Count spaces until the required amount. - // E.g. if 4 are needed but only 3 are present, remove only those 3. - let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: spacesCount) - guard removeSpacesCount != 0 else { return } + let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: spaceCount) + + guard removeSpacesCount > 0 else { return } textView.replaceCharacters( in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount), @@ -78,21 +82,7 @@ extension TextViewController { } func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { - var count = 0 - - for char in line { - if char == " " { - count += 1 - } else { - break // Stop as soon as a non-space character is encountered - } - - // Stop early if we've counted the max number of spaces - if count == maxCount { - break - } - } - - return count + // Count leading spaces using prefix and `filter` + return line.prefix(maxCount).filter { $0 == " " }.count } } From 51b3353be9c28c1cf2e8653f7b4ee74c302c139d Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Sat, 5 Oct 2024 13:53:24 +0200 Subject: [PATCH 04/10] Handle Tab and Shift+Tab --- .../TextViewController+LoadView.swift | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 3358b11ba..73a100d28 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -115,22 +115,43 @@ extension TextViewController { } self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard self?.view.window?.firstResponder == self?.textView else { return event } - let commandKey = NSEvent.ModifierFlags.command.rawValue + + let tabKey: UInt16 = 0x30 let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue - switch (modifierFlags, event.charactersIgnoringModifiers) { - case (commandKey, "/"): - self?.handleCommandSlash() - return nil - case (commandKey, "["): - self?.handleIndent(inwards: true) - return nil - case (commandKey, "]"): - self?.handleIndent() + if event.keyCode == tabKey { + self?.handleTab(modifierFalgs: modifierFlags) return nil - default: - return event + } else { + return self?.handleCommand(event: event, modifierFlags: modifierFlags) } } } + func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { + let commandKey = NSEvent.ModifierFlags.command.rawValue + + switch (modifierFlags, event.charactersIgnoringModifiers) { + case (commandKey, "/"): + handleCommandSlash() + return nil + case (commandKey, "["): + handleIndent(inwards: true) + return nil + case (commandKey, "]"): + handleIndent() + return nil + case (_, _): + return event + } + } + + func handleTab(modifierFalgs: UInt) { + let shiftKey = NSEvent.ModifierFlags.shift.rawValue + + if modifierFalgs == shiftKey { + handleIndent(inwards: true) + } else { + handleIndent() + } + } } From f389bd4762f9d99d2fba5413b64e02f118457b51 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Tue, 8 Oct 2024 09:58:13 +0200 Subject: [PATCH 05/10] Add documentation and fix spelling error --- .../TextViewController+IndentLines.swift | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index c9a50e070..00c3e7d6a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -9,26 +9,36 @@ import CodeEditTextView import AppKit extension TextViewController { + /// Handels indentation and unindentation + /// + /// Handles the indentation of lines in the text view based on the current indentation option. + /// + /// This function assumes that the document is formatted according to the current selected indentation option. + /// It will not indent a tab character if spaces are selected, and vice versa. Ensure that the document is + /// properly formatted before invoking this function. + /// + /// - Parameter inwards: A Boolean flag indicating whether to outdent (default is `false`). public func handleIndent(inwards: Bool = false) { - // TODO: Get indentation chars and count form settings - let spaceCount = 2 - let indentationChars = String(repeating: " ", count: spaceCount) - + let indentationChars: String = indentOption.stringValue guard !cursorPositions.isEmpty else { return } textView.undoManager?.beginUndoGrouping() for cursorPosition in self.cursorPositions { // get lineindex, i.e line-numbers+1 - guard let lineIndexes = getHeighlightedLines(for: cursorPosition.range) else { continue } + guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue } - for lineIndex in lineIndexes { - adjustIndentation(lineIndex: lineIndex, indentationChars: indentationChars, inwards: inwards) + for lineIndex in lineIndexes { + adjustIndentation( + lineIndex: lineIndex, + indentationChars: indentationChars, + inwards: inwards + ) } } textView.undoManager?.endUndoGrouping() } - private func getHeighlightedLines(for range: NSRange) -> [Int]? { + private func getHighlightedLines(for range: NSRange) -> [Int]? { guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else { return nil } @@ -45,11 +55,16 @@ extension TextViewController { return Array(startLineInfo.index...endLineInfo.index) } + private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) { guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } if inwards { - removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) + if indentOption != .tab { + removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) + } else { + removeLeadingTab(lineInfo: lineInfo) + } } else { addIndentation(lineInfo: lineInfo, indentationChars: indentationChars) } @@ -81,7 +96,20 @@ extension TextViewController { ) } - func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { + private func removeLeadingTab(lineInfo: TextLineStorage.TextLinePosition) { + guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { + return + } + + if lineContent.first == "\t" { + textView.replaceCharacters( + in: NSRange(location: lineInfo.range.lowerBound, length: 1), + with: "" + ) + } + } + + private func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { // Count leading spaces using prefix and `filter` return line.prefix(maxCount).filter { $0 == " " }.count } From 677574af7ba0fa7a51582b1d2497421627116a08 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Tue, 8 Oct 2024 11:20:50 +0200 Subject: [PATCH 06/10] Ensure tab is handled as expected --- .../TextViewController+IndentLines.swift | 15 ++++++++ .../TextViewController+LoadView.swift | 13 +++++-- .../TextViewController+IndentTests.swift | 35 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 00c3e7d6a..122598d50 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -38,6 +38,21 @@ extension TextViewController { textView.undoManager?.endUndoGrouping() } + /// This method is used to handle tabs appropriately when multiple lines are selected, + /// allowing normal use of tabs. + /// + /// - Returns: A Boolean value indicating whether multiple lines are highlighted. + func multipleLinesHighlighted() -> Bool { + for cursorPosition in self.cursorPositions { + if let startLineInfo = textView.layoutManager.textLineForOffset(cursorPosition.range.lowerBound), + let endLineInfo = textView.layoutManager.textLineForOffset(cursorPosition.range.upperBound), + startLineInfo.index != endLineInfo.index { + return true + } + } + return false + } + private func getHighlightedLines(for range: NSRange) -> [Int]? { guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else { return nil diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 73a100d28..34eb0dd42 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -120,8 +120,7 @@ extension TextViewController { let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue if event.keyCode == tabKey { - self?.handleTab(modifierFalgs: modifierFlags) - return nil + return self?.handleTab(event: event, modifierFalgs: modifierFlags) } else { return self?.handleCommand(event: event, modifierFlags: modifierFlags) } @@ -145,13 +144,21 @@ extension TextViewController { } } - func handleTab(modifierFalgs: UInt) { + /// Handles the tab key event. + /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines + /// are highlighted and handles indenting accordingly. + /// + /// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method. + func handleTab(event: NSEvent, modifierFalgs: UInt) -> NSEvent? { let shiftKey = NSEvent.ModifierFlags.shift.rawValue if modifierFalgs == shiftKey { handleIndent(inwards: true) } else { + // Only allow tab to work if multiple lines are selected + guard multipleLinesHighlighted() else { return event } handleIndent() } + return nil } } diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift new file mode 100644 index 000000000..c241093bf --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift @@ -0,0 +1,35 @@ +// +// TextViewController+IndentTests.swift +// CodeEditSourceEditor +// +// Created by Ludwig, Tom on 08.10.24. +// + +import XCTest + +final class TextViewController_IndentTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} From 63e644abb0eb0dc418c4e82c08cb8a52f4cbac0c Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Tue, 8 Oct 2024 11:21:16 +0200 Subject: [PATCH 07/10] Add tests --- .../TextViewController+IndentTests.swift | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift index c241093bf..45113436d 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift @@ -6,30 +6,100 @@ // import XCTest +@testable import CodeEditSourceEditor -final class TextViewController_IndentTests: XCTestCase { +final class TextViewControllerIndentTests: XCTestCase { + var controller: TextViewController! override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + controller = Mock.textViewController(theme: Mock.theme()) + + controller.loadView() + } + + func testHandleIndentWithSpacesInwards() { + controller.setText(" This is a test string") + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.cursorPositions = cursorPositions + controller.handleIndent(inwards: true) + + XCTAssertEqual(controller.string, "This is a test string") + + // Normally, 4 spaces are used for indentation; however, now we only insert 2 leading spaces. + // The outcome should be the same, though. + controller.setText(" This is a test string") + controller.cursorPositions = cursorPositions + controller.handleIndent(inwards: true) + + XCTAssertEqual(controller.string, "This is a test string") + } + + func testHandleIndentWithSpacesOutwards() { + controller.setText("This is a test string") + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.cursorPositions = cursorPositions + + controller.handleIndent(inwards: false) + + XCTAssertEqual(controller.string, " This is a test string") + } + + func testHandleIndentWithTabsInwards() { + controller.setText("\tThis is a test string") + controller.indentOption = .tab + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.cursorPositions = cursorPositions + + controller.handleIndent(inwards: true) + + XCTAssertEqual(controller.string, "This is a test string") } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + func testHandleIndentWithTabsOutwards() { + controller.setText("This is a test string") + controller.indentOption = .tab + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.cursorPositions = cursorPositions + + controller.handleIndent() + + // Normally, we expect nothing to happen because only one line is selected. + // However, this logic is not handled inside `handleIndent`. + XCTAssertEqual(controller.string, "\tThis is a test string") } - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + func testHandleIndentMultiLine() { + controller.indentOption = .tab + controller.setText("This is a test string\nWith multiple lines\nAnd some indentation") + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))] + controller.cursorPositions = cursorPositions + + controller.handleIndent() + let expectedString = "\tThis is a test string\nWith multiple lines\nAnd some indentation" + XCTAssertEqual(controller.string, expectedString) } - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } + func testHandleInwardIndentMultiLine() { + controller.indentOption = .tab + controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] + controller.cursorPositions = cursorPositions + + controller.handleIndent(inwards: true) + let expectedString = "This is a test string\nWith multiple lines\nAnd some indentation" + XCTAssertEqual(controller.string, expectedString) } + func testMultipleLinesHighlighted() { + controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") + var cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] + controller.cursorPositions = cursorPositions + + XCTAssert(controller.multipleLinesHighlighted()) + + cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))] + controller.cursorPositions = cursorPositions + + XCTAssertFalse(controller.multipleLinesHighlighted()) + } } From 57daf76f0b99f6db2abcf34910016bbcd11bb816 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 10 Oct 2024 11:44:07 +0200 Subject: [PATCH 08/10] Add better countLeadingSpacesUpTo --- .../TextViewController+IndentLines.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 122598d50..05f6a61ea 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -124,8 +124,20 @@ extension TextViewController { } } - private func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { - // Count leading spaces using prefix and `filter` - return line.prefix(maxCount).filter { $0 == " " }.count + func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { + var count = 0 + for char in line { + if char == " " { + count += 1 + } else { + break // Stop as soon as a non-space character is encountered + } + // Stop early if we've counted the max number of spaces + if count == maxCount { + break + } + } + + return count } } From 57228f4954f637265c756896c218f891d7ad2c1c Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 10 Oct 2024 11:45:51 +0200 Subject: [PATCH 09/10] Apply suggestions from code review Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> --- .../Controller/TextViewController+IndentLines.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 05f6a61ea..47ff3782a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -9,7 +9,7 @@ import CodeEditTextView import AppKit extension TextViewController { - /// Handels indentation and unindentation +/// Handles indentation and unindentation /// /// Handles the indentation of lines in the text view based on the current indentation option. /// @@ -23,7 +23,7 @@ extension TextViewController { guard !cursorPositions.isEmpty else { return } textView.undoManager?.beginUndoGrouping() - for cursorPosition in self.cursorPositions { +for cursorPosition in self.cursorPositions.reversed() { // get lineindex, i.e line-numbers+1 guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue } From a1e048c7e142a856bd8ca88c3f6a0d609c09ecd0 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 10 Oct 2024 13:53:29 +0200 Subject: [PATCH 10/10] Use ClosedRange instead of Array --- .../Controller/TextViewController+IndentLines.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 47ff3782a..8d690b76f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -9,7 +9,7 @@ import CodeEditTextView import AppKit extension TextViewController { -/// Handles indentation and unindentation + /// Handles indentation and unindentation /// /// Handles the indentation of lines in the text view based on the current indentation option. /// @@ -53,22 +53,17 @@ for cursorPosition in self.cursorPositions.reversed() { return false } - private func getHighlightedLines(for range: NSRange) -> [Int]? { + private func getHighlightedLines(for range: NSRange) -> ClosedRange? { guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else { return nil } - var lines: [Int] = [startLineInfo.index] guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound), endLineInfo.index != startLineInfo.index else { - return lines - } - if endLineInfo.index == startLineInfo.index + 1 { - lines.append(endLineInfo.index) - return lines + return startLineInfo.index...startLineInfo.index } - return Array(startLineInfo.index...endLineInfo.index) + return startLineInfo.index...endLineInfo.index } private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) {