Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/"):
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}