From ad754b74705c361af443e589a3e549bbf30fcf0f Mon Sep 17 00:00:00 2001 From: hi2gage Date: Fri, 11 Apr 2025 22:21:46 -0600 Subject: [PATCH 1/5] Add IndentPicker --- .../project.pbxproj | 4 ++ .../Views/ContentView.swift | 4 ++ .../Views/IndentPicker.swift | 42 +++++++++++++++++++ .../Enums/IndentOption.swift | 2 +- 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/IndentPicker.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 328c585b6..77b671085 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */; }; 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; }; 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */; }; 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */; }; @@ -22,6 +23,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndentPicker.swift; sourceTree = ""; }; 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeEditSourceEditorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSourceEditorExampleApp.swift; sourceTree = ""; }; 6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSourceEditorExampleDocument.swift; sourceTree = ""; }; @@ -114,6 +116,7 @@ children = ( 6C1365312B8A7B94004A1D18 /* ContentView.swift */, 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */, + 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */, ); path = Views; sourceTree = ""; @@ -208,6 +211,7 @@ 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */, 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */, 6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */, + 1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */, 6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */, 6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */, ); diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index e40d0c905..50c70de00 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { @State private var isInLongParse = false @State private var settingsIsPresented: Bool = false @State private var treeSitterClient = TreeSitterClient() + @State private var indentOption: IndentOption = .spaces(count: 4) init(document: Binding, fileURL: URL?) { self._document = document @@ -40,6 +41,7 @@ struct ContentView: View { theme: theme, font: font, tabWidth: 4, + indentOption: indentOption, lineHeight: 1.2, wrapLines: wrapLines, cursorPositions: $cursorPositions, @@ -85,6 +87,8 @@ struct ContentView: View { .frame(height: 12) LanguagePicker(language: $language) .buttonStyle(.borderless) + IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty) + .buttonStyle(.borderless) } .font(.subheadline) .fontWeight(.medium) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/IndentPicker.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/IndentPicker.swift new file mode 100644 index 000000000..b1894ccce --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/IndentPicker.swift @@ -0,0 +1,42 @@ +import SwiftUI +import CodeEditSourceEditor + +struct IndentPicker: View { + @Binding var indentOption: IndentOption + let enabled: Bool + + private let possibleIndents: [IndentOption] = [ + .spaces(count: 4), + .spaces(count: 2), + .tab + ] + + var body: some View { + Picker( + "Indent", + selection: $indentOption + ) { + ForEach(possibleIndents, id: \.optionDescription) { indent in + Text(indent.optionDescription) + .tag(indent) + } + } + .labelsHidden() + .disabled(!enabled) + } +} + +extension IndentOption { + var optionDescription: String { + switch self { + case .spaces(count: let count): + return "Spaces (\(count))" + case .tab: + return "Tab" + } + } +} + +#Preview { + IndentPicker(indentOption: .constant(.spaces(count: 4)), enabled: true) +} diff --git a/Sources/CodeEditSourceEditor/Enums/IndentOption.swift b/Sources/CodeEditSourceEditor/Enums/IndentOption.swift index 274fe5f0c..81682749d 100644 --- a/Sources/CodeEditSourceEditor/Enums/IndentOption.swift +++ b/Sources/CodeEditSourceEditor/Enums/IndentOption.swift @@ -6,7 +6,7 @@ // /// Represents what to insert on a tab key press. -public enum IndentOption: Equatable { +public enum IndentOption: Equatable, Hashable { case spaces(count: Int) case tab From baad0928b2ab24521246cc573127f618e52a1d05 Mon Sep 17 00:00:00 2001 From: hi2gage Date: Fri, 11 Apr 2025 15:28:31 -0600 Subject: [PATCH 2/5] Fix Tab button usage --- .../Controller/TextViewController+LoadView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index a4e2cf76d..0cab5edf2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -133,7 +133,13 @@ extension TextViewController { guard isKeyWindow && isFirstResponder else { return event } let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + let tabKey: UInt16 = 0x30 + + if event.keyCode == tabKey { + return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue) + } else { + return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + } } } From de4dfdb50be8bb7d6a7da439a61537500ec42a17 Mon Sep 17 00:00:00 2001 From: hi2gage Date: Mon, 10 Feb 2025 23:05:04 -0700 Subject: [PATCH 3/5] Add skipUpdateSelection --- .../Controller/TextViewController+IndentLines.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index cbe6f4be9..448c14374 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -86,7 +86,8 @@ extension TextViewController { ) { textView.replaceCharacters( in: NSRange(location: lineInfo.range.lowerBound, length: 0), - with: indentationChars + with: indentationChars, + skipUpdateSelection: true ) } @@ -102,7 +103,8 @@ extension TextViewController { textView.replaceCharacters( in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount), - with: "" + with: "", + skipUpdateSelection: true ) } @@ -114,7 +116,8 @@ extension TextViewController { if lineContent.first == "\t" { textView.replaceCharacters( in: NSRange(location: lineInfo.range.lowerBound, length: 1), - with: "" + with: "", + skipUpdateSelection: true ) } } From fde4174d48d0c2e0154a85e224d05bda7840a2cb Mon Sep 17 00:00:00 2001 From: hi2gage Date: Fri, 11 Apr 2025 22:34:43 -0600 Subject: [PATCH 4/5] Update IndentLines functions --- .../TextViewController+IndentLines.swift | 121 +++++++++++++++--- .../Enums/IndentOption.swift | 10 ++ 2 files changed, 114 insertions(+), 17 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 448c14374..9ef797219 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -9,35 +9,111 @@ import AppKit import CodeEditTextView extension TextViewController { - /// Handles indentation and unindentation + /// Handles indentation and unindentation for selected lines in the text view. /// - /// Handles the indentation of lines in the text view based on the current indentation option. + /// This function modifies the indentation of the selected lines based on the current `indentOption`. + /// It handles both indenting (moving text to the right) and unindenting (moving text to the left), with the + /// behavior depending on whether `inwards` is `true` or `false`. It processes the `indentOption` to apply the + /// correct number of spaces or tabs. /// - /// This function assumes that the document is formatted according to the current selected indentation option. - /// It will not indent a tab character if spaces are selected, and vice versa. Ensure that the document is - /// properly formatted before invoking this function. + /// The function operates on **one-to-many selections**, where each selection can affect **one-to-many lines**. + /// Each of those lines will be modified accordingly. /// - /// - Parameter inwards: A Boolean flag indicating whether to outdent (default is `false`). + /// ``` + /// +----------------------------+ + /// | Selection 1 | + /// | | + /// | +------------------------+ | + /// | | Line 1 (Modified) | | + /// | +------------------------+ | + /// | +------------------------+ | + /// | | Line 2 (Modified) | | + /// | +------------------------+ | + /// +----------------------------+ + /// + /// +----------------------------+ + /// | Selection 2 | + /// | | + /// | +------------------------+ | + /// | | Line 1 (Modified) | | + /// | +------------------------+ | + /// | +------------------------+ | + /// | | Line 2 (Modified) | | + /// | +------------------------+ | + /// +----------------------------+ + /// ``` + /// + /// **Selection Updates**: + /// The method will not update the selection (and its highlighting) until all lines for the given selection + /// have been processed. This ensures that the selection updates are only applied after all indentations + /// are completed, preventing issues where the selection might be updated incrementally during the processing + /// of multiple lines. + /// + /// - Parameter inwards: A `Bool` flag indicating whether to outdent (default is `false`). + /// - If `inwards` is `true`, the text will be unindented. + /// - If `inwards` is `false`, the text will be indented. + /// + /// - Note: This function assumes that the document is formatted according to the selected indentation option. + /// It will not indent a tab character if spaces are selected, and vice versa. Ensure that the document is + /// properly formatted before invoking this function. + /// + /// - Important: This method operates on the current selections in the `textView`. It performs a reverse iteration + /// over the text selections, ensuring that edits do not affect the later selections. + public func handleIndent(inwards: Bool = false) { - let indentationChars: String = indentOption.stringValue guard !cursorPositions.isEmpty else { return } textView.undoManager?.beginUndoGrouping() - for cursorPosition in self.cursorPositions.reversed() { + var selectionIndex = 0 + textView.editSelections { textView, selection in // get lineindex, i.e line-numbers+1 - guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue } - - for lineIndex in lineIndexes { - adjustIndentation( - lineIndex: lineIndex, - indentationChars: indentationChars, - inwards: inwards - ) - } + guard let lineIndexes = getHighlightedLines(for: selection.range) else { return } + + adjustIndentation(lineIndexes: lineIndexes, inwards: inwards) + + updateSelection( + selection: selection, + textSelectionCount: textView.selectionManager.textSelections.count, + inwards: inwards, + lineCount: lineIndexes.count, + selectionIndex: selectionIndex + ) + + selectionIndex += 1 } textView.undoManager?.endUndoGrouping() } + private func updateSelection( + selection: TextSelectionManager.TextSelection, + textSelectionCount: Int, + inwards: Bool, + lineCount: Int, + selectionIndex: Int + ) { + let sectionModifier = calculateSelectionIndentationAdjustment( + textSelectionCount: textSelectionCount, + selectionIndex: selectionIndex, + lineCount: lineCount + ) + + let charCount = indentOption.charCount + + selection.range.location += inwards ? -charCount * sectionModifier : charCount * sectionModifier + if lineCount > 1 { + let ammount = charCount * (lineCount - 1) + selection.range.length += inwards ? -ammount : ammount + } + } + + private func calculateSelectionIndentationAdjustment( + textSelectionCount: Int, + selectionIndex: Int, + lineCount: Int + ) -> Int { + return 1 + ((textSelectionCount - selectionIndex) - 1) * lineCount + } + /// This method is used to handle tabs appropriately when multiple lines are selected, /// allowing normal use of tabs. /// @@ -66,6 +142,17 @@ extension TextViewController { return startLineInfo.index...endLineInfo.index } + private func adjustIndentation(lineIndexes: ClosedRange, inwards: Bool) { + let indentationChars: String = indentOption.stringValue + for lineIndex in lineIndexes { + adjustIndentation( + lineIndex: lineIndex, + indentationChars: indentationChars, + inwards: inwards + ) + } + } + private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) { guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } diff --git a/Sources/CodeEditSourceEditor/Enums/IndentOption.swift b/Sources/CodeEditSourceEditor/Enums/IndentOption.swift index 81682749d..eaffde6d2 100644 --- a/Sources/CodeEditSourceEditor/Enums/IndentOption.swift +++ b/Sources/CodeEditSourceEditor/Enums/IndentOption.swift @@ -19,6 +19,16 @@ public enum IndentOption: Equatable, Hashable { } } + /// Represents the number of chacters that indent represents + var charCount: Int { + switch self { + case .spaces(let count): + count + case .tab: + 1 + } + } + public static func == (lhs: IndentOption, rhs: IndentOption) -> Bool { switch (lhs, rhs) { case (.tab, .tab): From e0ab576be2c4095a2a68873957d8b87cb77b1b9d Mon Sep 17 00:00:00 2001 From: hi2gage Date: Fri, 11 Apr 2025 22:35:00 -0600 Subject: [PATCH 5/5] Fixup tests --- .../xcshareddata/swiftpm/Package.resolved | 18 +++++++ Package.swift | 4 +- .../TextViewController+IndentTests.swift | 53 +++++++++++++++---- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0007c56b2..8c2c83e2e 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -36,6 +36,15 @@ "version" : "1.1.4" } }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", @@ -80,6 +89,15 @@ "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", "version" : "0.23.2" } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 795b14b4e..a26567e2d 100644 --- a/Package.swift +++ b/Package.swift @@ -33,7 +33,8 @@ let package = Package( .package( url: "https://github.com/ChimeHQ/TextFormation", from: "0.8.2" - ) + ), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") ], targets: [ // A source editor with useful features for code editing. @@ -55,6 +56,7 @@ let package = Package( dependencies: [ "CodeEditSourceEditor", "CodeEditLanguages", + .product(name: "CustomDump", package: "swift-custom-dump") ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin") diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift index 45113436d..319043c11 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift @@ -7,6 +7,8 @@ import XCTest @testable import CodeEditSourceEditor +@testable import CodeEditTextView +import CustomDump final class TextViewControllerIndentTests: XCTestCase { var controller: TextViewController! @@ -21,73 +23,102 @@ final class TextViewControllerIndentTests: XCTestCase { controller.setText(" This is a test string") let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.handleIndent(inwards: true) - XCTAssertEqual(controller.string, "This is a test string") + expectNoDifference(controller.string, "This is a test string") // Normally, 4 spaces are used for indentation; however, now we only insert 2 leading spaces. // The outcome should be the same, though. controller.setText(" This is a test string") controller.cursorPositions = cursorPositions + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.handleIndent(inwards: true) - XCTAssertEqual(controller.string, "This is a test string") + expectNoDifference(controller.string, "This is a test string") } func testHandleIndentWithSpacesOutwards() { controller.setText("This is a test string") let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions controller.handleIndent(inwards: false) - XCTAssertEqual(controller.string, " This is a test string") + expectNoDifference(controller.string, " This is a test string") } func testHandleIndentWithTabsInwards() { controller.setText("\tThis is a test string") controller.indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions controller.handleIndent(inwards: true) - XCTAssertEqual(controller.string, "This is a test string") + expectNoDifference(controller.string, "This is a test string") } func testHandleIndentWithTabsOutwards() { controller.setText("This is a test string") controller.indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions controller.handleIndent() // Normally, we expect nothing to happen because only one line is selected. // However, this logic is not handled inside `handleIndent`. - XCTAssertEqual(controller.string, "\tThis is a test string") + expectNoDifference(controller.string, "\tThis is a test string") } func testHandleIndentMultiLine() { controller.indentOption = .tab - controller.setText("This is a test string\nWith multiple lines\nAnd some indentation") - let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))] + let strings: [(NSString, Int)] = [ + ("This is a test string\n", 0), + ("With multiple lines\n", 22), + ("And some indentation", 42), + ] + for (insertedString, location) in strings { + controller.textView.replaceCharacters( + in: [NSRange(location: location, length: 0)], + with: insertedString as String + ) + } + + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 62))] + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 62))] controller.cursorPositions = cursorPositions controller.handleIndent() - let expectedString = "\tThis is a test string\nWith multiple lines\nAnd some indentation" - XCTAssertEqual(controller.string, expectedString) + let expectedString = "\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation" + expectNoDifference(controller.string, expectedString) } func testHandleInwardIndentMultiLine() { controller.indentOption = .tab - controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") + let strings: [(NSString, NSRange)] = [ + ("\tThis is a test string\n", NSRange(location: 0, length: 0)), + ("\tWith multiple lines\n", NSRange(location: 23, length: 0)), + ("\tAnd some indentation", NSRange(location: 44, length: 0)), + ] + for (insertedString, location) in strings { + controller.textView.replaceCharacters( + in: [location], + with: insertedString as String + ) + } + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] + controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 62))] controller.cursorPositions = cursorPositions controller.handleIndent(inwards: true) let expectedString = "This is a test string\nWith multiple lines\nAnd some indentation" - XCTAssertEqual(controller.string, expectedString) + expectNoDifference(controller.string, expectedString) } func testMultipleLinesHighlighted() {