Skip to content

Commit be46b2f

Browse files
Add the ability to move selected lines up and down
1 parent 7830486 commit be46b2f

File tree

3 files changed

+185
-0
lines changed

3 files changed

+185
-0
lines changed

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ extension TextViewController {
201201

202202
func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
203203
let commandKey = NSEvent.ModifierFlags.command.rawValue
204+
let commandOptionKey = NSEvent.ModifierFlags.command.union(.option).rawValue
204205

205206
switch (modifierFlags, event.charactersIgnoringModifiers) {
206207
case (commandKey, "/"):
@@ -209,9 +210,15 @@ extension TextViewController {
209210
case (commandKey, "["):
210211
handleIndent(inwards: true)
211212
return nil
213+
case (commandOptionKey, "["):
214+
moveLinesUp()
215+
return nil
212216
case (commandKey, "]"):
213217
handleIndent()
214218
return nil
219+
case (commandOptionKey, "]"):
220+
moveLinesDown()
221+
return nil
215222
case (commandKey, "f"):
216223
_ = self.textView.resignFirstResponder()
217224
self.findViewController?.showFindPanel()
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// TextViewController+MoveLines.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Bogdan Belogurov on 01/06/2025.
6+
//
7+
8+
import Foundation
9+
10+
extension TextViewController {
11+
/// Moves the selected lines up by one line.
12+
func moveLinesUp() {
13+
guard !cursorPositions.isEmpty else { return }
14+
15+
textView.undoManager?.beginUndoGrouping()
16+
17+
textView.editSelections { textView, selection in
18+
guard let lineIndexes = getOverlappingLines(for: selection.range) else { return }
19+
let lowerBound = lineIndexes.lowerBound
20+
guard
21+
lowerBound > .zero,
22+
let prevLineInfo = textView.layoutManager.textLineForIndex(lowerBound - 1),
23+
let prevString = textView.textStorage.substring(from: prevLineInfo.range),
24+
let lastSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.upperBound)
25+
else { return }
26+
27+
textView.insertString(prevString, at: lastSelectedString.range.upperBound)
28+
textView.replaceCharacters(in: [prevLineInfo.range], with: String())
29+
30+
let rangeToSelect = NSRange(
31+
start: prevLineInfo.range.lowerBound,
32+
end: lastSelectedString.range.location - prevLineInfo.range.length + lastSelectedString.range.length
33+
)
34+
35+
setCursorPositions([CursorPosition(range: rangeToSelect)], scrollToVisible: true)
36+
}
37+
38+
textView.undoManager?.endUndoGrouping()
39+
}
40+
41+
/// Moves the selected lines down by one line.
42+
public func moveLinesDown() {
43+
guard !cursorPositions.isEmpty else { return }
44+
45+
textView.undoManager?.beginUndoGrouping()
46+
47+
textView.editSelections { textView, selection in
48+
guard let lineIndexes = getOverlappingLines(for: selection.range) else { return }
49+
let totalLines = textView.layoutManager.lineCount
50+
let upperBound = lineIndexes.upperBound
51+
guard
52+
upperBound + 1 < totalLines,
53+
let nextLineInfo = textView.layoutManager.textLineForIndex(upperBound + 1),
54+
let nextString = textView.textStorage.substring(from: nextLineInfo.range),
55+
let firstSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound)
56+
else { return }
57+
58+
textView.replaceCharacters(in: [nextLineInfo.range], with: String())
59+
textView.insertString(nextString, at: firstSelectedString.range.lowerBound)
60+
61+
let rangeToSelect = NSRange(
62+
start: firstSelectedString.range.location + nextLineInfo.range.length,
63+
end: nextLineInfo.range.upperBound
64+
)
65+
66+
setCursorPositions([CursorPosition(range: rangeToSelect)], scrollToVisible: true)
67+
}
68+
69+
textView.undoManager?.endUndoGrouping()
70+
}
71+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//
2+
// TextViewController+MoveLinesTests.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Bogdan Belogurov on 01/06/2025.
6+
//
7+
8+
import XCTest
9+
@testable import CodeEditSourceEditor
10+
@testable import CodeEditTextView
11+
import CustomDump
12+
13+
final class TextViewControllerMoveLinesTests: XCTestCase {
14+
var controller: TextViewController!
15+
16+
override func setUpWithError() throws {
17+
controller = Mock.textViewController(theme: Mock.theme())
18+
19+
controller.loadView()
20+
}
21+
22+
func testHandleMoveLinesUpForSingleLine() {
23+
let strings: [(NSString, Int)] = [
24+
("This is a test string\n", 0),
25+
("With multiple lines\n", 22)
26+
]
27+
for (insertedString, location) in strings {
28+
controller.textView.replaceCharacters(
29+
in: [NSRange(location: location, length: 0)],
30+
with: insertedString as String
31+
)
32+
}
33+
34+
let cursorRange = NSRange(location: 40, length: 0)
35+
controller.textView.selectionManager.textSelections = [.init(range: cursorRange)]
36+
controller.cursorPositions = [CursorPosition(range: cursorRange)]
37+
38+
controller.moveLinesUp()
39+
let expectedString = "With multiple lines\nThis is a test string\n"
40+
expectNoDifference(controller.string, expectedString)
41+
}
42+
43+
func testHandleMoveLinesDownForSingleLine() {
44+
let strings: [(NSString, Int)] = [
45+
("This is a test string\n", 0),
46+
("With multiple lines\n", 22)
47+
]
48+
for (insertedString, location) in strings {
49+
controller.textView.replaceCharacters(
50+
in: [NSRange(location: location, length: 0)],
51+
with: insertedString as String
52+
)
53+
}
54+
55+
let cursorRange = NSRange(location: 0, length: 0)
56+
controller.textView.selectionManager.textSelections = [.init(range: cursorRange)]
57+
controller.cursorPositions = [CursorPosition(range: cursorRange)]
58+
59+
controller.moveLinesDown()
60+
let expectedString = "With multiple lines\nThis is a test string\n"
61+
expectNoDifference(controller.string, expectedString)
62+
}
63+
64+
func testHandleMoveLinesUpForMultiLine() {
65+
let strings: [(NSString, Int)] = [
66+
("This is a test string\n", 0),
67+
("With multiple lines\n", 22),
68+
("And additional info\n", 42)
69+
]
70+
for (insertedString, location) in strings {
71+
controller.textView.replaceCharacters(
72+
in: [NSRange(location: location, length: 0)],
73+
with: insertedString as String
74+
)
75+
}
76+
77+
let cursorRange = NSRange(location: 40, length: 15)
78+
controller.textView.selectionManager.textSelections = [.init(range: cursorRange)]
79+
controller.cursorPositions = [CursorPosition(range: cursorRange)]
80+
81+
controller.moveLinesUp()
82+
let expectedString = "With multiple lines\nAnd additional info\nThis is a test string\n"
83+
expectNoDifference(controller.string, expectedString)
84+
}
85+
86+
func testHandleMoveLinesDownForMultiLine() {
87+
let strings: [(NSString, Int)] = [
88+
("This is a test string\n", 0),
89+
("With multiple lines\n", 22),
90+
("And additional info\n", 42)
91+
]
92+
for (insertedString, location) in strings {
93+
controller.textView.replaceCharacters(
94+
in: [NSRange(location: location, length: 0)],
95+
with: insertedString as String
96+
)
97+
}
98+
99+
let cursorRange = NSRange(location: 0, length: 30)
100+
controller.textView.selectionManager.textSelections = [.init(range: cursorRange)]
101+
controller.cursorPositions = [CursorPosition(range: cursorRange)]
102+
103+
controller.moveLinesDown()
104+
let expectedString = "And additional info\nThis is a test string\nWith multiple lines\n"
105+
expectNoDifference(controller.string, expectedString)
106+
}
107+
}

0 commit comments

Comments
 (0)