diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 9b7028a9e..f3b2fd7a2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -201,6 +201,7 @@ extension TextViewController { func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue + let commandOptionKey = NSEvent.ModifierFlags.command.union(.option).rawValue switch (modifierFlags, event.charactersIgnoringModifiers) { case (commandKey, "/"): @@ -209,9 +210,15 @@ extension TextViewController { case (commandKey, "["): handleIndent(inwards: true) return nil + case (commandOptionKey, "["): + moveLinesUp() + return nil case (commandKey, "]"): handleIndent() return nil + case (commandOptionKey, "]"): + moveLinesDown() + return nil case (commandKey, "f"): _ = self.textView.resignFirstResponder() self.findViewController?.showFindPanel() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+MoveLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+MoveLines.swift new file mode 100644 index 000000000..e96144444 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+MoveLines.swift @@ -0,0 +1,71 @@ +// +// TextViewController+MoveLines.swift +// CodeEditSourceEditor +// +// Created by Bogdan Belogurov on 01/06/2025. +// + +import Foundation + +extension TextViewController { + /// Moves the selected lines up by one line. + public func moveLinesUp() { + guard !cursorPositions.isEmpty else { return } + + textView.undoManager?.beginUndoGrouping() + + textView.editSelections { textView, selection in + guard let lineIndexes = getOverlappingLines(for: selection.range) else { return } + let lowerBound = lineIndexes.lowerBound + guard lowerBound > .zero, + let prevLineInfo = textView.layoutManager.textLineForIndex(lowerBound - 1), + let prevString = textView.textStorage.substring(from: prevLineInfo.range), + let lastSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.upperBound) else { + return + } + + textView.insertString(prevString, at: lastSelectedString.range.upperBound) + textView.replaceCharacters(in: [prevLineInfo.range], with: String()) + + let rangeToSelect = NSRange( + start: prevLineInfo.range.lowerBound, + end: lastSelectedString.range.location - prevLineInfo.range.length + lastSelectedString.range.length + ) + + setCursorPositions([CursorPosition(range: rangeToSelect)], scrollToVisible: true) + } + + textView.undoManager?.endUndoGrouping() + } + + /// Moves the selected lines down by one line. + public func moveLinesDown() { + guard !cursorPositions.isEmpty else { return } + + textView.undoManager?.beginUndoGrouping() + + textView.editSelections { textView, selection in + guard let lineIndexes = getOverlappingLines(for: selection.range) else { return } + let totalLines = textView.layoutManager.lineCount + let upperBound = lineIndexes.upperBound + guard upperBound + 1 < totalLines, + let nextLineInfo = textView.layoutManager.textLineForIndex(upperBound + 1), + let nextString = textView.textStorage.substring(from: nextLineInfo.range), + let firstSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound) else { + return + } + + textView.replaceCharacters(in: [nextLineInfo.range], with: String()) + textView.insertString(nextString, at: firstSelectedString.range.lowerBound) + + let rangeToSelect = NSRange( + start: firstSelectedString.range.location + nextLineInfo.range.length, + end: nextLineInfo.range.upperBound + ) + + setCursorPositions([CursorPosition(range: rangeToSelect)], scrollToVisible: true) + } + + textView.undoManager?.endUndoGrouping() + } +} diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift new file mode 100644 index 000000000..738a647b3 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift @@ -0,0 +1,107 @@ +// +// TextViewController+MoveLinesTests.swift +// CodeEditSourceEditor +// +// Created by Bogdan Belogurov on 01/06/2025. +// + +import XCTest +@testable import CodeEditSourceEditor +@testable import CodeEditTextView +import CustomDump + +final class TextViewControllerMoveLinesTests: XCTestCase { + var controller: TextViewController! + + override func setUpWithError() throws { + controller = Mock.textViewController(theme: Mock.theme()) + + controller.loadView() + } + + func testHandleMoveLinesUpForSingleLine() { + let strings: [(NSString, Int)] = [ + ("This is a test string\n", 0), + ("With multiple lines\n", 22) + ] + for (insertedString, location) in strings { + controller.textView.replaceCharacters( + in: [NSRange(location: location, length: 0)], + with: insertedString as String + ) + } + + let cursorRange = NSRange(location: 40, length: 0) + controller.textView.selectionManager.textSelections = [.init(range: cursorRange)] + controller.cursorPositions = [CursorPosition(range: cursorRange)] + + controller.moveLinesUp() + let expectedString = "With multiple lines\nThis is a test string\n" + expectNoDifference(controller.string, expectedString) + } + + func testHandleMoveLinesDownForSingleLine() { + let strings: [(NSString, Int)] = [ + ("This is a test string\n", 0), + ("With multiple lines\n", 22) + ] + for (insertedString, location) in strings { + controller.textView.replaceCharacters( + in: [NSRange(location: location, length: 0)], + with: insertedString as String + ) + } + + let cursorRange = NSRange(location: 0, length: 0) + controller.textView.selectionManager.textSelections = [.init(range: cursorRange)] + controller.cursorPositions = [CursorPosition(range: cursorRange)] + + controller.moveLinesDown() + let expectedString = "With multiple lines\nThis is a test string\n" + expectNoDifference(controller.string, expectedString) + } + + func testHandleMoveLinesUpForMultiLine() { + let strings: [(NSString, Int)] = [ + ("This is a test string\n", 0), + ("With multiple lines\n", 22), + ("And additional info\n", 42) + ] + for (insertedString, location) in strings { + controller.textView.replaceCharacters( + in: [NSRange(location: location, length: 0)], + with: insertedString as String + ) + } + + let cursorRange = NSRange(location: 40, length: 15) + controller.textView.selectionManager.textSelections = [.init(range: cursorRange)] + controller.cursorPositions = [CursorPosition(range: cursorRange)] + + controller.moveLinesUp() + let expectedString = "With multiple lines\nAnd additional info\nThis is a test string\n" + expectNoDifference(controller.string, expectedString) + } + + func testHandleMoveLinesDownForMultiLine() { + let strings: [(NSString, Int)] = [ + ("This is a test string\n", 0), + ("With multiple lines\n", 22), + ("And additional info\n", 42) + ] + for (insertedString, location) in strings { + controller.textView.replaceCharacters( + in: [NSRange(location: location, length: 0)], + with: insertedString as String + ) + } + + let cursorRange = NSRange(location: 0, length: 30) + controller.textView.selectionManager.textSelections = [.init(range: cursorRange)] + controller.cursorPositions = [CursorPosition(range: cursorRange)] + + controller.moveLinesDown() + let expectedString = "And additional info\nThis is a test string\nWith multiple lines\n" + expectNoDifference(controller.string, expectedString) + } +}