Skip to content

Commit 15798b8

Browse files
authored
Fix Multi-line and Multi-selection Indentation and Keyboard Shortcuts (CodeEditApp#305)
### Description Updates indentation for multi line and multi selection indentation. Selection will remain after indentation is completed. ### Related Issues * CodeEditApp#304 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/d00ee2c7-db60-4dfc-8af2-49bf66016b83
1 parent af2d71b commit 15798b8

File tree

9 files changed

+241
-34
lines changed

9 files changed

+241
-34
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */; };
1011
61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; };
1112
61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */; };
1213
61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */; };
@@ -22,6 +23,7 @@
2223
/* End PBXBuildFile section */
2324

2425
/* Begin PBXFileReference section */
26+
1CB30C392DAA1C28008058A7 /* IndentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndentPicker.swift; sourceTree = "<group>"; };
2527
6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeEditSourceEditorExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
2628
6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSourceEditorExampleApp.swift; sourceTree = "<group>"; };
2729
6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSourceEditorExampleDocument.swift; sourceTree = "<group>"; };
@@ -114,6 +116,7 @@
114116
children = (
115117
6C1365312B8A7B94004A1D18 /* ContentView.swift */,
116118
6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */,
119+
1CB30C392DAA1C28008058A7 /* IndentPicker.swift */,
117120
);
118121
path = Views;
119122
sourceTree = "<group>";
@@ -208,6 +211,7 @@
208211
6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */,
209212
6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */,
210213
6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */,
214+
1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */,
211215
6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */,
212216
6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */,
213217
);

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct ContentView: View {
2626
@State private var isInLongParse = false
2727
@State private var settingsIsPresented: Bool = false
2828
@State private var treeSitterClient = TreeSitterClient()
29+
@State private var indentOption: IndentOption = .spaces(count: 4)
2930

3031
init(document: Binding<CodeEditSourceEditorExampleDocument>, fileURL: URL?) {
3132
self._document = document
@@ -40,6 +41,7 @@ struct ContentView: View {
4041
theme: theme,
4142
font: font,
4243
tabWidth: 4,
44+
indentOption: indentOption,
4345
lineHeight: 1.2,
4446
wrapLines: wrapLines,
4547
cursorPositions: $cursorPositions,
@@ -85,6 +87,8 @@ struct ContentView: View {
8587
.frame(height: 12)
8688
LanguagePicker(language: $language)
8789
.buttonStyle(.borderless)
90+
IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty)
91+
.buttonStyle(.borderless)
8892
}
8993
.font(.subheadline)
9094
.fontWeight(.medium)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import SwiftUI
2+
import CodeEditSourceEditor
3+
4+
struct IndentPicker: View {
5+
@Binding var indentOption: IndentOption
6+
let enabled: Bool
7+
8+
private let possibleIndents: [IndentOption] = [
9+
.spaces(count: 4),
10+
.spaces(count: 2),
11+
.tab
12+
]
13+
14+
var body: some View {
15+
Picker(
16+
"Indent",
17+
selection: $indentOption
18+
) {
19+
ForEach(possibleIndents, id: \.optionDescription) { indent in
20+
Text(indent.optionDescription)
21+
.tag(indent)
22+
}
23+
}
24+
.labelsHidden()
25+
.disabled(!enabled)
26+
}
27+
}
28+
29+
extension IndentOption {
30+
var optionDescription: String {
31+
switch self {
32+
case .spaces(count: let count):
33+
return "Spaces (\(count))"
34+
case .tab:
35+
return "Tab"
36+
}
37+
}
38+
}
39+
40+
#Preview {
41+
IndentPicker(indentOption: .constant(.spaces(count: 4)), enabled: true)
42+
}

Package.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ let package = Package(
3333
.package(
3434
url: "https://github.com/ChimeHQ/TextFormation",
3535
from: "0.8.2"
36-
)
36+
),
37+
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0")
3738
],
3839
targets: [
3940
// A source editor with useful features for code editing.
@@ -55,6 +56,7 @@ let package = Package(
5556
dependencies: [
5657
"CodeEditSourceEditor",
5758
"CodeEditLanguages",
59+
.product(name: "CustomDump", package: "swift-custom-dump")
5860
],
5961
plugins: [
6062
.plugin(name: "SwiftLint", package: "SwiftLintPlugin")

Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift

Lines changed: 110 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,111 @@ import AppKit
99
import CodeEditTextView
1010

1111
extension TextViewController {
12-
/// Handles indentation and unindentation
12+
/// Handles indentation and unindentation for selected lines in the text view.
1313
///
14-
/// Handles the indentation of lines in the text view based on the current indentation option.
14+
/// This function modifies the indentation of the selected lines based on the current `indentOption`.
15+
/// It handles both indenting (moving text to the right) and unindenting (moving text to the left), with the
16+
/// behavior depending on whether `inwards` is `true` or `false`. It processes the `indentOption` to apply the
17+
/// correct number of spaces or tabs.
1518
///
16-
/// This function assumes that the document is formatted according to the current selected indentation option.
17-
/// It will not indent a tab character if spaces are selected, and vice versa. Ensure that the document is
18-
/// properly formatted before invoking this function.
19+
/// The function operates on **one-to-many selections**, where each selection can affect **one-to-many lines**.
20+
/// Each of those lines will be modified accordingly.
1921
///
20-
/// - Parameter inwards: A Boolean flag indicating whether to outdent (default is `false`).
22+
/// ```
23+
/// +----------------------------+
24+
/// | Selection 1 |
25+
/// | |
26+
/// | +------------------------+ |
27+
/// | | Line 1 (Modified) | |
28+
/// | +------------------------+ |
29+
/// | +------------------------+ |
30+
/// | | Line 2 (Modified) | |
31+
/// | +------------------------+ |
32+
/// +----------------------------+
33+
///
34+
/// +----------------------------+
35+
/// | Selection 2 |
36+
/// | |
37+
/// | +------------------------+ |
38+
/// | | Line 1 (Modified) | |
39+
/// | +------------------------+ |
40+
/// | +------------------------+ |
41+
/// | | Line 2 (Modified) | |
42+
/// | +------------------------+ |
43+
/// +----------------------------+
44+
/// ```
45+
///
46+
/// **Selection Updates**:
47+
/// The method will not update the selection (and its highlighting) until all lines for the given selection
48+
/// have been processed. This ensures that the selection updates are only applied after all indentations
49+
/// are completed, preventing issues where the selection might be updated incrementally during the processing
50+
/// of multiple lines.
51+
///
52+
/// - Parameter inwards: A `Bool` flag indicating whether to outdent (default is `false`).
53+
/// - If `inwards` is `true`, the text will be unindented.
54+
/// - If `inwards` is `false`, the text will be indented.
55+
///
56+
/// - Note: This function assumes that the document is formatted according to the selected indentation option.
57+
/// It will not indent a tab character if spaces are selected, and vice versa. Ensure that the document is
58+
/// properly formatted before invoking this function.
59+
///
60+
/// - Important: This method operates on the current selections in the `textView`. It performs a reverse iteration
61+
/// over the text selections, ensuring that edits do not affect the later selections.
62+
2163
public func handleIndent(inwards: Bool = false) {
22-
let indentationChars: String = indentOption.stringValue
2364
guard !cursorPositions.isEmpty else { return }
2465

2566
textView.undoManager?.beginUndoGrouping()
26-
for cursorPosition in self.cursorPositions.reversed() {
67+
var selectionIndex = 0
68+
textView.editSelections { textView, selection in
2769
// get lineindex, i.e line-numbers+1
28-
guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue }
29-
30-
for lineIndex in lineIndexes {
31-
adjustIndentation(
32-
lineIndex: lineIndex,
33-
indentationChars: indentationChars,
34-
inwards: inwards
35-
)
36-
}
70+
guard let lineIndexes = getHighlightedLines(for: selection.range) else { return }
71+
72+
adjustIndentation(lineIndexes: lineIndexes, inwards: inwards)
73+
74+
updateSelection(
75+
selection: selection,
76+
textSelectionCount: textView.selectionManager.textSelections.count,
77+
inwards: inwards,
78+
lineCount: lineIndexes.count,
79+
selectionIndex: selectionIndex
80+
)
81+
82+
selectionIndex += 1
3783
}
3884
textView.undoManager?.endUndoGrouping()
3985
}
4086

87+
private func updateSelection(
88+
selection: TextSelectionManager.TextSelection,
89+
textSelectionCount: Int,
90+
inwards: Bool,
91+
lineCount: Int,
92+
selectionIndex: Int
93+
) {
94+
let sectionModifier = calculateSelectionIndentationAdjustment(
95+
textSelectionCount: textSelectionCount,
96+
selectionIndex: selectionIndex,
97+
lineCount: lineCount
98+
)
99+
100+
let charCount = indentOption.charCount
101+
102+
selection.range.location += inwards ? -charCount * sectionModifier : charCount * sectionModifier
103+
if lineCount > 1 {
104+
let ammount = charCount * (lineCount - 1)
105+
selection.range.length += inwards ? -ammount : ammount
106+
}
107+
}
108+
109+
private func calculateSelectionIndentationAdjustment(
110+
textSelectionCount: Int,
111+
selectionIndex: Int,
112+
lineCount: Int
113+
) -> Int {
114+
return 1 + ((textSelectionCount - selectionIndex) - 1) * lineCount
115+
}
116+
41117
/// This method is used to handle tabs appropriately when multiple lines are selected,
42118
/// allowing normal use of tabs.
43119
///
@@ -66,6 +142,17 @@ extension TextViewController {
66142
return startLineInfo.index...endLineInfo.index
67143
}
68144

145+
private func adjustIndentation(lineIndexes: ClosedRange<Int>, inwards: Bool) {
146+
let indentationChars: String = indentOption.stringValue
147+
for lineIndex in lineIndexes {
148+
adjustIndentation(
149+
lineIndex: lineIndex,
150+
indentationChars: indentationChars,
151+
inwards: inwards
152+
)
153+
}
154+
}
155+
69156
private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) {
70157
guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return }
71158

@@ -86,7 +173,8 @@ extension TextViewController {
86173
) {
87174
textView.replaceCharacters(
88175
in: NSRange(location: lineInfo.range.lowerBound, length: 0),
89-
with: indentationChars
176+
with: indentationChars,
177+
skipUpdateSelection: true
90178
)
91179
}
92180

@@ -102,7 +190,8 @@ extension TextViewController {
102190

103191
textView.replaceCharacters(
104192
in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount),
105-
with: ""
193+
with: "",
194+
skipUpdateSelection: true
106195
)
107196
}
108197

@@ -114,7 +203,8 @@ extension TextViewController {
114203
if lineContent.first == "\t" {
115204
textView.replaceCharacters(
116205
in: NSRange(location: lineInfo.range.lowerBound, length: 1),
117-
with: ""
206+
with: "",
207+
skipUpdateSelection: true
118208
)
119209
}
120210
}

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,13 @@ extension TextViewController {
133133
guard isKeyWindow && isFirstResponder else { return event }
134134

135135
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
136-
return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue)
136+
let tabKey: UInt16 = 0x30
137+
138+
if event.keyCode == tabKey {
139+
return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue)
140+
} else {
141+
return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue)
142+
}
137143
}
138144
}
139145

Sources/CodeEditSourceEditor/Enums/IndentOption.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//
77

88
/// Represents what to insert on a tab key press.
9-
public enum IndentOption: Equatable {
9+
public enum IndentOption: Equatable, Hashable {
1010
case spaces(count: Int)
1111
case tab
1212

@@ -19,6 +19,16 @@ public enum IndentOption: Equatable {
1919
}
2020
}
2121

22+
/// Represents the number of chacters that indent represents
23+
var charCount: Int {
24+
switch self {
25+
case .spaces(let count):
26+
count
27+
case .tab:
28+
1
29+
}
30+
}
31+
2232
public static func == (lhs: IndentOption, rhs: IndentOption) -> Bool {
2333
switch (lhs, rhs) {
2434
case (.tab, .tab):

0 commit comments

Comments
 (0)