-
Notifications
You must be signed in to change notification settings - Fork 90
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Indent/Unindent Selected Lines (#266)
<!--- IMPORTANT: If this PR addresses multiple unrelated issues, it will be closed until separated. --> ### Description <!--- REQUIRED: Describe what changed in detail --> New Shortcuts: - <kbd>tab</kbd> increases indention level - <kbd>⇧</kbd> <kbd>tab</kbd> decreases indentation level - <kbd>⌘</kbd> <kbd>]</kbd> increases indention level - <kbd>⌘</kbd> <kbd>[</kbd> decreases indentation level The keyboard shortcuts also work with multiple cursors. **Blocker:** ~~1. The functions aren't connected to the editor's settings, meaning the space count is hardcoded to 2 spaces for now.~~ ~~2. In the current implementation, the user cannot use the Tab key as expected.~~ ### Related Issues <!--- REQUIRED: Tag all related issues (e.g. * #123) --> <!--- If this PR resolves the issue please specify (e.g. * closes #123) --> <!--- If this PR addresses multiple issues, these issues must be related to one other --> * closes #220 ### Checklist <!--- Add things that are not yet implemented above --> - [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 - [x] I've added tests ### Screenshots <!--- REQUIRED: if issue is UI related --> <!--- IMPORTANT: Fill out all required fields. Otherwise we might close this PR temporarily --> --------- Co-authored-by: Khan Winter <[email protected]>
1 parent
d73edc6
commit 1b54e15
Showing
4 changed files
with
286 additions
and
7 deletions.
There are no files selected for viewing
138 changes: 138 additions & 0 deletions
138
Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
// | ||
// TextViewController+IndentLines.swift | ||
// | ||
// | ||
// Created by Ludwig, Tom on 11.09.24. | ||
// | ||
|
||
import CodeEditTextView | ||
import AppKit | ||
|
||
extension TextViewController { | ||
/// Handles indentation and unindentation | ||
/// | ||
/// Handles the indentation of lines in the text view based on the current indentation option. | ||
/// | ||
/// 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. | ||
/// | ||
/// - Parameter inwards: A Boolean flag indicating whether to outdent (default is `false`). | ||
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() { | ||
// 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 | ||
) | ||
} | ||
} | ||
textView.undoManager?.endUndoGrouping() | ||
} | ||
|
||
/// This method is used to handle tabs appropriately when multiple lines are selected, | ||
/// allowing normal use of tabs. | ||
/// | ||
/// - Returns: A Boolean value indicating whether multiple lines are highlighted. | ||
func multipleLinesHighlighted() -> Bool { | ||
for cursorPosition in self.cursorPositions { | ||
if let startLineInfo = textView.layoutManager.textLineForOffset(cursorPosition.range.lowerBound), | ||
let endLineInfo = textView.layoutManager.textLineForOffset(cursorPosition.range.upperBound), | ||
startLineInfo.index != endLineInfo.index { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
private func getHighlightedLines(for range: NSRange) -> ClosedRange<Int>? { | ||
guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else { | ||
return nil | ||
} | ||
|
||
guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound), | ||
endLineInfo.index != startLineInfo.index else { | ||
return startLineInfo.index...startLineInfo.index | ||
} | ||
|
||
return startLineInfo.index...endLineInfo.index | ||
} | ||
|
||
private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) { | ||
guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } | ||
|
||
if inwards { | ||
if indentOption != .tab { | ||
removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) | ||
} else { | ||
removeLeadingTab(lineInfo: lineInfo) | ||
} | ||
} else { | ||
addIndentation(lineInfo: lineInfo, indentationChars: indentationChars) | ||
} | ||
} | ||
|
||
private func addIndentation( | ||
lineInfo: TextLineStorage<TextLine>.TextLinePosition, | ||
indentationChars: String | ||
) { | ||
textView.replaceCharacters( | ||
in: NSRange(location: lineInfo.range.lowerBound, length: 0), | ||
with: indentationChars | ||
) | ||
} | ||
|
||
private func removeLeadingSpaces( | ||
lineInfo: TextLineStorage<TextLine>.TextLinePosition, | ||
spaceCount: Int | ||
) { | ||
guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { return } | ||
|
||
let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: spaceCount) | ||
|
||
guard removeSpacesCount > 0 else { return } | ||
|
||
textView.replaceCharacters( | ||
in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount), | ||
with: "" | ||
) | ||
} | ||
|
||
private func removeLeadingTab(lineInfo: TextLineStorage<TextLine>.TextLinePosition) { | ||
guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { | ||
return | ||
} | ||
|
||
if lineContent.first == "\t" { | ||
textView.replaceCharacters( | ||
in: NSRange(location: lineInfo.range.lowerBound, length: 1), | ||
with: "" | ||
) | ||
} | ||
} | ||
|
||
func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int { | ||
var count = 0 | ||
for char in line { | ||
if char == " " { | ||
count += 1 | ||
} else { | ||
break // Stop as soon as a non-space character is encountered | ||
} | ||
// Stop early if we've counted the max number of spaces | ||
if count == maxCount { | ||
break | ||
} | ||
} | ||
|
||
return count | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
// | ||
// TextViewController+IndentTests.swift | ||
// CodeEditSourceEditor | ||
// | ||
// Created by Ludwig, Tom on 08.10.24. | ||
// | ||
|
||
import XCTest | ||
@testable import CodeEditSourceEditor | ||
|
||
final class TextViewControllerIndentTests: XCTestCase { | ||
var controller: TextViewController! | ||
|
||
override func setUpWithError() throws { | ||
controller = Mock.textViewController(theme: Mock.theme()) | ||
|
||
controller.loadView() | ||
} | ||
|
||
func testHandleIndentWithSpacesInwards() { | ||
controller.setText(" This is a test string") | ||
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] | ||
controller.cursorPositions = cursorPositions | ||
controller.handleIndent(inwards: true) | ||
|
||
XCTAssertEqual(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.handleIndent(inwards: true) | ||
|
||
XCTAssertEqual(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.cursorPositions = cursorPositions | ||
|
||
controller.handleIndent(inwards: false) | ||
|
||
XCTAssertEqual(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.cursorPositions = cursorPositions | ||
|
||
controller.handleIndent(inwards: true) | ||
|
||
XCTAssertEqual(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.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") | ||
} | ||
|
||
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))] | ||
controller.cursorPositions = cursorPositions | ||
|
||
controller.handleIndent() | ||
let expectedString = "\tThis is a test string\nWith multiple lines\nAnd some indentation" | ||
XCTAssertEqual(controller.string, expectedString) | ||
} | ||
|
||
func testHandleInwardIndentMultiLine() { | ||
controller.indentOption = .tab | ||
controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") | ||
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] | ||
controller.cursorPositions = cursorPositions | ||
|
||
controller.handleIndent(inwards: true) | ||
let expectedString = "This is a test string\nWith multiple lines\nAnd some indentation" | ||
XCTAssertEqual(controller.string, expectedString) | ||
} | ||
|
||
func testMultipleLinesHighlighted() { | ||
controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") | ||
var cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] | ||
controller.cursorPositions = cursorPositions | ||
|
||
XCTAssert(controller.multipleLinesHighlighted()) | ||
|
||
cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))] | ||
controller.cursorPositions = cursorPositions | ||
|
||
XCTAssertFalse(controller.multipleLinesHighlighted()) | ||
} | ||
} |