Skip to content

Commit 978a35a

Browse files
Merge branch 'main' into use-better-storage-example
2 parents bc51f99 + 162f3d8 commit 978a35a

File tree

5 files changed

+240
-3
lines changed

5 files changed

+240
-3
lines changed

Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ extension TextViewController {
6767
var selectionIndex = 0
6868
textView.editSelections { textView, selection in
6969
// get lineindex, i.e line-numbers+1
70-
guard let lineIndexes = getHighlightedLines(for: selection.range) else { return }
70+
guard let lineIndexes = getOverlappingLines(for: selection.range) else { return }
7171

7272
adjustIndentation(lineIndexes: lineIndexes, inwards: inwards)
7373

@@ -129,7 +129,24 @@ extension TextViewController {
129129
return false
130130
}
131131

132-
private func getHighlightedLines(for range: NSRange) -> ClosedRange<Int>? {
132+
/// Find the range of lines overlapping a text range.
133+
///
134+
/// Use this method to determine what lines to apply a text transformation on using a text selection. For instance,
135+
/// when indenting a selected line.
136+
///
137+
/// Does not determine the *visible* lines, which is a very slight change from most
138+
/// ``CodeEditTextView/TextLayoutManager`` APIs.
139+
/// Given the text:
140+
/// ```
141+
/// A
142+
/// B
143+
/// ```
144+
/// This method will return lines `0...0` for the text range `0..<2`. The layout manager might return lines
145+
/// `0...1`, as the text range contains the newline, which appears *visually* in line index `1`.
146+
///
147+
/// - Parameter range: The text range in the document to find contained lines for.
148+
/// - Returns: A closed range of line indexes (0-indexed) where each line is overlapping the given text range.
149+
func getOverlappingLines(for range: NSRange) -> ClosedRange<Int>? {
133150
guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else {
134151
return nil
135152
}
@@ -139,7 +156,16 @@ extension TextViewController {
139156
return startLineInfo.index...startLineInfo.index
140157
}
141158

142-
return startLineInfo.index...endLineInfo.index
159+
// If we've selected up to the start of a line (just over the newline character), the layout manager tells us
160+
// we've selected the next line. However, we aren't overlapping the *text line* with that range, so we
161+
// decrement it if it's not the end of the document
162+
var endLineIndex = endLineInfo.index
163+
if endLineInfo.range.lowerBound == range.upperBound
164+
&& endLineInfo.index != textView.layoutManager.lineCount - 1 {
165+
endLineIndex -= 1
166+
}
167+
168+
return startLineInfo.index...endLineIndex
143169
}
144170

145171
private func adjustIndentation(lineIndexes: ClosedRange<Int>, inwards: Bool) {

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+
public 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 lowerBound > .zero,
21+
let prevLineInfo = textView.layoutManager.textLineForIndex(lowerBound - 1),
22+
let prevString = textView.textStorage.substring(from: prevLineInfo.range),
23+
let lastSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.upperBound) else {
24+
return
25+
}
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 upperBound + 1 < totalLines,
52+
let nextLineInfo = textView.layoutManager.textLineForIndex(upperBound + 1),
53+
let nextString = textView.textStorage.substring(from: nextLineInfo.range),
54+
let firstSelectedString = textView.layoutManager.textLineForIndex(lineIndexes.lowerBound) else {
55+
return
56+
}
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+
}

Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,5 +461,31 @@ final class TextViewControllerTests: XCTestCase {
461461
XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth)
462462
XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth)
463463
}
464+
465+
// MARK: - Get Overlapping Lines
466+
467+
func test_getOverlappingLines() {
468+
controller.setText("A\nB\nC")
469+
470+
// Select the entire first line, shouldn't include the second line
471+
var lines = controller.getOverlappingLines(for: NSRange(location: 0, length: 2))
472+
XCTAssertEqual(0...0, lines)
473+
474+
// Select the first char of the second line
475+
lines = controller.getOverlappingLines(for: NSRange(location: 0, length: 3))
476+
XCTAssertEqual(0...1, lines)
477+
478+
// Select the newline in the first line, and part of the second line
479+
lines = controller.getOverlappingLines(for: NSRange(location: 1, length: 2))
480+
XCTAssertEqual(0...1, lines)
481+
482+
// Select until the end of the document
483+
lines = controller.getOverlappingLines(for: NSRange(location: 3, length: 2))
484+
XCTAssertEqual(1...2, lines)
485+
486+
// Select just the last line of the document
487+
lines = controller.getOverlappingLines(for: NSRange(location: 4, length: 1))
488+
XCTAssertEqual(2...2, lines)
489+
}
464490
}
465491
// swiftlint:enable all

0 commit comments

Comments
 (0)