diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift new file mode 100644 index 000000000..8d690b76f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -0,0 +1,138 @@ +// +// TextViewController+IndentLines.swift +// +// +// Created by Ludwig, Tom on 11.09.24. +// + +import CodeEditTextView +import AppKit + +extension TextViewController { + /// Handles 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) { + let indentationChars: String = indentOption.stringValue + guard !cursorPositions.isEmpty else { return } + + textView.undoManager?.beginUndoGrouping() +for cursorPosition in self.cursorPositions.reversed() { + // get lineindex, i.e line-numbers+1 + guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue } + + for lineIndex in lineIndexes { + adjustIndentation( + lineIndex: lineIndex, + indentationChars: indentationChars, + inwards: inwards + ) + } + } + 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) -> ClosedRange? { + guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else { + return nil + } + + guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound), + endLineInfo.index != startLineInfo.index else { + return startLineInfo.index...startLineInfo.index + } + + return startLineInfo.index...endLineInfo.index + } + + private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) { + guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } + + if inwards { + if indentOption != .tab { + removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) + } else { + removeLeadingTab(lineInfo: lineInfo) + } + } 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: indentationChars + ) + } + + private func removeLeadingSpaces( + lineInfo: TextLineStorage.TextLinePosition, + spaceCount: Int + ) { + guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { return } + + let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: spaceCount) + + guard removeSpacesCount > 0 else { return } + + textView.replaceCharacters( + in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount), + with: "" + ) + } + + 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: "" + ) + } + } + + 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..34eb0dd42 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -115,14 +115,50 @@ 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 - if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" { - self?.handleCommandSlash() - return nil + + if event.keyCode == tabKey { + return self?.handleTab(event: event, modifierFalgs: modifierFlags) } else { - return event + 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 + } + } + + /// 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/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) } diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift new file mode 100644 index 000000000..45113436d --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift @@ -0,0 +1,105 @@ +// +// TextViewController+IndentTests.swift +// CodeEditSourceEditor +// +// Created by Ludwig, Tom on 08.10.24. +// + +import XCTest +@testable import CodeEditSourceEditor + +final class TextViewControllerIndentTests: XCTestCase { + var controller: TextViewController! + + override func setUpWithError() throws { + 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") + } + + 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 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 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()) + } +}