Skip to content

Commit 598f55a

Browse files
committed
Merge branch 'main' into feat/code-folding
2 parents ba30234 + 6f1ceca commit 598f55a

28 files changed

+1056
-221
lines changed

.github/workflows/CI-pull-request.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: CI - Pull Request
22
on:
33
pull_request:
4-
branches:
4+
branches:
55
- 'main'
66
workflow_dispatch:
77
jobs:

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Documents/CodeEditSourceEditorExampleDocument.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
import SwiftUI
99
import UniformTypeIdentifiers
1010

11-
struct CodeEditSourceEditorExampleDocument: FileDocument {
12-
var text: String
11+
struct CodeEditSourceEditorExampleDocument: FileDocument, @unchecked Sendable {
12+
enum DocumentError: Error {
13+
case failedToEncode
14+
}
15+
16+
var text: NSTextStorage
1317

14-
init(text: String = "") {
18+
init(text: NSTextStorage = NSTextStorage(string: "")) {
1519
self.text = text
1620
}
1721

@@ -25,11 +29,31 @@ struct CodeEditSourceEditorExampleDocument: FileDocument {
2529
guard let data = configuration.file.regularFileContents else {
2630
throw CocoaError(.fileReadCorruptFile)
2731
}
28-
text = String(decoding: data, as: UTF8.self)
32+
var nsString: NSString?
33+
NSString.stringEncoding(
34+
for: data,
35+
encodingOptions: [
36+
// Fail if using lossy encoding.
37+
.allowLossyKey: false,
38+
// In a real app, you'll want to handle more than just this encoding scheme. Check out CodeEdit's
39+
// implementation for a more involved solution.
40+
.suggestedEncodingsKey: [NSUTF8StringEncoding],
41+
.useOnlySuggestedEncodingsKey: true
42+
],
43+
convertedString: &nsString,
44+
usedLossyConversion: nil
45+
)
46+
if let nsString {
47+
self.text = NSTextStorage(string: nsString as String)
48+
} else {
49+
fatalError("Failed to read file")
50+
}
2951
}
3052

3153
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
32-
let data = Data(text.utf8)
54+
guard let data = (text.string as NSString?)?.data(using: NSUTF8StringEncoding) else {
55+
throw DocumentError.failedToEncode
56+
}
3357
return .init(regularFileWithContents: data)
3458
}
3559
}

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ struct ContentView: View {
4040
var body: some View {
4141
GeometryReader { proxy in
4242
CodeEditSourceEditor(
43-
$document.text,
43+
document.text,
4444
language: language,
4545
theme: theme,
4646
font: font,

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ struct StatusBar: View {
3131
var body: some View {
3232
HStack {
3333
Menu {
34-
IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty)
34+
IndentPicker(indentOption: $indentOption, enabled: document.text.length == 0)
3535
.buttonStyle(.borderless)
3636
Toggle("Wrap Lines", isOn: $wrapLines)
3737
Toggle("Show Minimap", isOn: $showMinimap)
@@ -108,8 +108,8 @@ struct StatusBar: View {
108108
guard let fileURL else { return nil }
109109
return CodeLanguage.detectLanguageFrom(
110110
url: fileURL,
111-
prefixBuffer: document.text.getFirstLines(5),
112-
suffixBuffer: document.text.getLastLines(5)
111+
prefixBuffer: document.text.string.getFirstLines(5),
112+
suffixBuffer: document.text.string.getLastLines(5)
113113
)
114114
}
115115

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+Lifecycle.swift

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

209209
func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
210210
let commandKey = NSEvent.ModifierFlags.command.rawValue
211+
let commandOptionKey = NSEvent.ModifierFlags.command.union(.option).rawValue
211212

212213
switch (modifierFlags, event.charactersIgnoringModifiers) {
213214
case (commandKey, "/"):
@@ -216,9 +217,15 @@ extension TextViewController {
216217
case (commandKey, "["):
217218
handleIndent(inwards: true)
218219
return nil
220+
case (commandOptionKey, "["):
221+
moveLinesUp()
222+
return nil
219223
case (commandKey, "]"):
220224
handleIndent()
221225
return nil
226+
case (commandOptionKey, "]"):
227+
moveLinesDown()
228+
return nil
222229
case (commandKey, "f"):
223230
_ = self.textView.resignFirstResponder()
224231
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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// FindMethod.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Austin Condiff on 5/2/25.
6+
//
7+
8+
enum FindMethod: CaseIterable {
9+
case contains
10+
case matchesWord
11+
case startsWith
12+
case endsWith
13+
case regularExpression
14+
15+
var displayName: String {
16+
switch self {
17+
case .contains:
18+
return "Contains"
19+
case .matchesWord:
20+
return "Matches Word"
21+
case .startsWith:
22+
return "Starts With"
23+
case .endsWith:
24+
return "Ends With"
25+
case .regularExpression:
26+
return "Regular Expression"
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)